{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-view",
  "type": "registry:ui",
  "title": "Tree view",
  "description": "Navigate nested folders and structured content.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/tree-view/tree-view.tsx",
      "type": "registry:component",
      "target": "@components/xzero/tree-view/tree-view.tsx",
      "content": "\"use client\";\n\nimport { useId, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./tree-view.module.css\";\n\nexport interface TreeNode {\n  id: string;\n  label: string;\n  children?: TreeNode[];\n  icon?: ReactNode;\n}\n\nexport interface TreeViewProps {\n  nodes: TreeNode[];\n  defaultExpandedIds?: string[];\n  expandedIds?: string[];\n  onExpandedChange?: (expandedIds: string[]) => void;\n  onSelect?: (node: TreeNode) => void;\n  \"aria-label\"?: string;\n}\n\ntype VisibleNode = {\n  node: TreeNode;\n  depth: number;\n  parentId?: string;\n  position: number;\n  setSize: number;\n};\n\n/** A row is 36px plus the 2px gap above it; both collapse together, so nothing snaps when a row leaves. */\nconst rowHeight = 38;\nconst still = { duration: 0 } as const;\nconst fadeIn = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } as const;\nconst fadeOut = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } as const;\nconst blur = (px: number) => `blur(${px}px)`;\n\nfunction flatten(nodes: TreeNode[], expanded: ReadonlySet<string>, depth = 1, parentId?: string): VisibleNode[] {\n  return nodes.flatMap((node, index) => [\n    { node, depth, parentId, position: index + 1, setSize: nodes.length },\n    ...(node.children && expanded.has(node.id) ? flatten(node.children, expanded, depth + 1, node.id) : []),\n  ]);\n}\n\nfunction fileTone(label: string, classNames: Record<string, string>) {\n  const name = label.toLowerCase();\n  if (name.endsWith(\".tsx\") || name.endsWith(\".ts\")) return classNames.typescript;\n  if (name.endsWith(\".css\")) return classNames.stylesheet;\n  if (name.endsWith(\".md\")) return classNames.markdown;\n  if (name.endsWith(\".json\")) return classNames.json;\n  return classNames.file;\n}\n\n/** Same markup with or without reduced motion, so the server render always hydrates cleanly; only the transition changes. */\nfunction FolderIcon({ open, reduced }: { open: boolean; reduced: boolean }) {\n  return <AnimatePresence initial={false} mode=\"popLayout\">\n    <motion.span\n      key={open ? \"open\" : \"closed\"}\n      className={styles.folderGlyph}\n      initial={reduced ? false : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) }}\n      animate={{ opacity: 1, scale: 1, filter: blur(0) }}\n      exit={reduced ? { opacity: 0, transition: still } : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle), transition: fadeOut }}\n      transition={reduced ? still : { ...motionTokens.spring.snappy, opacity: fadeIn, filter: fadeIn }}\n    >{open ? <FolderOpen size={16} strokeWidth={1.7} /> : <Folder size={16} strokeWidth={1.7} />}</motion.span>\n  </AnimatePresence>;\n}\n\n/**\n * The selection fill glides between rows through a shared layoutId. A row that is leaving drops its fill at once: left\n * in place, it held the row's exit open, so the row stayed in the tree with pointer events off and the next click on it\n * after reopening its folder did nothing.\n */\nfunction Selection({ layoutId, reduced }: { layoutId: string; reduced: boolean }) {\n  const present = useIsPresent();\n  if (!present) return null;\n  return <motion.span aria-hidden=\"true\" layoutId={layoutId} className={styles.selection} transition={reduced ? still : motionTokens.spring.morph} />;\n}\n\nexport function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpandedChange, onSelect, \"aria-label\": ariaLabel = \"File tree\" }: TreeViewProps) {\n  const reduced = useReducedMotion() ?? false;\n  const selectionLayoutId = `tree-selection-${useId()}`;\n  const [internalExpanded, setInternalExpanded] = useState(() => new Set(defaultExpandedIds));\n  const [focusedId, setFocusedId] = useState<string | null>(nodes[0]?.id ?? null);\n  const [selectedId, setSelectedId] = useState<string | null>(null);\n  /** The folder that opened last; its new children fade in one after another, then everything else stays still. */\n  const [openedId, setOpenedId] = useState<string | null>(null);\n  const refs = useRef(new Map<string, HTMLButtonElement>());\n  const expanded = expandedIds ? new Set(expandedIds) : internalExpanded;\n  const visible = flatten(nodes, expanded);\n  const focusableId = visible.some(({ node }) => node.id === focusedId) ? focusedId : (visible[0]?.node.id ?? null);\n  const openedIndex = visible.findIndex(({ node }) => node.id === openedId);\n\n  function setExpanded(next: Set<string>) {\n    if (!expandedIds) setInternalExpanded(next);\n    onExpandedChange?.([...next]);\n  }\n\n  function toggle(node: TreeNode) {\n    if (!node.children?.length) return;\n    const next = new Set(expanded);\n    if (next.has(node.id)) next.delete(node.id); else { next.add(node.id); setOpenedId(node.id); }\n    setExpanded(next);\n  }\n\n  function focus(id: string) {\n    setFocusedId(id);\n    const row = refs.current.get(id);\n    if (row) row.focus();\n    else requestAnimationFrame(() => refs.current.get(id)?.focus());\n  }\n\n  function select(node: TreeNode) {\n    setSelectedId(node.id);\n    onSelect?.(node);\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, item: VisibleNode, index: number) {\n    const { node } = item;\n    if (event.key === \"ArrowDown\") { event.preventDefault(); focus(visible[Math.min(index + 1, visible.length - 1)]!.node.id); return; }\n    if (event.key === \"ArrowUp\") { event.preventDefault(); focus(visible[Math.max(index - 1, 0)]!.node.id); return; }\n    if (event.key === \"Home\") { event.preventDefault(); focus(visible[0]!.node.id); return; }\n    if (event.key === \"End\") { event.preventDefault(); focus(visible[visible.length - 1]!.node.id); return; }\n    if (event.key === \"ArrowRight\" && node.children?.length) {\n      event.preventDefault();\n      if (!expanded.has(node.id)) toggle(node);\n      else if (visible[index + 1]?.parentId === node.id) focus(visible[index + 1]!.node.id);\n      return;\n    }\n    if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      if (node.children?.length && expanded.has(node.id)) toggle(node);\n      else if (item.parentId) focus(item.parentId);\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      select(node);\n      if (node.children?.length) toggle(node);\n    }\n  }\n\n  return <div className={styles.tree} role=\"tree\" aria-label={ariaLabel} aria-multiselectable=\"false\">\n    <AnimatePresence initial={false} mode=\"sync\">\n      {visible.map((item, index) => {\n        const hasChildren = Boolean(item.node.children?.length);\n        const isExpanded = hasChildren && expanded.has(item.node.id);\n        const isSelected = selectedId === item.node.id;\n        // Only rows that mount use this delay: the space opens in one motion and the new children follow in order.\n        const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;\n        return <motion.div\n          key={item.node.id}\n          className={styles.row}\n          layout={reduced ? false : \"position\"}\n          initial={reduced ? false : { height: 0, opacity: 0, x: -6, overflow: \"hidden\" }}\n          animate={{ height: rowHeight, opacity: 1, x: 0, pointerEvents: \"auto\", transitionEnd: { overflow: \"visible\" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }}\n          exit={reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: \"hidden\", pointerEvents: \"none\", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }}\n          transition={reduced ? still : { layout: motionTokens.spring.smooth }}\n        >\n          <button\n            ref={element => { if (element) refs.current.set(item.node.id, element); else refs.current.delete(item.node.id); }}\n            type=\"button\"\n            className={styles.item}\n            role=\"treeitem\"\n            aria-level={item.depth}\n            aria-posinset={item.position}\n            aria-setsize={item.setSize}\n            aria-expanded={hasChildren ? isExpanded : undefined}\n            aria-selected={isSelected}\n            tabIndex={focusableId === item.node.id ? 0 : -1}\n            style={{ \"--tree-depth\": item.depth } as CSSProperties}\n            onFocus={() => setFocusedId(item.node.id)}\n            onKeyDown={event => onKeyDown(event, item, index)}\n            onClick={() => { setFocusedId(item.node.id); select(item.node); if (hasChildren) toggle(item.node); }}\n          >\n            {isSelected && <Selection layoutId={selectionLayoutId} reduced={reduced} />}\n            {item.depth > 1 && <motion.span\n              aria-hidden=\"true\"\n              className={styles.branch}\n              initial={reduced ? false : { opacity: 0, scaleY: 0 }}\n              animate={{ opacity: 1, scaleY: 1 }}\n              transition={reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay }}\n            />}\n            <motion.span\n              className={styles.disclosure}\n              aria-hidden=\"true\"\n              initial={false}\n              animate={{ rotate: isExpanded ? 90 : 0 }}\n              transition={reduced ? still : motionTokens.spring.snappy}\n            >{hasChildren ? <ChevronRight size={14} strokeWidth={1.9} /> : null}</motion.span>\n            <span className={[styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(\" \")} aria-hidden=\"true\">\n              {item.node.icon ?? (hasChildren ? <FolderIcon open={isExpanded} reduced={reduced} /> : <File size={16} strokeWidth={1.7} />)}\n            </span>\n            <span className={styles.label}>{item.node.label}</span>\n          </button>\n        </motion.div>;\n      })}\n    </AnimatePresence>\n  </div>;\n}\n\nexport default TreeView;\n"
    },
    {
      "path": "registry/components/tree-view/tree-view.module.css",
      "type": "registry:component",
      "target": "@components/xzero/tree-view/tree-view.module.css",
      "content": ".tree { display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: 6px 8px 8px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--foreground); overflow: hidden; }\n/* Rows clip only while their height animates (Motion sets overflow), so the selection can glide between them unclipped. */\n.row { position: relative; display: flow-root; min-width: 0; height: 38px; }\n.item { position: relative; display: flex; width: 100%; min-width: 0; height: 36px; margin-top: 2px; align-items: center; gap: 8px; padding: 0 10px; padding-left: calc(10px + (var(--tree-depth) - 1) * 18px); border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); line-height: 1; text-align: left; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n/* Selection changes color only; a heavier weight would reflow the label. */\n.item[aria-selected=\"true\"] { color: var(--foreground); }\n.selection { position: absolute; inset: 0; z-index: 0; border-radius: 10px; background: var(--surface-muted); }\n.branch { position: absolute; top: 4px; bottom: 4px; left: calc(16.5px + (var(--tree-depth) - 2) * 18px); z-index: 1; width: 1px; background: var(--border); transform-origin: top; pointer-events: none; }\n.disclosure, .icon, .label { position: relative; z-index: 1; }\n.disclosure { display: grid; width: 14px; height: 16px; flex: 0 0 14px; place-items: center; color: var(--text-muted); }\n.icon { display: grid; width: 16px; height: 16px; flex: 0 0 16px; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }\n.folder { color: var(--text-secondary); }\n.item[aria-expanded=\"true\"] .folder, .item[aria-selected=\"true\"] .folder { color: var(--foreground); }\n.folderGlyph { position: absolute; inset: 0; display: grid; place-items: center; }\n.typescript { color: color-mix(in oklab, #3178c6 78%, var(--foreground)); }\n.stylesheet { color: color-mix(in oklab, #8b6ace 73%, var(--foreground)); }\n.markdown { color: color-mix(in oklab, #38896b 74%, var(--foreground)); }\n.json { color: color-mix(in oklab, #c48637 78%, var(--foreground)); }\n/* Its own line height: the row sets 1, and the clipped box (needed for the ellipsis) would cut descenders like g. */\n.label { min-width: 0; overflow: hidden; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }\n@media (hover: hover) and (pointer: fine) { .item:hover { background: var(--surface-muted); color: var(--foreground); } }\n@media (prefers-reduced-motion: reduce) { .item, .icon { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/tree-view",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "navigation"
    ],
    "docs": "https://ui.xzero.co.uk/components/tree-view",
    "markdown": "https://ui.xzero.co.uk/components/tree-view/markdown"
  }
}