{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\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\n/** File glyphs take a hue from their extension, mixed toward ink so they stay legible in both themes. */\nfunction fileTone(label: string) {\n  const name = label.toLowerCase();\n  if (name.endsWith(\".tsx\") || name.endsWith(\".ts\")) return \"text-[color-mix(in_oklab,#3178c6_78%,var(--ink))]\";\n  if (name.endsWith(\".css\")) return \"text-[color-mix(in_oklab,#8b6ace_73%,var(--ink))]\";\n  if (name.endsWith(\".md\")) return \"text-[color-mix(in_oklab,#38896b_74%,var(--ink))]\";\n  if (name.endsWith(\".json\")) return \"text-[color-mix(in_oklab,#c48637_78%,var(--ink))]\";\n  return \"text-ink-3\";\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 (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        key={open ? \"open\" : \"closed\"}\n        className=\"absolute inset-0 grid place-items-center\"\n        initial={reduced ? false : { opacity: 0, scale: 0.6, filter: blur(motionTokens.blur.subtle) }}\n        animate={{ opacity: 1, scale: 1, filter: blur(0) }}\n        exit={\n          reduced\n            ? { opacity: 0, transition: still }\n            : { opacity: 0, scale: 0.6, filter: blur(motionTokens.blur.subtle), transition: fadeOut }\n        }\n        transition={reduced ? still : { ...motionTokens.spring.snappy, opacity: fadeIn, filter: fadeIn }}\n      >\n        {open ? <FolderOpen size={16} strokeWidth={1.7} /> : <Folder size={16} strokeWidth={1.7} />}\n      </motion.span>\n    </AnimatePresence>\n  );\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 (\n    <motion.span\n      aria-hidden=\"true\"\n      layoutId={layoutId}\n      className=\"absolute inset-0 z-0 rounded-[10px] bg-hover\"\n      transition={reduced ? still : motionTokens.spring.morph}\n    />\n  );\n}\n\nexport function TreeView({\n  nodes,\n  defaultExpandedIds = [],\n  expandedIds,\n  onExpandedChange,\n  onSelect,\n  \"aria-label\": ariaLabel = \"File tree\",\n}: 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);\n    else {\n      next.add(node.id);\n      setOpenedId(node.id);\n    }\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\") {\n      event.preventDefault();\n      focus(visible[Math.min(index + 1, visible.length - 1)]!.node.id);\n      return;\n    }\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      focus(visible[Math.max(index - 1, 0)]!.node.id);\n      return;\n    }\n    if (event.key === \"Home\") {\n      event.preventDefault();\n      focus(visible[0]!.node.id);\n      return;\n    }\n    if (event.key === \"End\") {\n      event.preventDefault();\n      focus(visible[visible.length - 1]!.node.id);\n      return;\n    }\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 (\n    <div\n      className=\"block w-full min-w-0 overflow-hidden rounded-[16px] border border-line bg-surface px-2 pt-1.5 pb-2 text-ink\"\n      role=\"tree\"\n      aria-label={ariaLabel}\n      aria-multiselectable=\"false\"\n    >\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 =\n            openedIndex >= 0 && index > openedIndex\n              ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item\n              : 0;\n          return (\n            <motion.div\n              key={item.node.id}\n              // Rows clip only while their height animates (Motion sets overflow), so the selection can glide between them unclipped.\n              className=\"relative flow-root h-[38px] min-w-0\"\n              layout={reduced ? false : \"position\"}\n              initial={reduced ? false : { height: 0, opacity: 0, x: -6, overflow: \"hidden\" }}\n              animate={{\n                height: rowHeight,\n                opacity: 1,\n                x: 0,\n                pointerEvents: \"auto\",\n                transitionEnd: { overflow: \"visible\" },\n                transition: reduced\n                  ? still\n                  : {\n                      height: motionTokens.spring.smooth,\n                      opacity: { ...fadeIn, delay },\n                      x: { ...motionTokens.spring.smooth, delay },\n                    },\n              }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: still }\n                  : {\n                      height: 0,\n                      opacity: 0,\n                      x: -4,\n                      overflow: \"hidden\",\n                      pointerEvents: \"none\",\n                      transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut },\n                    }\n              }\n              transition={reduced ? still : { layout: motionTokens.spring.smooth }}\n            >\n              <button\n                ref={(element) => {\n                  if (element) refs.current.set(item.node.id, element);\n                  else refs.current.delete(item.node.id);\n                }}\n                type=\"button\"\n                // Selection changes color only; a heavier weight would reflow the label.\n                className=\"relative mt-0.5 flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-[10px] border-0 bg-transparent pr-2.5 pl-[calc(10px+(var(--tree-depth)-1)*18px)] text-left text-(length:--text-sm) leading-none text-ink-2 outline-none transition-[color,background-color] duration-fast ease-standard hover:bg-hover hover:text-ink aria-selected:text-ink motion-reduce:transition-none\"\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={() => {\n                  setFocusedId(item.node.id);\n                  select(item.node);\n                  if (hasChildren) toggle(item.node);\n                }}\n              >\n                {isSelected && <Selection layoutId={selectionLayoutId} reduced={reduced} />}\n                {item.depth > 1 && (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute top-1 bottom-1 left-[calc(16.5px+(var(--tree-depth)-2)*18px)] z-1 w-px origin-top bg-line\"\n                    initial={reduced ? false : { opacity: 0, scaleY: 0 }}\n                    animate={{ opacity: 1, scaleY: 1 }}\n                    transition={\n                      reduced\n                        ? still\n                        : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay }\n                    }\n                  />\n                )}\n                <motion.span\n                  className=\"relative z-1 grid h-4 w-3.5 flex-[0_0_14px] place-items-center text-ink-3\"\n                  aria-hidden=\"true\"\n                  initial={false}\n                  animate={{ rotate: isExpanded ? 90 : 0 }}\n                  transition={reduced ? still : motionTokens.spring.snappy}\n                >\n                  {hasChildren ? <ChevronRight size={14} strokeWidth={1.9} /> : null}\n                </motion.span>\n                <span\n                  className={cn(\n                    \"relative z-1 grid size-4 flex-[0_0_16px] place-items-center transition-[color] duration-fast ease-standard motion-reduce:transition-none\",\n                    hasChildren ? (isExpanded || isSelected ? \"text-ink\" : \"text-ink-2\") : fileTone(item.node.label),\n                  )}\n                  aria-hidden=\"true\"\n                >\n                  {item.node.icon ??\n                    (hasChildren ? (\n                      <FolderIcon open={isExpanded} reduced={reduced} />\n                    ) : (\n                      <File size={16} strokeWidth={1.7} />\n                    ))}\n                </span>\n                {/* Its own line height: the row sets 1, and the clipped box (needed for the ellipsis) would cut descenders like g. */}\n                <span className=\"relative z-1 min-w-0 truncate leading-[1.45]\">{item.node.label}</span>\n              </button>\n            </motion.div>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport default TreeView;\n",
      "type": "registry:component",
      "target": "@components/iris/tree-view/tree-view.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/tree-view",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "TreeView",
    "tags": [
      "data",
      "navigation"
    ],
    "docs": "https://irisui.dev/components/tree-view"
  }
}
