File Tree

A directory listing with collapsible folders, built from nested disclosures rather than a tree role.

A directory listing you can open a folder at a time. Write it the way you would draw it, nested, and the nesting is the structure.

      • components.json
      • package.json
  • turbo.json
  • package.json
What verno create writes for a Turborepo project. The two folders on the path you care about start open.

Install

bunx shadcn@latest add @vernostudio/file-tree

Or, if components.json has no registry namespace to resolve @vernostudio against, point the CLI straight at the file:

bunx shadcn@latest add https://verno-studio.vercel.app/r/file-tree.json

Writing one

Three pieces. FileTree holds the listing, Folder nests, File sits at the end of a branch.

<FileTree>
  <Folder defaultOpen name="apps">
    <Folder name="web">
      <File name="page.tsx" />
    </Folder>
  </Folder>
  <File name="turbo.json" />
</FileTree>

There is no depth prop. A folder's indentation comes from the list it is in, so moving a branch means moving the markup and nothing else.

What starts open

defaultOpen opens a folder on mount. Use it for the path you are pointing at and leave the rest closed, so the shape of the project is legible before anyone clicks.

  • turbo.json
Everything closed. Good for a listing that sits beside the thing it describes rather than being the point.

A folder remembers what was open inside it. Collapse a parent and open it again and the branch is as you left it.

Files that go somewhere

href turns a row into a link. Without it the name is text, because a row with nothing to activate should not be focusable.

Two files that link to their own pages.

Keyboard

Folders are buttons. Tab reaches them, Enter and

Space open them. That is the whole model, and none of it is custom.

This is deliberately not a role="tree". A role is a promise: tree commits to the full ARIA authoring practices keyboard model, roving tabindex and arrow navigation included. That earns its cost on a payload of hundreds of rows, which is why JSON View implements it. A directory listing of ten rows is a set of disclosures, and saying so costs a reader nothing to learn.

Source

One file, no dependencies beyond React and your cn.

components/file-tree.tsx
"use client";

import { useId, useState } from "react";
import type { ReactNode } from "react";

import { cn } from "@/lib/utils";

interface FileTreeProps {
  readonly children: ReactNode;
  readonly className?: string;
}

export const FileTree = ({ children, className }: FileTreeProps) => (
  <ul
    className={cn(
      "m-0 flex w-full list-none flex-col gap-0 p-0 font-mono text-gray-1000 text-sm [font-variant-ligatures:none]",
      className,
    )}
  >
    {children}
  </ul>
);

interface FolderProps {
  readonly name: string;
  /** Open on mount. Use it for the path you want the reader to look at. */
  readonly defaultOpen?: boolean;
  readonly children?: ReactNode;
}

export const Folder = ({ name, defaultOpen = false, children }: FolderProps) => {
  const [open, setOpen] = useState(defaultOpen);
  const id = useId();

  return (
    <li>
      <button
        aria-controls={children ? id : undefined}
        aria-expanded={open}
        className="flex min-h-7 w-full cursor-pointer items-center gap-1.5 rounded-sm py-1 pe-2 ps-1 text-start text-gray-1000 focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-gray-1000 focus-visible:outline-solid [@media(hover:hover)_and_(pointer:fine)]:hover:bg-gray-100"
        onClick={() => setOpen((current) => !current)}
        type="button"
      >
        <svg
          aria-hidden="true"
          className={cn(
            "size-4 shrink-0 text-gray-700 transition-transform duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
            open && "rotate-90",
          )}
          fill="none"
          viewBox="0 0 16 16"
        >
          <path
            clipRule="evenodd"
            d="M6.75 3.94L7.28 4.47L10.1 7.29C10.49 7.68 10.49 8.32 10.1 8.71L7.28 11.53L6.75 12.06L5.69 11L6.22 10.47L8.69 8L6.22 5.53L5.69 5L6.75 3.94Z"
            fill="currentColor"
            fillRule="evenodd"
          />
        </svg>

        <span aria-hidden="true" className="relative inline-flex size-4 shrink-0 text-gray-700">
          <svg
            className={cn(
              "absolute inset-0 size-4 transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
              open ? "scale-[0.25] opacity-0 blur-xs" : "scale-100 opacity-100 blur-0",
            )}
            fill="none"
            viewBox="0 0 16 16"
          >
            <path
              d="M1.5 3.5C1.5 2.95 1.95 2.5 2.5 2.5H6.09C6.36 2.5 6.61 2.61 6.8 2.79L8 4H13C13.55 4 14 4.45 14 5V12C14 12.55 13.55 13 13 13H2.5C1.95 13 1.5 12.55 1.5 12V3.5Z"
              fill="currentColor"
            />
          </svg>
          <svg
            className={cn(
              "absolute inset-0 size-4 transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
              open ? "scale-100 opacity-100 blur-0" : "scale-[0.25] opacity-0 blur-xs",
            )}
            fill="none"
            viewBox="0 0 16 16"
          >
            <path
              d="M1.5 3.5C1.5 2.95 1.95 2.5 2.5 2.5H6.09C6.36 2.5 6.61 2.61 6.8 2.79L8 4H12.5C13.05 4 13.5 4.45 13.5 5V5.5H4.5C4.06 5.5 3.67 5.79 3.55 6.21L2.2 11H2C1.72 11 1.5 10.78 1.5 10.5V3.5ZM3.5 12.5L4.86 7.71C4.92 7.5 5.11 7.36 5.33 7.36H14.2C14.53 7.36 14.77 7.68 14.68 8L13.5 12.14C13.38 12.56 12.99 12.85 12.55 12.85H3.5V12.5Z"
              fill="currentColor"
            />
          </svg>
        </span>
        <span className="truncate" title={name}>
          {name}
        </span>
      </button>
      {children ? (
        <ul
          className="m-0 ms-3 flex list-none flex-col gap-0 border-gray-200 border-s ps-2"
          hidden={!open}
          id={id}
        >
          {children}
        </ul>
      ) : null}
    </li>
  );
};

const FileGlyph = () => (
  <svg aria-hidden="true" className="size-4 shrink-0 text-gray-700" fill="none" viewBox="0 0 16 16">
    <path
      clipRule="evenodd"
      d="M3.5 2C3.5 1.72 3.72 1.5 4 1.5H9.25L13 5.25V14C13 14.28 12.78 14.5 12.5 14.5H4C3.72 14.5 3.5 14.28 3.5 14V2ZM9 2.75V5.5H11.75L9 2.75Z"
      fill="currentColor"
    />
  </svg>
);

interface FileProps {
  readonly name: string;
  /** Makes the row a link. Without it the name is text, with nothing to activate. */
  readonly href?: string;
}

export const File = ({ name, href }: FileProps) => (
  <li>
    {href ? (
      <a
        className="flex min-h-7 w-full cursor-pointer items-center gap-1.5 rounded-sm py-1 pe-2 ps-1 text-start text-gray-1000 no-underline focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-gray-1000 focus-visible:outline-solid [@media(hover:hover)_and_(pointer:fine)]:hover:bg-gray-100"
        href={href}
      >
        <span aria-hidden="true" className="size-4 shrink-0" />
        <FileGlyph />
        <span className="truncate" title={name}>
          {name}
        </span>
      </a>
    ) : (
      <span className="flex min-h-7 w-full items-center gap-1.5 rounded-sm py-1 pe-2 ps-1 text-start text-gray-1000">
        <span aria-hidden="true" className="size-4 shrink-0" />
        <FileGlyph />
        <span className="truncate" title={name}>
          {name}
        </span>
      </span>
    )}
  </li>
);