{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree",
  "title": "Tree",
  "description": "A file tree whose folders open on a height spring as the chevron turns, both interruptible. One selection glides between rows, guides mark each level, and keys follow WAI-ARIA.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-auto-height.json",
    "https://eased.dev/r/eased-indicator.json",
    "https://eased.dev/r/eased-scroll-fade.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/tree/tree.tsx",
      "content": "\"use client\";\n\nimport { motion } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { AutoHeight } from \"../_primitives/auto-height/auto-height\";\nimport { Indicator, IndicatorGroup } from \"../_primitives/indicator/indicator\";\nimport { scrollFadeMask, useScrollEdges } from \"../_primitives/scroll-fade/scroll-fade\";\n\nexport type TreeNode = {\n  /** Unique across the whole tree. */\n  id: string;\n  /** What the row shows. */\n  label: ReactNode;\n  /** Text for typeahead and announcements when `label` is not a string. */\n  textValue?: string;\n  /** A per-node icon; wins over `renderIcon`. */\n  icon?: ReactNode;\n  /** A folder when present, even if empty. */\n  children?: TreeNode[];\n  /** Muted trailing text, e.g. a size or \"M\" for modified. */\n  meta?: ReactNode;\n  disabled?: boolean;\n};\n\nexport type TreeIconState = { folder: boolean; expanded: boolean; selected: boolean; depth: number };\n\nexport type TreeProps = {\n  items: TreeNode[];\n  /** Names the tree, e.g. \"Files\". */\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  /** Open folders (controlled). */\n  expanded?: string[];\n  /** Initially open folders (uncontrolled). */\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  /** The selected node (controlled); `null` for none. */\n  selected?: string | null;\n  /** Initially selected node (uncontrolled). */\n  defaultSelected?: string | null;\n  onSelectedChange?: (id: string, node: TreeNode) => void;\n  /** Called when a leaf is activated by click, Enter or double-click. */\n  onAction?: (id: string, node: TreeNode) => void;\n  /**\n   * The icon slot, for nodes without their own `icon`. Default: a folder that opens when\n   * expanded and a document for leaves. Return `null` for no icon.\n   */\n  renderIcon?: (node: TreeNode, state: TreeIconState) => ReactNode;\n  /** Classes for the root. Give it a max height and it scrolls, with 20px edge fades. */\n  className?: string;\n};\n\ntype Flat = { node: TreeNode; depth: number; parent: string | null; folder: boolean };\n\nconst TYPEAHEAD_MS = 500;\n\nconst textOf = (node: TreeNode) => (node.textValue ?? (typeof node.label === \"string\" ? node.label : node.id)).toLowerCase();\nconst isFolder = (node: TreeNode) => node.children !== undefined;\n\n/** Every node visible with these folders open, in reading order. */\nfunction visibleNodes(items: TreeNode[], open: Set<string>) {\n  const out: Flat[] = [];\n  const walk = (nodes: TreeNode[], depth: number, parent: string | null) => {\n    for (const node of nodes) {\n      const folder = isFolder(node);\n      out.push({ node, depth, parent, folder });\n      if (folder && open.has(node.id)) walk(node.children!, depth + 1, node.id);\n    }\n  };\n  walk(items, 0, null);\n  return out;\n}\n\n/**\n * A file tree on the WAI-ARIA tree pattern. Folders open on a height spring with no\n * overshoot while the chevron turns, both interruptible mid-flight; one selection highlight\n * glides between rows; indent guides sit on one line per level, under the parent's chevron,\n * and the guide of the focused branch darkens. Arrow keys, Home/End, `*` and typeahead.\n */\nexport function Tree({\n  items,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  expanded: expandedProp,\n  defaultExpanded = [],\n  onExpandedChange,\n  selected: selectedProp,\n  defaultSelected = null,\n  onSelectedChange,\n  onAction,\n  renderIcon = defaultIcon,\n  className = \"\",\n}: TreeProps) {\n  const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);\n  const [innerSelected, setInnerSelected] = useState(defaultSelected);\n  const expanded = expandedProp ?? innerExpanded;\n  const selected = selectedProp !== undefined ? selectedProp : innerSelected;\n  const open = useMemo(() => new Set(expanded), [expanded]);\n  const visible = useMemo(() => visibleNodes(items, open), [items, open]);\n  const [focused, setFocused] = useState<string | null>(null);\n  const rowRefs = useRef(new Map<string, HTMLLIElement>());\n  const typeahead = useRef({ text: \"\", at: 0 });\n  const rootRef = useRef<HTMLUListElement>(null);\n  useScrollEdges(rootRef, \"y\");\n\n  // The roving tab stop: the focused node if it is still visible, else the selection, else the first.\n  const tabStop =\n    (focused && visible.some((v) => v.node.id === focused) && focused) ||\n    (selected && visible.some((v) => v.node.id === selected) && selected) ||\n    visible.find((v) => !v.node.disabled)?.node.id ||\n    null;\n\n  const setExpanded = useCallback(\n    (next: string[]) => {\n      if (expandedProp === undefined) setInnerExpanded(next);\n      onExpandedChange?.(next);\n    },\n    [expandedProp, onExpandedChange],\n  );\n\n  const toggle = (id: string, to = !open.has(id)) => {\n    if (to === open.has(id)) return;\n    setExpanded(to ? [...expanded, id] : expanded.filter((e) => e !== id));\n  };\n\n  const select = (flat: Flat) => {\n    if (flat.node.disabled) return;\n    if (selectedProp === undefined) setInnerSelected(flat.node.id);\n    if (flat.node.id !== selected) onSelectedChange?.(flat.node.id, flat.node);\n  };\n\n  const focusNode = (id: string) => {\n    setFocused(id);\n    const el = rowRefs.current.get(id);\n    el?.focus({ preventScroll: true });\n    el?.querySelector<HTMLElement>(\"[data-row]\")?.scrollIntoView({ block: \"nearest\" });\n  };\n\n  const activate = (flat: Flat) => {\n    select(flat);\n    if (flat.folder) toggle(flat.node.id);\n    else if (!flat.node.disabled) onAction?.(flat.node.id, flat.node);\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLUListElement>) => {\n    const target = (e.target as HTMLElement).closest<HTMLLIElement>(\"[role=treeitem]\");\n    const id = target?.dataset.id;\n    const at = visible.findIndex((v) => v.node.id === id);\n    if (at < 0) return;\n    const cur = visible[at];\n    const enabled = visible.filter((v) => !v.node.disabled);\n    const step = (dir: 1 | -1) => {\n      for (let i = at + dir; i >= 0 && i < visible.length; i += dir) if (!visible[i].node.disabled) return visible[i];\n      return null;\n    };\n    let next: Flat | null | undefined = null;\n    switch (e.key) {\n      case \"ArrowDown\":\n        next = step(1);\n        break;\n      case \"ArrowUp\":\n        next = step(-1);\n        break;\n      case \"Home\":\n        next = enabled[0];\n        break;\n      case \"End\":\n        next = enabled[enabled.length - 1];\n        break;\n      case \"ArrowRight\":\n        if (!cur.folder) break;\n        if (!open.has(cur.node.id)) toggle(cur.node.id, true);\n        else next = cur.node.children!.length ? visible[at + 1] : null;\n        break;\n      case \"ArrowLeft\":\n        if (cur.folder && open.has(cur.node.id)) toggle(cur.node.id, false);\n        else if (cur.parent) next = visible.find((v) => v.node.id === cur.parent);\n        break;\n      case \"Enter\":\n        activate(cur);\n        break;\n      case \" \":\n        select(cur);\n        break;\n      case \"*\": {\n        const siblings = visible.filter((v) => v.parent === cur.parent && v.folder && !open.has(v.node.id)).map((v) => v.node.id);\n        if (siblings.length) setExpanded([...expanded, ...siblings]);\n        break;\n      }\n      default: {\n        if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return;\n        const now = performance.now();\n        const t = typeahead.current;\n        t.text = now - t.at > TYPEAHEAD_MS ? e.key.toLowerCase() : t.text + e.key.toLowerCase();\n        t.at = now;\n        // A repeated single letter cycles; otherwise the search starts at the current row.\n        const cycling = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);\n        const query = cycling ? t.text[0] : t.text;\n        const from = cycling || t.text.length === 1 ? at + 1 : at;\n        for (let k = 0; k < visible.length; k++) {\n          const v = visible[(from + k) % visible.length];\n          if (!v.node.disabled && textOf(v.node).startsWith(query)) {\n            next = v;\n            break;\n          }\n        }\n        if (!next) return;\n      }\n    }\n    e.preventDefault();\n    if (next) focusNode(next.node.id);\n  };\n\n  // A folder closing over the focused row hands focus to the folder itself.\n  useEffect(() => {\n    if (!focused || visible.some((v) => v.node.id === focused)) return;\n    const active = document.activeElement;\n    if (!rootRef.current?.contains(active) && active !== document.body) return;\n    let id: string | null = focused;\n    const parentOf = new Map<string, string | null>();\n    const walk = (nodes: TreeNode[], parent: string | null) => nodes.forEach((n) => (parentOf.set(n.id, parent), n.children && walk(n.children, n.id)));\n    walk(items, null);\n    while (id && !visible.some((v) => v.node.id === id)) id = parentOf.get(id) ?? null;\n    // Its onFocus records it as the focused row.\n    if (id) rowRefs.current.get(id)?.focus({ preventScroll: true });\n  }, [visible, focused, items]);\n\n  const ctx: RowContext = {\n    open,\n    selected,\n    tabStop,\n    focused,\n    rowRefs,\n    renderIcon,\n    onRowClick: (flat) => {\n      setFocused(flat.node.id);\n      activate(flat);\n    },\n    onFocusRow: setFocused,\n  };\n\n  return (\n    <IndicatorGroup>\n      <motion.ul\n        ref={rootRef}\n        layoutScroll\n        role=\"tree\"\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledBy}\n        onKeyDown={onKeyDown}\n        style={scrollFadeMask(\"y\", 20)}\n        className={`relative min-w-0 overflow-y-auto overscroll-contain text-ui select-none [--tree-indent:16px] ${className}`}\n      >\n        <Branch nodes={items} depth={0} parent={null} ctx={ctx} />\n      </motion.ul>\n    </IndicatorGroup>\n  );\n}\n\ntype RowContext = {\n  open: Set<string>;\n  selected: string | null;\n  tabStop: string | null;\n  focused: string | null;\n  rowRefs: RefObject<Map<string, HTMLLIElement>>;\n  renderIcon: NonNullable<TreeProps[\"renderIcon\"]>;\n  onRowClick: (flat: Flat) => void;\n  onFocusRow: (id: string) => void;\n};\n\nfunction Branch({ nodes, depth, parent, ctx }: { nodes: TreeNode[]; depth: number; parent: string | null; ctx: RowContext }) {\n  return nodes.map((node, i) => (\n    <Item key={node.id} flat={{ node, depth, parent, folder: isFolder(node) }} pos={i + 1} size={nodes.length} ctx={ctx} />\n  ));\n}\n\nfunction Item({ flat, pos, size, ctx }: { flat: Flat; pos: number; size: number; ctx: RowContext }) {\n  const { node, depth, folder } = flat;\n  const expanded = folder && ctx.open.has(node.id);\n  const selected = ctx.selected === node.id;\n  // Children mount on first open and stay, so closing animates and reopening is instant.\n  const [mounted, setMounted] = useState(expanded);\n  if (expanded && !mounted) setMounted(true);\n  // The guide of the branch holding the focused row darkens, so you can see where you are.\n  const holdsFocus = folder && expanded && ctx.focused !== null && contains(node, ctx.focused);\n  const icon = node.icon !== undefined ? node.icon : ctx.renderIcon(node, { folder, expanded, selected, depth });\n\n  return (\n    <li\n      ref={(el) => {\n        if (el) ctx.rowRefs.current.set(node.id, el);\n        else ctx.rowRefs.current.delete(node.id);\n      }}\n      role=\"treeitem\"\n      data-id={node.id}\n      aria-level={depth + 1}\n      aria-setsize={size}\n      aria-posinset={pos}\n      aria-expanded={folder ? expanded : undefined}\n      aria-selected={selected}\n      aria-disabled={node.disabled || undefined}\n      tabIndex={ctx.tabStop === node.id ? 0 : -1}\n      onFocus={(e) => {\n        if (e.target === e.currentTarget) ctx.onFocusRow(node.id);\n      }}\n      className=\"outline-none\"\n    >\n      <div\n        data-row\n        onClick={() => ctx.onRowClick(flat)}\n        className={`relative flex h-8 min-w-0 items-center gap-1.5 rounded-control-sm pr-1.5 [transition:background-color_150ms_var(--eased-ease-standard),box-shadow_var(--eased-duration-focus)_var(--eased-ease-standard)] [li:focus-visible>&]:shadow-[inset_0_0_0_2px_var(--eased-ring)] ${\n          node.disabled\n            ? \"cursor-not-allowed text-text-disabled\"\n            : `cursor-pointer ${selected ? \"text-text\" : \"text-text-secondary hover-fine:bg-surface-hover hover-fine:text-text\"}`\n        }`}\n        style={{ paddingLeft: `calc(var(--tree-indent) * ${depth} + 6px)` }}\n      >\n        {selected && <Indicator name=\"tree-selection\" className=\"inset-0 rounded-control-sm bg-surface-active\" />}\n        <span className=\"relative flex size-4 shrink-0 items-center justify-center text-text-muted\">{folder && <Chevron open={expanded} />}</span>\n        {icon !== null && icon !== undefined && (\n          <span className=\"relative flex size-4 shrink-0 items-center justify-center text-text-muted\">{icon}</span>\n        )}\n        <span className=\"relative min-w-0 flex-1 truncate\">{node.label}</span>\n        {node.meta !== undefined && <span className=\"relative shrink-0 text-ui-xs text-text-muted tabular-nums\">{node.meta}</span>}\n      </div>\n      {folder && (\n        <AutoHeight open={expanded}>\n          {mounted && (\n            <ul role=\"group\" className=\"relative\">\n              <span\n                aria-hidden\n                className={`pointer-events-none absolute inset-y-0 z-[1] w-px transition-colors duration-150 ease-standard ${\n                  holdsFocus ? \"bg-line-strong\" : \"bg-line-subtle\"\n                }`}\n                // One anchor line per level: the centre of this folder's chevron.\n                style={{ left: `calc(var(--tree-indent) * ${depth} + 6px + 7.5px)` }}\n              />\n              <Branch nodes={node.children!} depth={depth + 1} parent={node.id} ctx={ctx} />\n            </ul>\n          )}\n        </AutoHeight>\n      )}\n    </li>\n  );\n}\n\nfunction contains(node: TreeNode, id: string): boolean {\n  return node.children?.some((c) => c.id === id || contains(c, id)) ?? false;\n}\n\nfunction Chevron({ open }: { open: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      className={`size-3.5 [transition:rotate_var(--eased-duration-spring-smooth)_var(--eased-spring-smooth)] reduced:[transition:none] ${open ? \"rotate-90\" : \"\"}`}\n    >\n      <path d=\"m6.5 4 4 4-4 4\" />\n    </svg>\n  );\n}\n\n/** The default icon slot: a folder that opens while expanded, and a page for leaves. */\nfunction defaultIcon(_node: TreeNode, { folder, expanded }: TreeIconState): ReactNode {\n  if (!folder) return <TreeFileIcon />;\n  return expanded ? <TreeFolderOpenIcon /> : <TreeFolderIcon />;\n}\n\nconst iconProps = {\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n  \"aria-hidden\": true,\n  className: \"size-4\",\n} as const;\n\nexport function TreeFolderIcon() {\n  return (\n    <svg {...iconProps}>\n      <path d=\"M2 4.5c0-.8.7-1.5 1.5-1.5h2.6l1.5 1.5h4.9c.8 0 1.5.7 1.5 1.5v5.5c0 .8-.7 1.5-1.5 1.5h-9c-.8 0-1.5-.7-1.5-1.5z\" />\n    </svg>\n  );\n}\n\nexport function TreeFolderOpenIcon() {\n  return (\n    <svg {...iconProps}>\n      <path d=\"M2 11.5v-7C2 3.7 2.7 3 3.5 3h2.6l1.5 1.5h4.4c.8 0 1.5.7 1.5 1.5v.5\" />\n      <path d=\"M2 11.5 3.6 7.6c.2-.4.6-.6 1-.6h9.1c.5 0 .9.5.7 1l-1.2 3.5c-.2.6-.8 1-1.4 1H3.5c-.8 0-1.5-.7-1.5-1z\" />\n    </svg>\n  );\n}\n\nexport function TreeFileIcon() {\n  return (\n    <svg {...iconProps}>\n      <path d=\"M4.5 2h4.2L12 5.3v7.2c0 .8-.7 1.5-1.5 1.5h-6c-.8 0-1.5-.7-1.5-1.5v-9C3 2.7 3.7 2 4.5 2z\" />\n      <path d=\"M8.5 2v3.5H12\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/tree/tree.tsx"
    }
  ],
  "meta": {
    "industries": [
      "devtools",
      "saas",
      "legal",
      "ai"
    ]
  },
  "docs": "Import Tree and TreeNode from components/eased/tree/tree. Give it a max height and it scrolls with edge fades. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}