Navigation

File Tree

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

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
utils
"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). role=group genuinely nests inside its
  parent role=treeitem, so events from a deeply nested child bubble through
  every ancestor folder. Put the click handler on the row header only
  (nested rows live in the group, not the header), and record focus only
  when e.target === e.currentTarget — so neither needs stopPropagation(),
  and the consumer's own handlers around the tree still see every event.
- 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.
- A collapsed folder's group is `inert`: the 0fr row only clips visually,
  and without inert screen readers in browse mode would still read (and
  count) every hidden treeitem. Children mount on a folder's first expand
  and then stay mounted, so a large repo with only the root open renders
  one row per top-level node, and collapsing still animates.
- 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 on the label row, driven by the
  treeitem's own :focus-visible through a child selector
  ([&:focus-visible>div:first-child]:ring-2 …). Not the roving tab-stop
  state: that marks the first row before anything is focused and keeps the
  last-clicked row ringed after focus leaves. And not a plain
  focus-visible ring on the treeitem, which would span 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 pattern — role="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.
  • Header-only activation — because a folder's children live inside its own DOM subtree, the click handler sits on the row header alone and focus is recorded only when the treeitem itself is the event target, so activating a deeply nested file never toggles or re-focuses its ancestor folders — without stopPropagation(), which would also hide those events from the consumer.

On This Page