# File Tree

> Expandable file tree with arrow keys, Home/End, Enter and Space following the ARIA tree pattern.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/file-tree
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/file-tree.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/file-tree-demo.tsx"
"use client"

import * as React from "react"

import { FileTree, type FileTreeNode } from "@/components/file-tree"

const nodes: FileTreeNode[] = [
  {
    name: "src",
    children: [
      {
        name: "app",
        children: [{ name: "layout.tsx" }, { name: "page.tsx" }, { name: "globals.css" }],
      },
      {
        name: "components",
        children: [{ name: "button.tsx" }, { name: "card.tsx" }],
      },
      { name: "lib", children: [{ name: "utils.ts" }] },
    ],
  },
  { name: "public", children: [{ name: "favicon.ico" }] },
  { name: "package.json" },
  { name: "tsconfig.json" },
]

const expanded = ["src", "src/app"]

export default function FileTreeDemo() {
  const [path, setPath] = React.useState<string | null>(null)

  return (
    <div className="w-72 rounded-2xl border bg-card p-3">
      <FileTree nodes={nodes} defaultExpanded={expanded} onSelect={setPath} />
      <p className="mt-3 truncate border-t pt-2 text-xs text-muted-foreground">{path ?? "Select a file"}</p>
    </div>
  )
}
```

## Source

```tsx title="components/file-tree.tsx"
"use client"

import * as React from "react"
import { ChevronRight, File, Folder, FolderOpen } from "lucide-react"

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

export type FileTreeNode = { name: string; children?: FileTreeNode[] }

export interface FileTreeProps extends Omit<React.ComponentProps<"ul">, "children" | "onSelect"> {
  nodes: FileTreeNode[]
  /** Paths ("src/app") that start expanded */
  defaultExpanded?: string[]
  onSelect?: (path: string) => void
}

type Ctx = {
  expanded: Set<string>
  selected: string | null
  tabbable: string
  toggle: (path: string) => void
  select: (path: string) => void
}

function Branch({ nodes, parent, level, ctx }: { nodes: FileTreeNode[]; parent: string; level: number; ctx: Ctx }) {
  return (
    <>
      {nodes.map((node) => {
        const path = parent ? `${parent}/${node.name}` : node.name
        const folder = !!node.children
        const open = ctx.expanded.has(path)
        const Icon = folder ? (open ? FolderOpen : Folder) : File
        return (
          <li key={path} role="none">
            <div
              role="treeitem"
              aria-level={level}
              aria-expanded={folder ? open : undefined}
              aria-selected={ctx.selected === path}
              tabIndex={ctx.tabbable === path ? 0 : -1}
              data-path={path}
              data-parent={parent}
              onClick={() => {
                if (folder) ctx.toggle(path)
                ctx.select(path)
              }}
              style={{ paddingLeft: 8 + (level - 1) * 16 }}
              className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm outline-none select-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring aria-selected:bg-accent aria-selected:font-medium"
            >
              <ChevronRight
                className={cn("size-3.5 shrink-0 text-muted-foreground transition-transform duration-150", open && "rotate-90", !folder && "invisible")}
              />
              <Icon className={cn("size-4 shrink-0", folder ? "text-amber-500" : "text-muted-foreground")} />
              <span className="truncate">{node.name}</span>
            </div>
            {folder && open && (
              <ul role="group">
                <Branch nodes={node.children!} parent={path} level={level + 1} ctx={ctx} />
              </ul>
            )}
          </li>
        )
      })}
    </>
  )
}

export function FileTree({ nodes, defaultExpanded = [], onSelect, className, ...props }: FileTreeProps) {
  const [expanded, setExpanded] = React.useState(() => new Set(defaultExpanded))
  const [selected, setSelected] = React.useState<string | null>(null)
  const [tabbable, setTabbable] = React.useState(nodes[0]?.name ?? "")
  const tree = React.useRef<HTMLUListElement>(null)

  const items = () => [...(tree.current?.querySelectorAll<HTMLElement>("[role=treeitem]") ?? [])]
  const focus = (element: HTMLElement | undefined) => {
    if (!element) return
    setTabbable(element.dataset.path ?? "")
    element.focus()
  }
  const toggle = (path: string, force?: boolean) =>
    setExpanded((current) => {
      const next = new Set(current)
      if (force ?? !next.has(path)) next.add(path)
      else next.delete(path)
      return next
    })
  const select = (path: string) => {
    setSelected(path)
    setTabbable(path)
    onSelect?.(path)
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    const current = (event.target as HTMLElement).closest<HTMLElement>("[role=treeitem]")
    if (!current) return
    const all = items()
    const index = all.indexOf(current)
    const path = current.dataset.path ?? ""
    const folder = current.hasAttribute("aria-expanded")
    const open = current.getAttribute("aria-expanded") === "true"
    if (event.key === "ArrowDown") focus(all[Math.min(index + 1, all.length - 1)])
    else if (event.key === "ArrowUp") focus(all[Math.max(index - 1, 0)])
    else if (event.key === "Home") focus(all[0])
    else if (event.key === "End") focus(all[all.length - 1])
    else if (event.key === "ArrowRight") {
      if (folder && !open) toggle(path, true)
      else if (folder) focus(all[index + 1])
    } else if (event.key === "ArrowLeft") {
      if (folder && open) toggle(path, false)
      else focus(all.find((item) => item.dataset.path === current.dataset.parent))
    } else if (event.key === "Enter" || event.key === " ") {
      if (folder) toggle(path)
      select(path)
    } else return
    event.preventDefault()
  }

  const ctx: Ctx = { expanded, selected, tabbable, toggle: (path) => toggle(path), select }

  return (
    <ul ref={tree} role="tree" aria-label="Files" onKeyDown={onKeyDown} className={cn("text-foreground", className)} {...props}>
      <Branch nodes={nodes} parent="" level={1} ctx={ctx} />
    </ul>
  )
}
```
