Navigation

File Tree

An accessible, keyboard-navigable hierarchical file/folder tree with expand-collapse disclosure and controlled selection.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronRight, FileText, Folder, FolderOpen } from "lucide-react"
import { cn } from "@/lib/utils"

export interface TreeNode {
  id: string
  name: string
  /** Present (even as []) marks this node as a folder; omit it for a file/leaf. */
  children?: TreeNode[]
}

export interface FileTreeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/file-tree.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "FileTree" component using
lucide-react for icons.

Contract
- export const FileTree = React.forwardRef<HTMLDivElement, FileTreeProps>
  rendering a <div role="tree">; spread remaining native div props on the
  root, merge className via cn().
- TreeNode = { id: string; name: string; children?: TreeNode[] } — the
  presence of `children` (even as an empty array) marks a node as a folder;
  omitting it marks a file/leaf.
- items: TreeNode[] — the root-level nodes.
- defaultExpanded?: string[] — node ids expanded on mount; after that,
  expand/collapse is internal state (an id Set), not re-driven by this prop.
- selected?: string | null (default null) + onSelect?: (id: string) => void
  — selection is fully controlled by the consumer. The component never
  falls back to its own "selected" state; it only calls onSelect(id) when a
  file node is activated. Folders are never "selected", only expanded.

Behavior
- Implements the WAI-ARIA APG Tree View pattern with genuine DOM nesting:
  the root is role="tree"; every node is a div role="treeitem" that
  directly contains (a) its own visual label row and, for folders, (b) a
  div role="group" wrapping the recursively rendered children — group is a
  real DOM descendant of its owning treeitem, matching the ARIA spec's
  reference markup. Folders carry aria-expanded; only file nodes carry
  aria-selected (folders aren't part of the selection model, so they never
  get that attribute at all).
- Roving tabindex: exactly one treeitem has tabIndex 0 at any time (the
  "current" node) so Tab enters/exits the whole tree in a single stop;
  every other node is tabIndex -1. Clicking or focusing a node updates
  which one is "current".
- Keyboard, handled by a single delegated onKeyDown on the tree root and
  computed against a depth-first flattened list of the currently *visible*
  nodes (collapsed subtrees are skipped from that list entirely):
  - ArrowDown / ArrowUp: move to the next/previous visible node.
  - ArrowRight: expand a collapsed folder; if already expanded, move focus
    into its first child instead. No-op on a file.
  - ArrowLeft: collapse an expanded folder (focus stays on it); if already
    collapsed or it's a file, move focus to the parent instead.
  - Enter / Space: select a file (calls onSelect) or toggle a folder's
    expand state.
  - Home / End: jump to the first / last visible node.
- Click: clicking a folder's row toggles expand/collapse; clicking a
  file's row calls onSelect(id). Both the click and focus handlers call
  stopPropagation() — because role=group genuinely nests inside its parent
  role=treeitem, a click or focus event on a deeply nested child would
  otherwise bubble up and also fire the ancestor folders' handlers.
- Expand/collapse animates via the grid-template-rows 0fr↔1fr technique
  (own transition, duration-150); prefers-reduced-motion drops the
  transition so it snaps open/closed instantly instead — collapsing never
  breaks the ability to reach the folder's contents, it just stops
  animating.
- Icons: Folder/FolderOpen swap based on expand state; a lucide
  ChevronRight rotates 90deg when expanded. Files render FileText and an
  equal-width empty spacer takes the chevron's place, so file and folder
  names stay vertically aligned regardless of type. All icons
  aria-hidden="true".
- Indentation: each row's label gets an inline paddingLeft of
  depth * 16 + 8 pixels — plain depth-based padding, no connector lines by
  default (see Customization levers to add them).

Rendering & styling
- Semantic tokens only: row hover:bg-muted, selected state
  bg-accent text-accent-foreground, icons text-muted-foreground, focus
  indicator ring-2 ring-ring ring-inset (applied via JS-tracked focus
  state on the label row, not the CSS :focus-visible pseudo-class, so the
  ring never accidentally spans an expanded folder's entire subtree).
  cn() merges className throughout. Row: py-1 pr-2 rounded-md text-sm;
  icons size-4 shrink-0.
- No hex/oklch/rgb literals; no UI dependency beyond lucide-react.

Customization levers
- Icon-per-extension: replace the single FileText icon with a lookup
  keyed by the file's extension (parsed from node.name), e.g.
  Record<string, LucideIcon> mapping ".tsx" -> a code icon, ".json" -> a
  braces icon, falling back to FileText.
- Connector lines: add a border-l (border-muted) on the nested group
  wrapper or on each row to draw classic guide lines between indentation
  levels — purely a rendering addition, no state changes needed.
- Async / lazy-loaded children: swap the "children !== undefined ⇒ folder"
  check for an explicit isDir flag, fetch children on first expand, and
  render a Skeleton row while that fetch is pending.
- Controlled expansion: promote the internal expanded Set<string> to a
  controlled expanded/onExpandedChange pair if the consumer needs to
  persist open folders or drive them from outside (e.g. syncing with a
  router path).
- Density / row height: adjust py-1 and the gap-1.5 icon spacing for a
  denser IDE-style tree, or increase them for a touch-friendly file picker.
- Drag-to-reorder is intentionally out of scope here — layer
  @dnd-kit/core + @dnd-kit/sortable on top if nodes need to be
  draggable/movable, keeping this component as the pure display/selection
  layer underneath.

Concepts

  • WAI-ARIA tree patternrole="treeitem" genuinely nests role="group" for its children, matching the ARIA spec's reference markup exactly, so assistive tech gets accurate hierarchy without any extra aria-level/aria-posinset bookkeeping.
  • Roving tabindex — only the "current" node sits in the page's Tab order (tabIndex 0); arrow keys move a single internal focus pointer across nodes instead of tabbing through every row one by one.
  • Flattened visible-node list — the current expand state collapses to a depth-first array of only the currently visible nodes; every keyboard command walks that array, so collapsed subtrees are transparently skipped without special-casing.
  • Path-as-identity — the demo's node ids double as full paths ("src/components/button.tsx"), so a selected id is already a real, displayable path with no separate lookup step.
  • Stopped propagation on nested activation — because a folder's children live inside its own DOM subtree, click/focus handlers call stopPropagation() so activating a deeply nested file never also toggles or re-focuses its ancestor folders.

On This Page