shadimage
vernostudio
uifile-treesource: ok · hit

File Tree

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

npx shadcn add "https://verno-studio.vercel.app/r/file-tree.json"
free

Interaction

accordion

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 5:55:28 PM

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 touch-manipulation 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-200 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 touch-manipulation 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>
);

More from vernostudio

Browse all components in this library →