FileTree Component with interactions using TailwindCSS

6 min read

Learn how to create a FileTree component with interactions while leveraging the features of TailwindCSS.

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

file-list.tsx
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.

file-node.tsx
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 directory prop which is of type Directory;
  • 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:

file-list.tsx
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.

file-node.tsx
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 nodeId in 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 htmlFor of the label element should point to the value of the id of the checkbox element.
  • the checkbox has a hidden attribute 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.

file-node.tsx
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.

file-list.tsx
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 group class to the label element;
  • We render a FolderIcon but when its parent which is the label has a checked descendant, the hidden class will be applied to it to hide it;
  • We render a FolderOpenIcon which is hidden by default but when its parent which is the label has a checked descendant, the inline-block class 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.

file-node.tsx
<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.