An example of how to use a recursive React component to render a treenode data structure of arbitrary depth.
Watch the tutorial on YouTube.
Code
// filesystem-item.tsx
'use client';
import { ChevronRightIcon } from '@heroicons/react/16/solid';
import { DocumentIcon, FolderIcon } from '@heroicons/react/24/solid';
import { useState } from 'react';
type Node = {
name: string;
nodes?: Node[];
};
export function FilesystemItem({ node }: { node: Node }) {
let [isOpen, setIsOpen] = useState(false);
return (
<li key={node.name}>
<span className="flex items-center gap-1.5 py-1">
{node.nodes && node.nodes.length > 0 && (
<button onClick={() => setIsOpen(!isOpen)} className="p-1 -m-1">
<ChevronRightIcon
className={`size-4 text-gray-500 ${isOpen ? 'rotate-90' : ''}`}
/>
</button>
)}
{node.nodes ? (
<FolderIcon
className={`size-6 text-sky-500 ${
node.nodes.length === 0 ? 'ml-[22px]' : ''
}`}
/>
) : (
<DocumentIcon className="ml-[22px] size-6 text-gray-900" />
)}
{node.name}
</span>
{isOpen && (
<ul className="pl-6">
{node.nodes?.map((node) => (
<FilesystemItem node={node} key={node.name} />
))}
</ul>
)}
</li>
);
}
Usage
import { FilesystemItem } from './filesystem-item';
export default function Page() {
return (
<ul>
{nodes.map((node) => (
<FilesystemItem node={node} key={node.name} />
))}
</ul>
);
}
type Node = {
name: string;
nodes?: Node[];
};
const nodes: Node[] = [
{
name: 'Home',
nodes: [
{
name: 'Movies',
nodes: [
{
name: 'Action',
nodes: [
{
name: '2000s',
nodes: [
{ name: 'Gladiator.mp4' },
{ name: 'The-Dark-Knight.mp4' },
],
},
{ name: '2010s', nodes: [] },
],
},
{
name: 'Comedy',
nodes: [{ name: '2000s', nodes: [{ name: 'Superbad.mp4' }] }],
},
{
name: 'Drama',
nodes: [
{ name: '2000s', nodes: [{ name: 'American-Beauty.mp4' }] },
],
},
],
},
{
name: 'Music',
nodes: [
{ name: 'Rock', nodes: [] },
{ name: 'Classical', nodes: [] },
],
},
{ name: 'Pictures', nodes: [] },
{
name: 'Documents',
nodes: [],
},
{ name: 'passwords.txt' },
],
},
];
Animation
The demo above uses Framer Motion to animate the chevron and subfolders as they expand and collapse.
// filesystem-item-animated.tsx
'use client';
import { ChevronRightIcon } from '@heroicons/react/16/solid';
import { DocumentIcon, FolderIcon } from '@heroicons/react/24/solid';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
type Node = {
name: string;
nodes?: Node[];
};
export function FilesystemItemAnimated({ node }: { node: Node }) {
let [isOpen, setIsOpen] = useState(false);
return (
<li key={node.name}>
<span className="flex items-center gap-1.5 py-1">
{node.nodes && node.nodes.length > 0 && (
<button onClick={() => setIsOpen(!isOpen)} className="p-1 -m-1">
<motion.span
animate={{ rotate: isOpen ? 90 : 0 }}
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
className="flex"
>
<ChevronRightIcon className="size-4 text-gray-500" />
</motion.span>
</button>
)}
{node.nodes ? (
<FolderIcon
className={`size-6 text-sky-500 ${
node.nodes.length === 0 ? 'ml-[22px]' : ''
}`}
/>
) : (
<DocumentIcon className="ml-[22px] size-6 text-gray-900" />
)}
{node.name}
</span>
<AnimatePresence>
{isOpen && (
<motion.ul
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
className="pl-6 overflow-hidden flex flex-col justify-end"
>
{node.nodes?.map((node) => (
<FilesystemItemAnimated node={node} key={node.name} />
))}
</motion.ul>
)}
</AnimatePresence>
</li>
);
}