Skip to content

FreeIn reviewv1.0.0

File Tree

A repository as a console: branch header, a keyboard tree with indent guides, and a detail pane.
npx shadcn@latest add @vibebox/file-tree

Controls

About

Show what a client receives: the repository, folder by folder, with a sentence about each part. Built for handover, pricing and documentation pages, and used by Launchpad to show what the Starter package delivers.

Use it when

  • Showing a project's structure on a pricing, handover or docs page.
  • Explaining a scaffold or template.

Skip it when

  • Browsing thousands of files: it renders every open row.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/file-tree

AI prompt

Everything an AI coding tool needs to rebuild this component the way it looks here: colours, fonts, the art it draws, props and rules.

Read the prompt
Build the VibeBox Motion component "File Tree" (Devices) in a Next.js App Router project with Tailwind CSS v4.

What it is
- A repository as a console: branch header, a keyboard tree with indent guides, and a detail pane.
- A file tree for showing what a project contains: a header with repo name and branch, folders that open and close, file-type chips, indent guides, a staggered reveal and a detail pane that explains the selected file. WAI-ARIA tree keyboard model.

Install
- `npx shadcn@latest add @vibebox/file-tree`
- Registry: add `"@vibebox": "https://motion.vibeboxph.com/r/{name}.json"` to components.json, then run the command. The source lands in `components/vibebox/file-tree` and is yours to edit.

Visual spec
- Colours (the VibeBox spectrum, the same in light and dark):
    - --spectrum-1: #00c5fc (cyan)
    - --spectrum-2: #1d6ef0 (blue)
    - --spectrum-3: #5225ed (indigo)
    - --spectrum-4: #e434dc (magenta)
    - --spectrum-5: #fd3aa9 (pink)
    - --spectrum-6: #f98472 (coral)
    - --spectrum-7: #fcbe72 (amber)
- Ink surface (dark): background #05070c, card #0a0d15, text #f5f6fa, muted text #a3abbd, border rgba(255,255,255,0.08).
- Paper surface (light): background #f3f0ea, card #fbfaf7, text #0c0e14, muted text #4a4f5c, border rgba(12,14,20,0.12).
- Fonts:
    - Display and body: Figtree (400, 500, 600, 700), falling back to ui-sans-serif, system-ui.
    - Emphasis: Fraunces italic, for one or two words of a heading.
    - Labels and code: JetBrains Mono (400, 500), uppercase labels with 0.14em tracking.
- Shape: radius 8 to 12px on cards and panels; pills may be fully round. Hairline borders, soft glow instead of heavy shadow.
- Motion: animate transform and opacity only; fast ease-out (cubic-bezier(0.16, 1, 0.3, 1)), 140ms for micro-interactions, 320 to 520ms for reveals.

Art and media
- Built from HTML, CSS and inline SVG only; no image files or video.

Props
- `tree`: `FileNode[]` (default a sample repo). The repo's top level: { name, note?, tag?, children? }.
- `title`: `string` (default "client-app"). The repository name in the header.
- `branch`: `string` (default "main"). The branch pill.
- `open`: `"all" | number | string[]` (default 1). Folders open at first: all, a depth, or a list of paths.
- `selected`: `string`. The path selected at first.
- `detail`: `boolean` (default true). Show the detail pane.
- `className`: `string`. Merged with cn(); sets the width and margins.

Behaviour and access
- Reduced motion: The row reveal is off; nothing else animates.
- Keyboard: Arrow keys move, Right opens or enters a folder, Left closes or goes to the parent, Home and End jump, Enter toggles; one tab stop.
- Screen readers: role=tree with treeitem rows, levels and expanded state; the detail pane is a polite live region.
- Touch: Rows are 32 px tall; tapping selects and toggles.
- Budget: about 3 KB of JavaScript, WebGL none, GPU low. It pauses while off screen.

Theming
The surface reads `--g-tint`, `--g-edge`, `--g-ink`, `--g-blur` and `--g-radius` from its root; the demo backdrop uses `--spectrum-*`.

Notes
Add the VibeBox Motion File Tree with `npx shadcn@latest add @vibebox/file-tree`. Pass `tree` as nested `{ name, note, children }` objects describing the repository you deliver, a `title` for the repo and a `branch`, and open the top level with `open={1}`. Keep the tree keyboard model, the detail pane and the reduced-motion rule; write one honest sentence per important file.

Rules
- Keep the licence header on every installed file.
- Original code only: do not copy components from other libraries, and do not add GSAP.
- Use tokens for colour, type and radius; no hard-coded brand values.
- Server Components by default; `"use client"` only for the island.

Code

2 files
components/vibebox/file-tree/file-tree.client.tsx
/**
 * VibeBox Motion · File Tree
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useMemo, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type FileNode = {
  name: string;
  /** A short line about what it is; shown in the detail pane when it is selected. */
  note?: string;
  /** A small tag at the row's end, such as "new" or "yours". */
  tag?: string;
  /** Children make it a folder. */
  children?: FileNode[];
};

export type FileTreeProps = {
  /** The root's children (the repo's top level). */
  tree?: FileNode[];
  /** The repository name in the header. */
  title?: string;
  /** The branch pill in the header. */
  branch?: string;
  /** Folders open at first: `"all"`, a depth (1 opens the top level's folders), or a list of paths. */
  open?: "all" | number | string[];
  /** The path selected at first. */
  selected?: string;
  /** Hide the detail pane. */
  detail?: boolean;
  className?: string;
};

type Row = { path: string; node: FileNode; depth: number; folder: boolean };

const SAMPLE: FileNode[] = [
  { name: "AGENTS.md", note: "The entry point for any person or AI agent that picks the repo up." },
  { name: "README.md", note: "How to run the site and where things live." },
  {
    name: "src",
    note: "The application.",
    children: [
      { name: "app", note: "One route per page.", children: [{ name: "layout.tsx" }, { name: "page.tsx" }, { name: "globals.css", note: "Your colours, type and radius as tokens." }] },
      { name: "components", children: [{ name: "vibebox", note: "The library blocks, copied in as source you own.", children: [{ name: "hero-split-stage" }, { name: "services-bento" }] }] },
      { name: "content", children: [{ name: "site.ts", note: "Business facts, navigation and SEO defaults." }] },
    ],
  },
  { name: "package.json" },
];

const LABELS: Record<string, string> = { ts: "TS", tsx: "TX", md: "MD", json: "{}", css: "CS", sql: "SQ", yml: "YM", yaml: "YM", mjs: "JS", js: "JS", svg: "SV", env: "EN", example: "EN", lock: "LK" };
const HUES: Record<string, string> = { ts: "var(--spectrum-2)", tsx: "var(--spectrum-3)", md: "var(--spectrum-6)", json: "var(--spectrum-7)", css: "var(--spectrum-5)", sql: "var(--spectrum-1)", yml: "var(--spectrum-4)", yaml: "var(--spectrum-4)" };

function ext(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}

function flatten(nodes: FileNode[], open: Set<string>, parent = "", depth = 0, out: Row[] = []): Row[] {
  for (const node of nodes) {
    const path = parent ? `${parent}/${node.name}` : node.name;
    const folder = Boolean(node.children?.length);
    out.push({ path, node, depth, folder });
    if (folder && open.has(path)) flatten(node.children!, open, path, depth + 1, out);
  }
  return out;
}

function initialOpen(tree: FileNode[], open: FileTreeProps["open"]): Set<string> {
  const set = new Set<string>();
  const walk = (nodes: FileNode[], parent: string, depth: number) => {
    for (const node of nodes) {
      const path = parent ? `${parent}/${node.name}` : node.name;
      if (node.children?.length) {
        if (open === "all" || (typeof open === "number" && depth < open) || (Array.isArray(open) && open.includes(path))) set.add(path);
        walk(node.children, path, depth + 1);
      }
    }
  };
  walk(tree, "", 0);
  return set;
}

function count(nodes: FileNode[]): number {
  return nodes.reduce((total, node) => total + (node.children ? count(node.children) : 1), 0);
}

function find(nodes: FileNode[], path: string): FileNode | undefined {
  const [head, ...rest] = path.split("/");
  const hit = nodes.find((node) => node.name === head);
  if (!hit) return undefined;
  return rest.length ? find(hit.children ?? [], rest.join("/")) : hit;
}

function FolderIcon({ open }: { open: boolean }) {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true" className="size-4 flex-none text-[color:var(--spectrum-3)]" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round">
      {open ? <path d="M2.5 6.5v8a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H9L7.500 5.500h-4a1 1 0 0 0-1 1z" fill="currentColor" fillOpacity="0.16" /> : <path d="M2.500 5.500a1 1 0 0 1 1-1h4l1.500 1.500h7a1 1 0 0 1 1 1v7.500a1 1 0 0 1-1 1h-12.500a1 1 0 0 1-1-1z" />}
    </svg>
  );
}

/**
 * A repository as a console: a header with the branch, a tree you can walk with the keyboard (open,
 * close, select), indent guides, file-type chips, and a detail pane that says what the selected file is.
 * Rows reveal in a short stagger once, by opacity and transform.
 */
export function FileTree({ tree = SAMPLE, title = "client-app", branch = "main", open = 1, selected, detail = true, className }: FileTreeProps) {
  const [openSet, setOpenSet] = useState(() => initialOpen(tree, open));
  const rows = useMemo(() => flatten(tree, openSet), [tree, openSet]);
  const [current, setCurrent] = useState(selected ?? rows[0]?.path ?? "");
  const total = useMemo(() => count(tree), [tree]);
  const node = find(tree, current);
  const index = Math.max(0, rows.findIndex((row) => row.path === current));

  const toggle = (path: string, force?: boolean) =>
    setOpenSet((value) => {
      const next = new Set(value);
      const on = force ?? !next.has(path);
      if (on) next.add(path);
      else next.delete(path);
      return next;
    });

  const onKey = (event: KeyboardEvent) => {
    const row = rows[index];
    if (!row) return;
    const focusRow = (target: Row | undefined) => {
      if (!target) return;
      setCurrent(target.path);
      requestAnimationFrame(() => document.getElementById(`ft-${target.path}`)?.focus());
    };
    switch (event.key) {
      case "ArrowDown":
        focusRow(rows[index + 1]);
        break;
      case "ArrowUp":
        focusRow(rows[index - 1]);
        break;
      case "Home":
        focusRow(rows[0]);
        break;
      case "End":
        focusRow(rows[rows.length - 1]);
        break;
      case "ArrowRight":
        if (row.folder && !openSet.has(row.path)) toggle(row.path, true);
        else focusRow(rows[index + 1]?.depth === row.depth + 1 ? rows[index + 1] : undefined);
        break;
      case "ArrowLeft":
        if (row.folder && openSet.has(row.path)) toggle(row.path, false);
        else focusRow([...rows.slice(0, index)].reverse().find((candidate) => candidate.depth === row.depth - 1));
        break;
      case "Enter":
      case " ":
        if (row.folder) toggle(row.path);
        break;
      default:
        return;
    }
    event.preventDefault();
  };

  return (
    <figure data-vb="file-tree" className={cn("m-0 w-full overflow-hidden rounded-xl border border-white/10 bg-[#080b14] text-[0.84rem] text-white shadow-[0_30px_70px_-34px_rgb(0_0_0/0.8)]", className)}>
      <style href="vb-file-tree" precedence="vb">{`@keyframes vb-ft-in{from{opacity:0;transform:translate3d(-6px,0,0)}to{opacity:1;transform:none}}@media (prefers-reduced-motion:reduce){[data-vb-ft-row]{animation:none!important}}`}</style>
      <figcaption className="flex items-center gap-3 border-b border-white/10 bg-white/[0.03] px-3.5 py-2.5">
        <svg viewBox="0 0 16 16" aria-hidden="true" className="size-4 text-white/70" fill="none" stroke="currentColor" strokeWidth="1.4">
          <path d="M3 2.500h8.500a1 1 0 0 1 1 1V13H4.500A1.500 1.500 0 0 1 3 11.500zM3 11.500A1.500 1.500 0 0 1 4.500 10h8" />
        </svg>
        <span className="min-w-0 truncate font-semibold tracking-tight">{title}</span>
        <span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/[0.04] px-2 py-0.5 font-mono text-[0.68rem] text-white/75">
          <svg viewBox="0 0 12 12" aria-hidden="true" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.3">
            <circle cx="3" cy="2.500" r="1.200" />
            <circle cx="3" cy="9.500" r="1.200" />
            <circle cx="9" cy="4.500" r="1.200" />
            <path d="M3 3.700v4.600M9 5.700c0 2-3 1.500-6 3" />
          </svg>
          {branch}
        </span>
        <span className="ml-auto font-mono text-[0.68rem] text-white/50">{total} files</span>
      </figcaption>
      <div className={cn("grid", detail && "sm:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]")}>
        <ul role="tree" aria-label={`${title} files`} onKeyDown={onKey} className="max-h-[26rem] overflow-y-auto p-1.5 sm:max-h-[30rem]">
          {rows.map((row, position) => {
            const isOpen = openSet.has(row.path);
            const kind = ext(row.node.name);
            return (
              <li
                key={row.path}
                role="none"
                data-vb-ft-row=""
                style={{ animation: `vb-ft-in 420ms var(--ease-expo-out, cubic-bezier(0.16,1,0.3,1)) ${Math.min(position, 18) * 22}ms both` }}
              >
                <button
                  id={`ft-${row.path}`}
                  role="treeitem"
                  type="button"
                  aria-level={row.depth + 1}
                  aria-expanded={row.folder ? isOpen : undefined}
                  aria-selected={current === row.path}
                  tabIndex={current === row.path ? 0 : -1}
                  onClick={() => {
                    setCurrent(row.path);
                    if (row.folder) toggle(row.path);
                  }}
                  className={cn(
                    "relative flex w-full items-center gap-2 rounded-md py-[0.32rem] pr-2 text-left transition-colors hover:bg-white/[0.06] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
                    current === row.path ? "bg-white/[0.09]" : "",
                  )}
                  style={{ paddingLeft: `${0.5 + row.depth * 1.15}rem` }}
                >
                  {row.depth > 0 && <span aria-hidden="true" className="absolute inset-y-0 w-px bg-white/10" style={{ left: `${0.5 + (row.depth - 1) * 1.15 + 0.52}rem` }} />}
                  {row.folder ? (
                    <>
                      <svg viewBox="0 0 10 10" aria-hidden="true" className={cn("size-2.5 flex-none text-white/45 transition-transform duration-200", isOpen && "rotate-90")} fill="currentColor">
                        <path d="M3 1.500 7.500 5 3 8.500z" />
                      </svg>
                      <FolderIcon open={isOpen} />
                    </>
                  ) : (
                    <>
                      <span aria-hidden="true" className="size-2.5 flex-none" />
                      <span
                        aria-hidden="true"
                        className="grid size-4 flex-none place-items-center rounded-[4px] font-mono text-[0.5rem] leading-none font-bold text-black/80"
                        style={{ background: HUES[kind] ?? "rgb(255 255 255 / 0.55)" }}
                      >
                        {LABELS[kind] ?? "··"}
                      </span>
                    </>
                  )}
                  <span className={cn("min-w-0 flex-1 truncate", row.folder ? "font-medium" : "text-white/85")}>{row.node.name}</span>
                  {row.node.tag && <span className="rounded-full border border-white/15 px-1.5 font-mono text-[0.6rem] text-white/60">{row.node.tag}</span>}
                </button>
              </li>
            );
          })}
        </ul>
        {detail && (
          <div className="border-t border-white/10 bg-white/[0.025] p-4 sm:border-t-0 sm:border-l" aria-live="polite">
            <p className="font-mono text-[0.64rem] tracking-[0.14em] text-white/45 uppercase">Selected</p>
            <p className="mt-1.5 font-mono text-[0.78rem] break-all text-white/90">{current || title}</p>
            <p className="mt-3 text-[0.86rem] leading-relaxed text-white/70">
              {node?.note ?? (node?.children ? `${count(node.children)} files inside.` : "A file in the repository.")}
            </p>
          </div>
        )}
      </div>
    </figure>
  );
}
components/vibebox/file-tree/index.tsx
/**
 * VibeBox Motion · File Tree
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { FileTree, type FileTreeProps, type FileNode } from "./file-tree.client";

Props

PropTypeDefaultDescription
treeFileNode[]a sample repoThe repo's top level: { name, note?, tag?, children? }.
titlestring"client-app"The repository name in the header.
branchstring"main"The branch pill.
open"all" | number | string[]1Folders open at first: all, a depth, or a list of paths.
selectedstringThe path selected at first.
detailbooleantrueShow the detail pane.
classNamestring—Merged with cn(); sets the width and margins.

Accessibility and motion

Keyboard
Arrow keys move, Right opens or enters a folder, Left closes or goes to the parent, Home and End jump, Enter toggles; one tab stop.
Screen readers
role=tree with treeitem rows, levels and expanded state; the detail pane is a polite live region.
Touch
Rows are 32 px tall; tapping selects and toggles.
Reduced motion
The row reveal is off; nothing else animates.
  • A browser window showing a business website

    A browser window that frames a site and pans through the full page on hover, on focus, or on a slow loop.

    Devices · Server

  • An iPhone drawn in its orange finish with a colourful app screen

    Phone

    Free

    One phone that becomes any iPhone model, finish and side, with your picture on its screen.

    Devices · CSS

  • A graphite phone showing a mobile website

    A phone that shows a mobile layout, scrolls through the full page, and tilts toward the cursor.

    Devices · Motion

↑ ↓ to move↵ to openesc to close