FileTree Component with interactions using TailwindCSS
6 min read

What we'll build
File Tree
- data.json
- package.json
- .eslintrc.json
- tailwind.config.js
We pass a directories object which is an array of Directory which has the ff. shape:
export type Directory = {
path: string;
name: string;
subfolders?: Directory[];
};
const directories: Directory[] = [
{
path: '/components',
name: 'components',
subfolders: [
{
path: '/components/features',
name: 'features',
},
],
},
];Creating a <FileList /> Component
import { Directory } from './directory';
import { cn } from '@lib/utils';
export function FileList({ fileList }: { fileList: Directory[] }) {
return (
<ul className={cn('pl-6 select-none overflow-hidden text-sm')}>
{fileList.map((file) => (
<li>....</li>
))}
</ul>
);
}The <FileList /> receives a prop called fileList which is an array of type Directory.
Creating a <FileNode /> Component
Next, we create the <FileNode /> which is responsible for rendering each directory.
import { Directory } from './directory';
import { FileIcon, FolderIcon } from 'lucide-react';
export function FileNode({ directory }: { directory: Directory }) {
const isDirectory = directory.subfolders ? true : false;
return (
<li>
<div className="flex items-center gap-3 px-3 py-2 rounded">
{isDirectory ? (
<FolderIcon className="size-4 text-amber-500" />
) : (
<FileIcon className="size-4 text-sky-300" />
)}
<span>{directory.name}</span>
</div>
</li>
);
}- It receives a
directoryprop which is of typeDirectory; - It renders a Folder icon besides the name if the directory has subfolder(s), a File icon is rendered otherwise.
So then, we can replace the li in the FileList component with the FileNode component:
import { FileNode } from './file-node';
import { Directory } from './directory';
export function FileList({ fileList }: { fileList: Directory[] }) {
return (
<ul className={cn('pl-6 select-none overflow-hidden text-sm')}>
{fileList.map((directory) => (
<FileNode key={directory.path} directory={directory} />
))}
</ul>
);
}Add Toggle behavior if FileNode has Subfolder(s)
We aim to bring in a toggling functionality but we will implement it without JavaScript. We will leverage the power of TailwindCSS and HTML label and input:checkbox elements. So, if the directory has subfolders, we render a label within which is a checkbox.
import { FileIcon, FolderIcon } from 'lucide-react';
import { Directory } from './directory';
import { FileList } from './file-list';
export function FileNode({ directory }: { directory: Directory }) {
if (directory.subfolders) {
const nodeId = [directory.path, directory.name].join('-');
return (
<li>
<label
htmlFor={nodeId}
className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-800 cursor-pointer"
>
<FolderIcon className="size-4 text-amber-500" />
<span>{directory.name}</span>
<input
defaultChecked={false}
type="checkbox"
name={nodeId}
id={nodeId}
hidden
/>
</label>
</li>
);
}
return (
<li>
<div className="flex items-center gap-3 px-3 py-2 rounded">
<FileIcon className="size-4 text-sky-300" />
<span>{directory.name}</span>
</div>
</li>
);
}Note:
- the
nodeIdin line 7 is defined to make sure the id of the checkbox is unique since we can have directories with the same file name. - the
htmlForof the label element should point to the value of theidof the checkbox element. - the checkbox has a
hiddenattribute so it won't be visible.
Let us leave this for now. We will get back to this toggle behavior later.
Render nested directories and/or files (Recursion!)
Our implementation so far only renders the first-level directories and files relative to the root path we provided ('components', in this case). To be able to render the nested directories and files, we render a FileList next to the label element.
import { FileIcon, FolderIcon } from 'lucide-react';
import { Directory } from './directory';
import { FileList } from './file-list';
export function FileNode({ directory }: { directory: Directory }) {
if (directory.subfolders) {
const nodeId = [directory.path, directory.name].join('-');
return (
<li>
<label
htmlFor={nodeId}
className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-800 cursor-pointer"
>
<FolderIcon className="size-4 text-amber-500" />
<span>{directory.name}</span>
<input
defaultChecked={false}
type="checkbox"
name={nodeId}
id={nodeId}
hidden
/>
</label>
<FileList fileList={directory.subfolders} />
</li>
);
}
return (
<li>
<div className="flex items-center gap-3 px-3 py-2 rounded">
<FileIcon className="size-4 text-sky-300" />
<span>{directory.name}</span>
</div>
</li>
);
}<FileList /> will now render the nested directories and files in a recursive manner.
Enter TailwindCSS peer and has
Now, going back to the toggle behavior. The first step is to add TailwindCSS' peer class to the label element:
<label className="peer"></label>By this change we can style its FileList sibling (which has a ul as the root element) based on the state of the checkbox within the label.
import { cn } from '@lib/utils';
import { Directory } from './directory';
import { FileNode } from './file-node';
export function FileList({ fileList }: { fileList: Directory[] }) {
return (
// other classes are omitted for brevity
<ul className={cn('hidden peer-has-[:checked]:block')}>
{fileList.map((directory) => (
<FileNode key={directory.path} directory={directory} />
))}
</ul>
);
}By default the nested <FileList /> is hidden but if its peer, which means its label sibling, has a checkbox in it that is checked, then we display it with the class block.
Enter TailwindCSS group
Next, let us render an open folder icon if the directory is toggled "open".
<label htmlFor={nodeId} className="peer group">
<FolderIcon className="size-4 text-amber-500 group-has-[:checked]:hidden" />
<FolderOpenIcon className="size-4 text-amber-500 hidden group-has-[:checked]:inline-block" />
<span>{file.name}</span>
<input
defaultChecked={false}
type="checkbox"
name={nodeId}
id={nodeId}
hidden
/>
</label>- We add the
groupclass to the label element; - We render a
FolderIconbut when its parent which is the label has a checked descendant, thehiddenclass will be applied to it to hide it; - We render a
FolderOpenIconwhich is hidden by default but when its parent which is the label has a checked descendant, theinline-blockclass will be applied to it to display it;
Final Touch
For aesthetic purposes, let us that line that's visible when a directory is expanded.
<li className="relative before:absolute before:top-9 before:bottom-3 before:left-5 before:w-px before:bg-gray-800">
<label>.....</label>
<FileList fileList={fileList} />
</li>This is just using the li element's pseudo ::before element.
Result
File Tree
- data.json
- package.json
- .eslintrc.json
- tailwind.config.js
Conclusion
Interactive UIs can be achieved even without JavaScript by leveraging the web platform's features which are made available to us by TailwindCSS and HTML itself.