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.
- layout.tsx
- page.tsx
- globals.css
- components.json
- package.json
- base.json
- nextjs.json
- turbo.json
- package.json
Install
bunx shadcn@latest add @vernostudio/file-treeOr, 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.jsonWriting 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.
- package.json
- README.md
- turbo.json
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.
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.
"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>
);