{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "type": "registry:ui",
  "title": "JSON viewer",
  "description": "A collapsible JSON tree with search, paging for long arrays, and copy value or path.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/json-viewer/json-viewer.tsx",
      "type": "registry:component",
      "target": "@components/xzero/json-viewer/json-viewer.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useReducedMotion } from \"motion/react\";\nimport type { AnimationPlaybackControls, Transition } from \"motion/react\";\nimport { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Copy, Link2, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./json-viewer.module.css\";\n\nexport type JsonValueType = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\" | \"other\";\n\nexport interface JsonViewerCopyDetail {\n  kind: \"value\" | \"path\";\n  path: string;\n  text: string;\n}\n\n/**\n * A collapsible tree for JSON. Values are colored by type, branches unfold row by row, search highlights every match and\n * opens the branches that hold them, long arrays and objects load in pages, and each row can copy its value or path.\n * The tree follows the WAI-ARIA tree pattern: arrows move and open, Home and End jump, Enter toggles, and the modifier\n * key with C copies the focused value.\n */\nexport interface JsonViewerProps {\n  data: unknown;\n  /** Name of the root in paths, as in `root.users[0].name`. Defaults to \"root\". */\n  rootName?: string;\n  /** Levels open on first render when `defaultExpanded` is not set. Defaults to 1, which opens the root. */\n  defaultExpandDepth?: number;\n  /** Paths of open branches. */\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (paths: string[]) => void;\n  /** Show the search field. Defaults to true. */\n  searchable?: boolean;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  /** Children shown per page in a long array or object. Defaults to 50. */\n  pageSize?: number;\n  /** Show copy value and copy path actions. Defaults to true. */\n  copyable?: boolean;\n  onCopy?: (detail: JsonViewerCopyDetail) => void;\n  /** Called when a row becomes the current one. */\n  onSelect?: (detail: { path: string; value: unknown; type: JsonValueType }) => void;\n  /** Show the path of the current row under the tree. Defaults to true. */\n  showPath?: boolean;\n  /** Height of the scrolling tree area. Defaults to 420px. */\n  maxHeight?: number | string;\n  /** Accessible name of the tree. */\n  label?: string;\n  className?: string;\n}\n\ntype Row = {\n  id: string;\n  kind: \"node\" | \"more\";\n  level: number;\n  name: string | number | null;\n  value: unknown;\n  type: JsonValueType;\n  count: number;\n  open: boolean;\n  parent: string | null;\n  posinset: number;\n  setsize: number;\n  /** For \"more\" rows: how many children are still hidden. */\n  hidden?: number;\n};\n\nconst ROW = 30;\nconst IDENT = /^[A-Za-z_$][\\w$]*$/;\n\nfunction typeOf(value: unknown): JsonValueType {\n  if (value === null) return \"null\";\n  if (Array.isArray(value)) return \"array\";\n  switch (typeof value) {\n    case \"object\": return \"object\";\n    case \"string\": return \"string\";\n    case \"number\": case \"bigint\": return \"number\";\n    case \"boolean\": return \"boolean\";\n    default: return \"other\";\n  }\n}\nconst isBranch = (type: JsonValueType) => type === \"object\" || type === \"array\";\nfunction entries(value: unknown): [string | number, unknown][] {\n  if (Array.isArray(value)) return value.map((item, index) => [index, item]);\n  if (value && typeof value === \"object\") return Object.entries(value as Record<string, unknown>);\n  return [];\n}\nconst childPath = (parent: string, key: string | number) => typeof key === \"number\" ? `${parent}[${key}]` : IDENT.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;\nconst within = (ancestor: string, id: string) => id === ancestor || id.startsWith(`${ancestor}.`) || id.startsWith(`${ancestor}[`);\n\nfunction primitiveText(value: unknown, type: JsonValueType) {\n  if (type === \"string\") return value as string;\n  if (type === \"null\") return \"null\";\n  return String(value);\n}\nfunction copyText(value: unknown, type: JsonValueType) {\n  if (type === \"string\") return value as string;\n  if (isBranch(type)) return JSON.stringify(value, null, 2);\n  return primitiveText(value, type);\n}\nconst plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;\nconst countLabel = (row: { type: JsonValueType; count: number }) => row.type === \"array\" ? plural(row.count, \"item\", \"items\") : plural(row.count, \"key\", \"keys\");\n\n/** Every container path, for expand all. */\nfunction allBranches(data: unknown, rootName: string) {\n  const out: string[] = [];\n  const visit = (value: unknown, id: string) => {\n    if (!isBranch(typeOf(value))) return;\n    out.push(id);\n    for (const [key, child] of entries(value)) visit(child, childPath(id, key));\n  };\n  visit(data, rootName);\n  return out;\n}\nfunction branchesToDepth(data: unknown, rootName: string, depth: number) {\n  const out: string[] = [];\n  const visit = (value: unknown, id: string, level: number) => {\n    if (level >= depth || !isBranch(typeOf(value))) return;\n    out.push(id);\n    for (const [key, child] of entries(value)) visit(child, childPath(id, key), level + 1);\n  };\n  visit(data, rootName, 0);\n  return out;\n}\n\ntype SearchResult = { matches: string[]; ancestors: Set<string>; needed: Map<string, number> };\n/** Finds keys and primitive values that contain the query, the branches that hold them, and how far each page must reach. */\nfunction searchJson(data: unknown, rootName: string, needle: string): SearchResult {\n  const matches: string[] = [];\n  const ancestors = new Set<string>();\n  const needed = new Map<string, number>();\n  const frames: [string, number][] = [];\n  const visit = (value: unknown, id: string, name: string | number | null) => {\n    if (matches.length >= 2000) return;\n    const type = typeOf(value);\n    const keyHit = typeof name === \"string\" && name.toLowerCase().includes(needle);\n    const valueHit = !isBranch(type) && primitiveText(value, type).toLowerCase().includes(needle);\n    if (keyHit || valueHit) {\n      matches.push(id);\n      for (const [frame, index] of frames) {\n        ancestors.add(frame);\n        needed.set(frame, Math.max(needed.get(frame) ?? 0, index + 1));\n      }\n    }\n    if (!isBranch(type)) return;\n    entries(value).forEach(([key, child], index) => {\n      frames.push([id, index]);\n      visit(child, childPath(id, key), key);\n      frames.pop();\n    });\n  };\n  visit(data, rootName, null);\n  return { matches, ancestors, needed };\n}\n\nfunction Highlight({ text, needle, current }: { text: string; needle: string; current: boolean }) {\n  if (!needle) return <>{text}</>;\n  const lower = text.toLowerCase();\n  const parts: ReactNode[] = [];\n  let from = 0, at = lower.indexOf(needle);\n  while (at !== -1) {\n    if (at > from) parts.push(text.slice(from, at));\n    parts.push(<mark key={at} className={styles.mark} data-current={current || undefined}>{text.slice(at, at + needle.length)}</mark>);\n    from = at + needle.length;\n    at = lower.indexOf(needle, from);\n  }\n  if (from < text.length) parts.push(text.slice(from));\n  return <>{parts}</>;\n}\n\ntype CopyState = \"idle\" | \"done\" | \"error\";\n/** Icon-only copy action. The glyph swaps in place, so the button never changes width. */\nfunction CopyButton({ label, icon, onCopy, reduced, className, focusable = false }: { label: string; icon: ReactNode; onCopy: () => Promise<void>; reduced: boolean; className?: string; focusable?: boolean }) {\n  const [state, setState] = useState<CopyState>(\"idle\");\n  const timer = useRef(0);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  const run = async () => {\n    window.clearTimeout(timer.current);\n    try { await onCopy(); setState(\"done\"); } catch { setState(\"error\"); }\n    timer.current = window.setTimeout(() => setState(\"idle\"), 1400);\n  };\n  const glyph = state === \"done\" ? <svg width={14} height={14} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><motion.path d=\"M4 12l5 5L20 6\" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: .22, ease: [...motionTokens.ease.standard], delay: .04 }} /></svg> : state === \"error\" ? <X size={14} strokeWidth={2} aria-hidden=\"true\" /> : icon;\n  return <button type=\"button\" tabIndex={focusable ? undefined : -1} className={[styles.action, className].filter(Boolean).join(\" \")} data-state={state} aria-label={state === \"done\" ? \"Copied\" : state === \"error\" ? \"Copy failed\" : label} title={label}\n    onClick={event => { event.stopPropagation(); void run(); }}>\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span key={state} className={styles.actionGlyph} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .85 }}\n        animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, transition: { duration: reduced ? 0 : .1, ease: [...motionTokens.ease.standard] } }}\n        transition={{ duration: reduced ? 0 : .16, ease: [...motionTokens.ease.standard] }}>{glyph}</motion.span>\n    </AnimatePresence>\n  </button>;\n}\n\nasync function writeClipboard(text: string) {\n  await writeClipboard(text);\n}\n\n/* Scroll, row heights and the current-row highlight share one critically damped spring, so a highlight that moves while\n * the tree scrolls travels straight to its row instead of overshooting the viewport. */\nconst scrollSpring: Transition = motionTokens.spring.smooth;\n\nexport const JsonViewer = forwardRef<HTMLDivElement, JsonViewerProps>(function JsonViewer({\n  data, rootName = \"root\", defaultExpandDepth = 1, expanded: expandedProp, defaultExpanded, onExpandedChange,\n  searchable = true, query: queryProp, defaultQuery = \"\", onQueryChange, pageSize = 50, copyable = true, onCopy, onSelect,\n  showPath = true, maxHeight = 420, label = \"JSON\", className,\n}, ref) {\n  const reduced = !!useReducedMotion();\n  const uid = useId();\n  const treeRef = useRef<HTMLDivElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const rowRefs = useRef(new Map<string, HTMLDivElement>());\n\n  /* Open branches. */\n  const [expandedInternal, setExpandedInternal] = useState<string[]>(() => defaultExpanded ?? branchesToDepth(data, rootName, defaultExpandDepth));\n  const expandedList = expandedProp ?? expandedInternal;\n  const base = useMemo(() => new Set(expandedList), [expandedList]);\n  const setExpanded = useCallback((next: Set<string>) => {\n    const list = [...next];\n    if (expandedProp === undefined) setExpandedInternal(list);\n    onExpandedChange?.(list);\n  }, [expandedProp, onExpandedChange]);\n\n  /* Search. Branches holding a match open on their own; closing one while searching is remembered until the query changes. */\n  const [queryInternal, setQueryInternal] = useState(defaultQuery);\n  const query = queryProp ?? queryInternal;\n  const needle = query.trim().toLowerCase();\n  const setQuery = (next: string) => {\n    if (queryProp === undefined) setQueryInternal(next);\n    onQueryChange?.(next);\n  };\n  const search = useMemo<SearchResult | null>(() => needle ? searchJson(data, rootName, needle) : null, [data, needle, rootName]);\n  const [closedWhileSearching, setClosedWhileSearching] = useState<{ needle: string; ids: Set<string> }>({ needle: \"\", ids: new Set() });\n  const closed = closedWhileSearching.needle === needle ? closedWhileSearching.ids : null;\n  const [matchIndex, setMatchIndex] = useState(0);\n  const matchCount = search?.matches.length ?? 0;\n  const matchSet = useMemo(() => new Set(search?.matches ?? []), [search]);\n  const currentMatch = search && matchCount ? search.matches[Math.min(matchIndex, matchCount - 1)] : null;\n\n  const isOpen = useCallback((id: string) => {\n    if (search && search.ancestors.has(id)) return !closed?.has(id);\n    return base.has(id);\n  }, [base, closed, search]);\n\n  /* Pages for long branches. */\n  const [pages, setPages] = useState<Record<string, number>>({});\n  const limitOf = useCallback((id: string) => Math.max((pages[id] ?? 1) * pageSize, search?.needed.get(id) ?? 0), [pageSize, pages, search]);\n\n  /* The visible rows, flat. Each row unfolds its own height, so nested branches, pages, and expand all share one motion. */\n  const rows = useMemo(() => {\n    const out: Row[] = [];\n    const visit = (value: unknown, id: string, name: string | number | null, level: number, parent: string | null, posinset: number, setsize: number) => {\n      const type = typeOf(value);\n      const list = isBranch(type) ? entries(value) : [];\n      const open = isBranch(type) && isOpen(id);\n      out.push({ id, kind: \"node\", level, name, value, type, count: list.length, open, parent, posinset, setsize });\n      if (!open) return;\n      const limit = limitOf(id);\n      const shown = list.slice(0, limit);\n      const more = list.length - shown.length;\n      const size = shown.length + (more > 0 ? 1 : 0);\n      shown.forEach(([key, child], index) => visit(child, childPath(id, key), key, level + 1, id, index + 1, size));\n      if (more > 0) out.push({ id: `${id}::more`, kind: \"more\", level: level + 1, name: null, value: null, type: \"other\", count: 0, open: false, parent: id, posinset: size, setsize: size, hidden: more });\n    };\n    visit(data, rootName, null, 1, null, 1, 1);\n    return out;\n  }, [data, isOpen, limitOf, rootName]);\n\n  /* The current row. When it is folded away, the nearest visible branch that holds it takes over. */\n  const [activeRaw, setActiveRaw] = useState(rootName);\n  const active = useMemo(() => {\n    if (rows.some(row => row.id === activeRaw)) return activeRaw;\n    let best = rootName;\n    for (const row of rows) if (row.kind === \"node\" && within(row.id, activeRaw) && row.id.length > best.length) best = row.id;\n    return best;\n  }, [activeRaw, rootName, rows]);\n  const activeIndex = Math.max(0, rows.findIndex(row => row.id === active));\n  /* The current row folded away and a branch above took over. The highlight lands there directly instead of flying from a row that is collapsing out of view. */\n  const folded = active !== activeRaw;\n  const activeRow = rows[activeIndex];\n\n  const onSelectRef = useRef(onSelect);\n  useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);\n  const lastReported = useRef<string | null>(null);\n  useEffect(() => {\n    if (!activeRow || activeRow.kind !== \"node\" || lastReported.current === activeRow.id) return;\n    lastReported.current = activeRow.id;\n    onSelectRef.current?.({ path: activeRow.id, value: activeRow.value, type: activeRow.type });\n  }, [activeRow]);\n\n  /* Scrolling keeps the current row in view, springing to where it will sit once rows finish unfolding. */\n  const scrollFlight = useRef<AnimationPlaybackControls | null>(null);\n  const reveal = useCallback((index: number, center: boolean) => {\n    const tree = treeRef.current;\n    if (!tree) return;\n    const top = index * ROW, bottom = top + ROW, view = tree.clientHeight, now = tree.scrollTop;\n    let target = now;\n    if (center) target = top - view / 2 + ROW / 2;\n    else if (top < now + 4) target = top - 4;\n    else if (bottom > now + view - 4) target = bottom - view + 4;\n    target = Math.max(0, target);\n    if (Math.abs(target - now) < 1) return;\n    scrollFlight.current?.stop();\n    if (reduced) { tree.scrollTop = target; return; }\n    scrollFlight.current = animate(now, target, { ...scrollSpring, onUpdate: value => { tree.scrollTop = value; } });\n  }, [reduced]);\n  useEffect(() => () => scrollFlight.current?.stop(), []);\n\n  const pendingFocus = useRef(false);\n  const pendingReveal = useRef<\"nearest\" | \"center\" | null>(null);\n  /* Bumped on every navigation, so moving to the row that is already current still focuses and reveals it. */\n  const [navigation, setNavigation] = useState(0);\n  useEffect(() => {\n    if (pendingFocus.current) {\n      pendingFocus.current = false;\n      rowRefs.current.get(active)?.focus({ preventScroll: true });\n    }\n    if (pendingReveal.current) {\n      reveal(activeIndex, pendingReveal.current === \"center\");\n      pendingReveal.current = null;\n    }\n  }, [active, activeIndex, reveal, navigation]);\n\n  const moveTo = (id: string, focus = true, how: \"nearest\" | \"center\" = \"nearest\") => {\n    pendingFocus.current = focus;\n    pendingReveal.current = how;\n    setActiveRaw(id);\n    setNavigation(count => count + 1);\n  };\n\n  const toggle = (id: string, next?: boolean) => {\n    const open = isOpen(id);\n    const want = next ?? !open;\n    if (want === open) return;\n    if (search?.ancestors.has(id)) {\n      const ids = new Set(closed ?? []);\n      if (want) ids.delete(id); else ids.add(id);\n      setClosedWhileSearching({ needle, ids });\n      if (want && !base.has(id)) setExpanded(new Set(base).add(id));\n      return;\n    }\n    const nextSet = new Set(base);\n    if (want) nextSet.add(id); else nextSet.delete(id);\n    setExpanded(nextSet);\n  };\n\n  const expandAll = () => {\n    setExpanded(new Set(allBranches(data, rootName)));\n    setClosedWhileSearching({ needle, ids: new Set() });\n    // Branches above the current row unfold and push it down; follow it to where it settles.\n    moveTo(active, false, \"nearest\");\n  };\n  const collapseAll = () => {\n    const rootOpen = isBranch(typeOf(data)) ? [rootName] : [];\n    setExpanded(new Set(rootOpen));\n    setClosedWhileSearching({ needle, ids: new Set([...(search?.ancestors ?? [])].filter(id => id !== rootName)) });\n    setPages({});\n    moveTo(rootName, false, \"nearest\");\n  };\n  const showMore = (parent: string) => setPages(current => ({ ...current, [parent]: Math.ceil(limitOf(parent) / pageSize) + 1 }));\n\n  /* Copy. */\n  const [announcement, setAnnouncement] = useState(\"\");\n  const copy = async (row: Row, kind: \"value\" | \"path\") => {\n    const text = kind === \"path\" ? row.id : copyText(row.value, row.type);\n    try {\n      await writeClipboard(text);\n      setAnnouncement(`Copied ${kind === \"path\" ? \"path\" : \"value of\"} ${row.id}`);\n      onCopy?.({ kind, path: row.id, text });\n    } catch (error) {\n      setAnnouncement(\"Copy failed\");\n      throw error;\n    }\n  };\n\n  /* Search navigation. */\n  const stepMatch = (step: number) => {\n    if (!search || !matchCount) return;\n    const next = ((Math.min(matchIndex, matchCount - 1) + step) % matchCount + matchCount) % matchCount;\n    setMatchIndex(next);\n    moveTo(search.matches[next]!, false, \"center\");\n  };\n  const onQuery = (next: string) => {\n    setQuery(next);\n    setMatchIndex(0);\n    const trimmed = next.trim().toLowerCase();\n    if (trimmed) {\n      const first = searchJson(data, rootName, trimmed).matches[0];\n      if (first) moveTo(first, false, \"center\");\n    } else if (needle) {\n      // Clearing folds the branches search opened. Aim the scroll at where the current row settles, so it stays in view.\n      moveTo(activeRaw, false, \"nearest\");\n    }\n  };\n\n  const onSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Enter\") { event.preventDefault(); stepMatch(event.shiftKey ? -1 : 1); }\n    else if (event.key === \"Escape\" && query) { event.preventDefault(); event.stopPropagation(); onQuery(\"\"); }\n    else if (event.key === \"ArrowDown\" && !event.altKey) { event.preventDefault(); moveTo(active, true); }\n  };\n\n  const onTreeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const row = rows[activeIndex];\n    if (!row || event.altKey) return;\n    const mod = event.metaKey || event.ctrlKey;\n    if (mod && event.key.toLowerCase() === \"c\") {\n      if (row.kind === \"node\" && !window.getSelection()?.toString()) { event.preventDefault(); void copy(row, event.shiftKey ? \"path\" : \"value\").catch(() => {}); }\n      return;\n    }\n    if (mod) return;\n    const go = (index: number) => { event.preventDefault(); const target = rows[Math.max(0, Math.min(rows.length - 1, index))]; if (target) moveTo(target.id); };\n    switch (event.key) {\n      case \"ArrowDown\": go(activeIndex + 1); break;\n      case \"ArrowUp\": go(activeIndex - 1); break;\n      case \"Home\": go(0); break;\n      case \"End\": go(rows.length - 1); break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        if (row.kind === \"node\" && isBranch(row.type)) { if (!row.open) toggle(row.id, true); else if (row.count) go(activeIndex + 1); }\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        if (row.kind === \"node\" && row.open) toggle(row.id, false);\n        else if (row.parent) moveTo(row.parent);\n        break;\n      case \"Enter\": case \" \":\n        event.preventDefault();\n        if (row.kind === \"more\") showMore(row.parent!);\n        else if (isBranch(row.type)) toggle(row.id);\n        break;\n      case \"/\":\n        if (searchable) { event.preventDefault(); searchRef.current?.focus(); searchRef.current?.select(); }\n        break;\n      default:\n    }\n  };\n\n  const heightStyle = { \"--json-max-height\": typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight } as CSSProperties;\n  const bulk = rows.length > 400;\n  const rowTransition: Transition = reduced || bulk ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\n\n  const describe = (row: Row) => {\n    const name = row.name === null ? rootName : String(row.name);\n    if (row.kind === \"more\") return `Show ${Math.min(pageSize, row.hidden ?? 0)} more, ${row.hidden} hidden`;\n    return isBranch(row.type) ? `${name}, ${row.type}, ${countLabel(row)}` : `${name}: ${primitiveText(row.value, row.type)}`;\n  };\n\n  return <div ref={ref} className={[styles.root, className].filter(Boolean).join(\" \")} style={heightStyle}>\n    {searchable || rows.length > 1 ? <div className={styles.toolbar}>\n      {searchable ? <label className={styles.search}>\n        <Search size={15} strokeWidth={1.75} aria-hidden=\"true\" className={styles.searchIcon} />\n        <input ref={searchRef} type=\"search\" className={styles.input} value={query} placeholder=\"Search keys and values\" aria-label=\"Search JSON\" aria-controls={`${uid}-tree`}\n          onChange={event => onQuery(event.target.value)} onKeyDown={onSearchKeyDown} spellCheck={false} autoComplete=\"off\" />\n        <AnimatePresence initial={false}>\n          {needle ? <motion.span key=\"matches\" className={styles.matches} initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} transition={{ duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }}>\n            <span className={styles.count} aria-live=\"polite\">{matchCount ? `${Math.min(matchIndex, matchCount - 1) + 1}/${matchCount}` : \"0/0\"}</span>\n            <button type=\"button\" className={styles.tool} aria-label=\"Previous match\" disabled={!matchCount} onClick={() => stepMatch(-1)}><ChevronUp size={15} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n            <button type=\"button\" className={styles.tool} aria-label=\"Next match\" disabled={!matchCount} onClick={() => stepMatch(1)}><ChevronDown size={15} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n          </motion.span> : null}\n        </AnimatePresence>\n      </label> : <span className={styles.grow} />}\n      <span className={styles.divider} aria-hidden=\"true\" />\n      <button type=\"button\" className={styles.tool} aria-label=\"Expand all\" title=\"Expand all\" onClick={expandAll}><ChevronsUpDown size={15} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n      <button type=\"button\" className={styles.tool} aria-label=\"Collapse all\" title=\"Collapse all\" onClick={collapseAll}><ChevronsDownUp size={15} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n    </div> : null}\n\n    <LayoutGroup id={uid}>\n      <motion.div ref={treeRef} layoutScroll id={`${uid}-tree`} role=\"tree\" aria-label={label} className={styles.tree} onKeyDown={onTreeKeyDown}>\n        <AnimatePresence initial={false}>\n          {rows.map(row => {\n            const isActive = row.id === active;\n            const branch = isBranch(row.type);\n            const matched = !!search && row.kind === \"node\" && matchSet.has(row.id);\n            const isCurrentMatch = row.id === currentMatch;\n            const mark = matched ? needle : \"\";\n            return <motion.div key={row.id} ref={(node: HTMLDivElement | null) => { if (node) rowRefs.current.set(row.id, node); else rowRefs.current.delete(row.id); }}\n              role=\"treeitem\" tabIndex={isActive ? 0 : -1} aria-level={row.level} aria-posinset={row.posinset} aria-setsize={row.setsize}\n              aria-expanded={row.kind === \"node\" && branch ? row.open : undefined} aria-selected={isActive} aria-label={describe(row)}\n              className={styles.row} data-kind={row.kind} data-active={isActive || undefined} style={{ \"--level\": row.level - 1 } as CSSProperties}\n              initial={{ height: 0, opacity: 0, overflow: \"hidden\" }} animate={{ height: ROW, opacity: 1, transitionEnd: { overflow: \"visible\" } }} exit={{ height: 0, opacity: 0, overflow: \"hidden\" }} transition={rowTransition}\n              onFocus={event => { if (event.target === event.currentTarget && row.id !== active) setActiveRaw(row.id); }}\n              onClick={() => {\n                if (row.kind === \"more\") { showMore(row.parent!); moveTo(row.id); return; }\n                if (branch) toggle(row.id);\n                moveTo(row.id);\n              }}>\n              {isActive ? <motion.span layoutId=\"json-active\" className={styles.highlight} transition={reduced || folded ? { duration: 0 } : scrollSpring} aria-hidden=\"true\" /> : null}\n              <span className={styles.line}>\n                {row.kind === \"more\" ? <span className={styles.more}>\n                  <span className={styles.moreLabel}>Show {Math.min(pageSize, row.hidden ?? 0)} more</span>\n                  <span className={styles.muted}>{row.hidden} hidden</span>\n                </span> : <>\n                  <span className={styles.chevron} data-open={row.open || undefined} data-branch={branch || undefined} aria-hidden=\"true\">{branch ? <ChevronRight size={14} strokeWidth={1.75} /> : null}</span>\n                  <span className={typeof row.name === \"number\" ? styles.index : styles.key}>\n                    {row.name === null ? rootName : typeof row.name === \"number\" ? row.name : <Highlight text={row.name} needle={mark} current={isCurrentMatch} />}\n                  </span>\n                  <span className={styles.colon} aria-hidden=\"true\">{row.name === null && branch ? \"\" : \":\"}</span>\n                  {branch\n                    ? <span className={styles.summary}>\n                        {!row.open ? <span className={styles.preview}>{row.type === \"array\" ? \"[…]\" : `{ ${entries(row.value).slice(0, 3).map(([key]) => key).join(\", \")}${row.count > 3 ? \", …\" : \"\"} }`}</span> : null}\n                        <span className={styles.countLabel}>{countLabel(row)}</span>\n                      </span>\n                    : <span className={styles.value} data-type={row.type} title={row.type === \"string\" && (row.value as string).length > 40 ? row.value as string : undefined}>\n                        {row.type === \"string\" ? <>&quot;<Highlight text={row.value as string} needle={mark} current={isCurrentMatch} />&quot;</> : <Highlight text={primitiveText(row.value, row.type)} needle={mark} current={isCurrentMatch} />}\n                      </span>}\n                  {copyable ? <span className={styles.actions}>\n                    <CopyButton label=\"Copy value\" icon={<Copy size={14} strokeWidth={1.75} aria-hidden=\"true\" />} reduced={reduced} onCopy={() => copy(row, \"value\")} />\n                    <CopyButton label=\"Copy path\" icon={<Link2 size={14} strokeWidth={1.75} aria-hidden=\"true\" />} reduced={reduced} onCopy={() => copy(row, \"path\")} />\n                  </span> : null}\n                </>}\n              </span>\n            </motion.div>;\n          })}\n        </AnimatePresence>\n      </motion.div>\n    </LayoutGroup>\n\n    {showPath && activeRow ? <div className={styles.pathBar}>\n      <code className={styles.path} title={activeRow.kind === \"node\" ? activeRow.id : activeRow.parent ?? \"\"}>{activeRow.kind === \"node\" ? activeRow.id : activeRow.parent}</code>\n      <span className={styles.pathType}>{activeRow.kind === \"node\" ? isBranch(activeRow.type) ? `${activeRow.type}, ${countLabel(activeRow)}` : activeRow.type : \"page\"}</span>\n      {copyable && activeRow.kind === \"node\" ? <CopyButton key={activeRow.id} focusable className={styles.pathCopy} label=\"Copy path\" icon={<Copy size={14} strokeWidth={1.75} aria-hidden=\"true\" />} reduced={reduced} onCopy={() => copy(activeRow, \"path\")} /> : null}\n    </div> : null}\n    <span className={styles.srOnly} role=\"status\">{announcement}</span>\n  </div>;\n});\n\nexport default JsonViewer;\n"
    },
    {
      "path": "registry/components/json-viewer/json-viewer.module.css",
      "type": "registry:component",
      "target": "@components/xzero/json-viewer/json-viewer.module.css",
      "content": ".root { --mono: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace; --indent: 16px; display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Toolbar: search with a match counter, then expand and collapse all. */\n.toolbar { display: flex; align-items: center; gap: 2px; padding: 6px; border-bottom: 1px solid var(--border-subtle); }\n.search { position: relative; display: flex; min-width: 0; flex: 1; align-items: center; gap: 4px; min-height: 34px; padding: 0 4px 0 10px; border-radius: 14px; background: var(--surface-muted); cursor: text; }\n.searchIcon { flex: none; color: var(--text-muted); }\n.input { min-width: 0; flex: 1; height: 34px; padding: 0 4px; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); outline: none; }\n.input::placeholder { color: var(--text-muted); }\n.input::-webkit-search-cancel-button { display: none; }\n.matches { display: inline-flex; flex: none; align-items: center; gap: 0; }\n.count { min-width: 3.5ch; padding-inline: 4px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; text-align: right; }\n.grow { flex: 1; }\n.divider { width: 1px; height: 18px; margin-inline: 4px; background: var(--border-subtle); }\n.tool { display: inline-grid; width: 34px; height: 34px; flex: none; place-items: center; padding: 0; border: 0; border-radius: 14px; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n.tool:disabled { cursor: default; opacity: .4; }\n.tool:active:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }\n.search .tool { width: 26px; height: 26px; border-radius: 9px; }\n.search .tool:active:not(:disabled) { background: var(--surface); }\n\n/* Tree. Rows are one fixed height, so scrolling can aim at where a row will settle. */\n.tree { position: relative; max-height: var(--json-max-height, 420px); padding: 6px; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; font-family: var(--mono); font-size: 13px; font-variant-ligatures: none; letter-spacing: 0; scrollbar-width: thin; }\n.row { position: relative; border-radius: 10px; cursor: default; outline: none; -webkit-tap-highlight-color: transparent; }\n.highlight { position: absolute; inset: 1px 0; border-radius: 9px; background: var(--surface-muted); }\n.line { position: relative; display: flex; height: 30px; min-width: 0; align-items: center; gap: 6px; padding-right: 6px; padding-left: calc(4px + var(--level, 0) * var(--indent)); white-space: nowrap; }\n.row[data-kind=\"node\"] .line { cursor: pointer; }\n\n.chevron { display: inline-grid; width: 14px; height: 14px; flex: none; place-items: center; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }\n.chevron[data-open] { transform: rotate(90deg); }\n.row[data-active] .chevron[data-branch] { color: var(--foreground); }\n\n.key { flex: none; max-width: 45%; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; }\n.index { flex: none; color: var(--text-muted); font-variant-numeric: tabular-nums; }\n.colon { flex: none; margin-left: -5px; color: var(--text-muted); }\n.summary { display: inline-flex; min-width: 0; align-items: baseline; gap: 8px; overflow: hidden; }\n.preview { min-width: 0; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; }\n.countLabel { flex: none; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n\n/* Type colors lean on the semantic roles, so both themes stay legible. */\n.value { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n.value[data-type=\"string\"] { color: var(--success); }\n.value[data-type=\"number\"] { color: var(--accent); font-variant-numeric: tabular-nums; }\n.value[data-type=\"boolean\"] { color: var(--warning); }\n.value[data-type=\"null\"], .value[data-type=\"other\"] { color: var(--text-muted); }\n\n.mark { border-radius: 3px; background: color-mix(in oklab, var(--warning) 22%, transparent); color: inherit; box-shadow: 0 0 0 1px color-mix(in oklab, var(--warning) 22%, transparent); transition: background-color var(--duration-fast) var(--ease-standard); }\n.mark[data-current] { background: color-mix(in oklab, var(--warning) 48%, transparent); }\n\n.more { display: inline-flex; align-items: baseline; gap: 10px; padding-left: 20px; font-family: var(--font-body); font-size: var(--text-sm); }\n.moreLabel { color: var(--foreground); font-weight: 500; }\n.row[data-kind=\"more\"] { cursor: pointer; }\n.muted { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n\n/* Copy actions appear at the row's end over a soft fade, on hover or on the current row. */\n/* Inset like the highlight, with its right corners, so the fade never shows a square edge past the row's rounded end. */\n.actions { position: absolute; top: 1px; right: 0; bottom: 1px; display: flex; align-items: center; gap: 0; padding-right: 1px; padding-left: 24px; border-radius: 0 9px 9px 0; background: linear-gradient(to right, transparent, var(--surface) 22px); opacity: 0; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }\n.row[data-active] .actions { background: linear-gradient(to right, transparent, var(--surface-muted) 22px); }\n.action { position: relative; display: inline-grid; width: 26px; height: 26px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.action[data-state=\"done\"] { color: var(--success); }\n.action[data-state=\"error\"] { color: var(--danger); }\n.action:active { background: color-mix(in oklab, var(--foreground) 8%, transparent); }\n.actionGlyph { display: grid; place-items: center; }\n.row[data-active] .actions, .row:focus-within .actions { opacity: 1; pointer-events: auto; }\n\n/* The current path, always readable in full through its copy action. */\n.pathBar { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-top: 1px solid var(--border-subtle); }\n.path { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); font-family: var(--mono); font-size: var(--text-xs); letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }\n.pathType { flex: none; color: var(--text-muted); font-size: var(--text-xs); }\n.pathCopy { width: 30px; height: 30px; border-radius: 10px; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (hover: hover) and (pointer: fine) {\n  .row:hover .actions { opacity: 1; pointer-events: auto; }\n  /* Same inset and corner as the current-row highlight, so hover and selection share one shape. */\n  .row:not([data-active]):hover { --row-hover: color-mix(in oklab, var(--foreground) 3.5%, var(--surface)); }\n  .row:not([data-active]):hover::before { content: \"\"; position: absolute; inset: 1px 0; border-radius: 9px; background: var(--row-hover); pointer-events: none; }\n  .row:not([data-active]):hover .actions { background: linear-gradient(to right, transparent, var(--row-hover) 22px); }\n  .row:hover .chevron[data-branch] { color: var(--foreground); }\n  .tool:hover:not(:disabled), .action:hover { background: var(--surface-muted); color: var(--foreground); }\n  .search .tool:hover:not(:disabled) { background: var(--surface); }\n  .action[data-state=\"done\"]:hover { color: var(--success); }\n}\n\n@media (max-width: 420px) {\n  .tree { --indent: 12px; }\n  .key { max-width: 40%; }\n  .pathType { display: none; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .chevron, .actions, .tool, .action, .mark { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/json-viewer",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/json-viewer",
    "markdown": "https://ui.xzero.co.uk/components/json-viewer/markdown"
  }
}