{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:component","title":"Tree view","description":"Folders unfold in place while the selection glides to the file you pick.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/tree-view.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TreeNode = { name: string; children?: TreeNode[] };\n\ntype Item = { id: string; name: string; parent: string | null; folder: boolean };\n\n// Each level steps in by 16px, the width of the chevron column, so a child's\n// chevron sits just right of its parent's guide.\nconst INDENT = 16;\nconst ROW_PAD = 8;\nconst EASE = \"ease-[cubic-bezier(0.23,1,0.32,1)]\";\nconst HIGHLIGHT = { type: \"spring\", duration: 0.25, bounce: 0 } as const;\n// Long enough to type \"pa\" at a normal pace, short enough that a fresh\n// letter a moment later starts a new search.\nconst TYPEAHEAD_RESET = 500;\n\nconst join = (parent: string | null, name: string) => (parent ? `${parent}/${name}` : name);\n\n// The rows a keyboard user can reach: everything not inside a closed folder.\nfunction flatten(nodes: TreeNode[], open: Set<string>, parent: string | null = null): Item[] {\n  return nodes.flatMap((node) => {\n    const id = join(parent, node.name);\n    const item = { id, name: node.name, parent, folder: !!node.children };\n    return node.children && open.has(id) ? [item, ...flatten(node.children, open, id)] : [item];\n  });\n}\n\n// A toggled folder row never moves: only rows below it shift, siblings never\n// auto-collapse, and toggling never scrolls. Pin the demo with anchor \"top\".\nexport function TreeView({\n  nodes,\n  label,\n  defaultOpen = [],\n  defaultSelected,\n  onSelect,\n  className,\n}: {\n  nodes: TreeNode[];\n  label: string;\n  defaultOpen?: string[];\n  defaultSelected?: string;\n  onSelect?: (path: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const group = useId();\n  const [open, setOpen] = useState(() => new Set(defaultOpen));\n  const [selected, setSelected] = useState(defaultSelected ?? null);\n  const visible = useMemo(() => flatten(nodes, open), [nodes, open]);\n  const [focused, setFocused] = useState(() => defaultSelected ?? visible[0]?.id);\n  const items = useRef(new Map<string, HTMLLIElement>());\n  const typed = useRef(\"\");\n  const typedTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => () => clearTimeout(typedTimer.current), []);\n\n  const focus = (id: string) => {\n    setFocused(id);\n    items.current.get(id)?.focus();\n  };\n\n  const setFolder = (id: string, next: boolean) =>\n    setOpen((current) => {\n      const copy = new Set(current);\n      if (next) copy.add(id);\n      else copy.delete(id);\n      return copy;\n    });\n\n  const activate = (item: Item) => {\n    if (item.folder) return setFolder(item.id, !open.has(item.id));\n    setSelected(item.id);\n    onSelect?.(item.id);\n  };\n\n  const typeahead = (key: string, from: number) => {\n    clearTimeout(typedTimer.current);\n    typedTimer.current = setTimeout(() => (typed.current = \"\"), TYPEAHEAD_RESET);\n    const next = typed.current + key.toLowerCase();\n    // Repeating one letter cycles through the items that start with it.\n    const repeat = [...next].every((c) => c === next[0]);\n    typed.current = repeat ? next[0] : next;\n    const start = repeat ? from + 1 : from;\n    for (let n = 0; n < visible.length; n++) {\n      const item = visible[(start + n) % visible.length];\n      if (item.name.toLowerCase().startsWith(typed.current)) return focus(item.id);\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent, item: Item) => {\n    const index = visible.findIndex((v) => v.id === item.id);\n    const isOpen = open.has(item.id);\n    const go = (i: number) => {\n      const target = visible[i];\n      if (target) focus(target.id);\n    };\n    switch (e.key) {\n      case \"ArrowDown\":\n        go(index + 1);\n        break;\n      case \"ArrowUp\":\n        go(index - 1);\n        break;\n      case \"Home\":\n        go(0);\n        break;\n      case \"End\":\n        go(visible.length - 1);\n        break;\n      case \"ArrowRight\":\n        if (item.folder && isOpen) go(index + 1);\n        else if (item.folder) setFolder(item.id, true);\n        break;\n      case \"ArrowLeft\":\n        if (item.folder && isOpen) setFolder(item.id, false);\n        else if (item.parent) focus(item.parent);\n        break;\n      case \"Enter\":\n      case \" \":\n        activate(item);\n        break;\n      default:\n        if (e.key.length !== 1 || e.key === \" \" || e.metaKey || e.ctrlKey || e.altKey) return;\n        typeahead(e.key, index);\n    }\n    e.preventDefault();\n    // Items nest inside their parent's item; without this the parent would\n    // handle the same key again.\n    e.stopPropagation();\n  };\n\n  const selectedFolder = selected?.includes(\"/\") ? selected.slice(0, selected.lastIndexOf(\"/\")) : null;\n\n  const render = (list: TreeNode[], depth: number, parent: string | null): React.ReactNode =>\n    list.map((node) => {\n      const id = join(parent, node.name);\n      const item: Item = { id, name: node.name, parent, folder: !!node.children };\n      const isOpen = open.has(id);\n      const isSelected = selected === id;\n      return (\n        <li\n          key={id}\n          ref={(el) => {\n            if (el) items.current.set(id, el);\n            else items.current.delete(id);\n          }}\n          role=\"treeitem\"\n          aria-expanded={item.folder ? isOpen : undefined}\n          aria-selected={item.folder ? undefined : isSelected}\n          tabIndex={focused === id ? 0 : -1}\n          onKeyDown={(e) => onKeyDown(e, item)}\n          onFocus={(e) => {\n            if (e.target === e.currentTarget) setFocused(id);\n          }}\n          // The item holds its children too, so the ring goes on the row alone.\n          className=\"outline-hidden [&:focus-visible>div]:outline-2 [&:focus-visible>div]:outline-solid [&:focus-visible>div]:-outline-offset-2 [&:focus-visible>div]:outline-foreground\"\n        >\n          <div\n            onClick={() => activate(item)}\n            style={{ paddingLeft: ROW_PAD + depth * INDENT }}\n            className={cn(\n              \"relative flex h-8 cursor-default items-center gap-1.5 rounded-md pr-2 text-[15px] transition-[color,background-color] duration-150 ease-out select-none hover:bg-muted/60\",\n              isSelected || item.folder ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n            )}\n          >\n            {/* Every row spans the full width, so moving the highlight is a\n                pure translate and nothing inside it stretches. */}\n            {isSelected && (\n              <motion.span\n                layoutId=\"tree-highlight\"\n                transition={reduceMotion ? { duration: 0 } : HIGHLIGHT}\n                className=\"absolute inset-0 rounded-md bg-muted\"\n              />\n            )}\n            <span className=\"relative flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n              {item.folder && (\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  aria-hidden\n                  className={cn(\n                    \"size-3.5 transition-[rotate] duration-200 motion-reduce:transition-none\",\n                    EASE,\n                    isOpen && \"rotate-90\",\n                  )}\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                >\n                  <path d=\"m6 4 4 4-4 4\" />\n                </svg>\n              )}\n            </span>\n            <svg\n              viewBox=\"0 0 16 16\"\n              aria-hidden\n              className=\"relative size-4 shrink-0 text-muted-foreground\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              {item.folder ? (\n                <path d=\"M2.25 4.5A1.25 1.25 0 0 1 3.5 3.25h2.6l1.4 1.5h5a1.25 1.25 0 0 1 1.25 1.25v5.5a1.25 1.25 0 0 1-1.25 1.25h-9A1.25 1.25 0 0 1 2.25 11.5z\" />\n              ) : (\n                <path d=\"M4.25 2.25h4.5l3 3v7.25a1.25 1.25 0 0 1-1.25 1.25h-6.25A1.25 1.25 0 0 1 3 12.5v-9a1.25 1.25 0 0 1 1.25-1.25zM8.5 2.5v3h3\" />\n              )}\n            </svg>\n            <span className=\"relative truncate\">{node.name}</span>\n          </div>\n          {node.children && (\n            <Collapse open={isOpen}>\n              <ul role=\"group\" className=\"relative\">\n                {render(node.children, depth + 1, id)}\n                {/* After the rows so it paints over the highlight, the way an\n                    editor draws its guides; 7.5px centres it under the\n                    parent's 16px chevron column. */}\n                <span\n                  aria-hidden\n                  style={{ left: ROW_PAD + depth * INDENT + 7.5 }}\n                  className={cn(\n                    \"pointer-events-none absolute inset-y-0 w-px transition-[background-color] duration-150 ease-out\",\n                    selectedFolder === id ? \"bg-foreground/35\" : \"bg-border\",\n                  )}\n                />\n              </ul>\n            </Collapse>\n          )}\n        </li>\n      );\n    });\n\n  return (\n    <LayoutGroup id={group}>\n      <ul role=\"tree\" aria-label={label} className={cn(\"flex flex-col\", className)}>\n        {render(nodes, 0, null)}\n      </ul>\n    </LayoutGroup>\n  );\n}\n\n// Animates grid-template-rows between 0fr and 1fr, so the folder reaches its\n// real height without anything being measured.\nfunction Collapse({ open, children }: { open: boolean; children: React.ReactNode }) {\n  // Clipping is only needed while the rows move. A folder that has finished\n  // opening stops clipping, so the highlight can glide in from a row outside\n  // it without being cut off at its edge.\n  const [settledAt, setSettledAt] = useState(open);\n  const settled = open && settledAt === open;\n  return (\n    <div\n      inert={!open}\n      onTransitionEnd={(e) => {\n        if (e.target === e.currentTarget && e.propertyName === \"grid-template-rows\") {\n          setSettledAt(open);\n        }\n      }}\n      className={cn(\n        \"grid motion-reduce:transition-none\",\n        EASE,\n        open\n          ? \"grid-rows-[1fr] transition-[grid-template-rows] duration-250\"\n          : \"grid-rows-[0fr] transition-[grid-template-rows] duration-200\",\n      )}\n    >\n      {/* Rows fade in a beat behind the opening and leave at once, so text is\n          never seen squeezed by a closing folder. */}\n      <div\n        className={cn(\n          \"min-h-0 motion-reduce:transition-[opacity]\",\n          settled ? \"overflow-visible\" : \"overflow-hidden\",\n          open\n            ? \"opacity-100 blur-[0px] transition-[opacity,filter] delay-50 duration-200 ease-out\"\n            : \"opacity-0 blur-[2px] transition-[opacity,filter] duration-120 ease-out\",\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n\nconst PROJECT: TreeNode[] = [\n  {\n    name: \"src\",\n    children: [\n      {\n        name: \"app\",\n        children: [{ name: \"layout.tsx\" }, { name: \"page.tsx\" }, { name: \"globals.css\" }],\n      },\n      {\n        name: \"lab\",\n        children: [\n          {\n            name: \"components\",\n            children: [\n              { name: \"code-block.tsx\" },\n              { name: \"reading-progress.tsx\" },\n              { name: \"tree-view.tsx\" },\n            ],\n          },\n          { name: \"registry.ts\" },\n        ],\n      },\n      { name: \"lib\", children: [{ name: \"cn.ts\" }] },\n    ],\n  },\n  { name: \"public\", children: [{ name: \"favicon.svg\" }, { name: \"og.png\" }] },\n  { name: \"package.json\" },\n  { name: \"README.md\" },\n];\n\nexport default function TreeViewDemo() {\n  return (\n    // 6px of padding around 6px row corners keeps the radii concentric: 12 = 6 + 6.\n    <div className=\"w-[360px] max-w-full rounded-xl bg-background p-1.5 shadow-raised\">\n      <TreeView\n        nodes={PROJECT}\n        label=\"Project files\"\n        defaultOpen={[\"src\", \"src/app\"]}\n        defaultSelected=\"src/app/page.tsx\"\n      />\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/tree-view"}