{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "title": "JSON Viewer",
  "description": "Renders a parsed JSON value as a collapsible, type-coloured tree you can read and navigate. Reach for it wherever a page has to show a payload the reader did not write: an API or REST response in an admin or debug panel, a webhook body, the payload attached to a log or audit entry, a GraphQL response, an LLM tool-call's arguments, a JSONB/JSON database column, a feature-flag or config blob, a job's input and output in a queue dashboard, or the raw record behind a row in an internal tool. Common asks it answers: \"json viewer\", \"json tree viewer\", \"json inspector\", \"object inspector\", \"display JSON in React\", \"render an API response\", \"pretty print JSON component\", \"collapsible JSON\", \"expandable JSON tree\", \"JSON formatter component\", \"view webhook payload\", \"debug panel for API responses\", \"react-json-view alternative\", \"shadcn JSON viewer\". shadcn/ui ships nothing for JSON — its accordion and collapsible are single open/closed sections that know nothing about types, counts or depth — so this is normally either a raw <pre>{JSON.stringify(data, null, 2)}</pre>, which is unreadable past a screenful and cannot be collapsed, or a third-party viewer pulled in for one panel. Pass the value itself — `data={await res.json()}` — and nothing else is required: objects and arrays open and close, strings, numbers, booleans and null are coloured by type, every container reports how many entries it holds, and `defaultExpandedDepth` sets how much is open on first paint (0 for just the root, 1 by default, Infinity for the whole document). Built for payloads that are bigger than the demo: a container draws `maxItemsPerNode` entries (100 by default) behind a keyboard-reachable “… 39,900 more” row, so a 40,000-element array costs a hundred rows rather than mounting all of them, long strings are elided inside their quotes with the full text kept on hover, and a value that points back at one of its own ancestors is drawn as [Circular] instead of unfolding forever. It is the real ARIA tree pattern rather than a stack of collapsibles: role=tree/treeitem with aria-expanded, aria-level, aria-posinset and aria-setsize, and a roving tabindex that makes the whole viewer one Tab stop instead of one per row. Up/Down walk the rows actually on screen, Right opens a container and then steps into it, Left closes it or jumps to the parent, Home/End hit the ends, Enter/Space toggle a row or ask a “… more” row for its next page. `onSelect` hands back the row's accessor path — `$.items[0].id`, quoted so that `{ \"a.b\": 1 }` and `{ a: { b: 1 } }` never produce the same string, and pasteable straight into code — together with the live value, which is the hook for a copy button or a “filter to this” action; pair it with copy-button for copy-on-click. Open state is stored as the difference from `defaultExpandedDepth` rather than as a set of open paths, so swapping in the next response leaves the view opened to the same depth instead of collapsing to one unreadable root row. Counts are grouped without toLocaleString, whose locale-dependent output shows up as a hydration mismatch in Next.js. Styled with shadcn tokens (foreground, muted-foreground, accent, ring) plus dark-aware type colours so it follows light and dark themes; lucide-react is the only dependency, with no Radix, no state library and no JSON parser of its own. Distinct from tree-view, which renders a hierarchy you have already built into `{ id, label, children }` nodes: this one takes the raw parsed value and needs no node-building step, and it renders keys, types and counts rather than labels. Distinct from code-block, which shows JSON as static text to copy rather than a structure to open and walk.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/json-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * What a value is drawn as. The four JSON scalars and the two containers, plus the\n * JS-only values that `JSON.parse` output never holds but a live object from your own\n * app does — they are listed so that passing such an object shows `undefined` or a\n * function marker rather than silently drawing it as an empty `{}`. `circular` marks a\n * reference back to an ancestor, the one shape that would otherwise never stop\n * unfolding.\n */\ntype JsonKind =\n  | \"object\"\n  | \"array\"\n  | \"string\"\n  | \"number\"\n  | \"boolean\"\n  | \"null\"\n  | \"undefined\"\n  | \"bigint\"\n  | \"function\"\n  | \"symbol\"\n  | \"circular\"\n\n/**\n * One line on screen. `value` rows are the data; a `more` row is the \"… 900 more\"\n * footer standing in for the entries held back by `maxItemsPerNode`, and is a row of its\n * own so the keyboard can reach it and ask for the next page.\n */\ntype JsonRow =\n  | {\n      type: \"value\"\n      /** Accessor path from the root, e.g. `$.items[0].id`. Unique — it keys the row. */\n      path: string\n      /** Object key or array index this row sits under; null on the root. */\n      key: string | number | null\n      kind: JsonKind\n      value: unknown\n      depth: number\n      expandable: boolean\n      expanded: boolean\n      /** Entry count for a container; 0 for scalars. */\n      size: number\n      pos: number\n      setsize: number\n    }\n  | {\n      type: \"more\"\n      path: string\n      /** The container these hidden entries belong to. */\n      parentPath: string\n      depth: number\n      hidden: number\n      pos: number\n      setsize: number\n    }\n\ninterface JsonViewerProps {\n  /** Anything `JSON.parse` can return. Parse the response body yourself and pass the value. */\n  data: unknown\n  /**\n   * How deep the tree is open on first paint. 0 hides everything under the root row,\n   * 1 (the default) shows the root's own entries, `Infinity` opens the whole document.\n   */\n  defaultExpandedDepth?: number\n  /** Name drawn on the root row, e.g. \"response\". Omitted, the root shows only its type. */\n  rootLabel?: string\n  /**\n   * Entries drawn per container before the rest are held behind a \"… N more\" row.\n   * Guards against a 50,000-element array trying to mount 50,000 rows.\n   */\n  maxItemsPerNode?: number\n  /** Strings longer than this are elided on screen; the full text stays in the title. */\n  maxStringLength?: number\n  /** Fires on click or Enter/Space with the row's accessor path and its live value. */\n  onSelect?: (entry: { path: string; value: unknown }) => void\n  /** Indent per level, in pixels. */\n  indent?: number\n  className?: string\n  /** Accessible name for the tree (or wire `aria-labelledby` to a visible heading). */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\n/** Keys that can be written as `.key`; everything else has to go in brackets. */\nconst IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/\n\n/**\n * Extend a path by one step, producing something you can paste into code.\n *\n * The quoting is not cosmetic: it is what keeps two different documents from landing on\n * the same string. `{ \"a\": { \"b\": 1 } }` yields `$.a.b` while `{ \"a.b\": 1 }` yields\n * `$[\"a.b\"]`, so a path never names two rows and toggling one can never open the other.\n * Joining with dots and no quoting collapses those two cases into a single key.\n */\nfunction jsonPath(parent: string, key: string | number): string {\n  if (typeof key === \"number\") return `${parent}[${key}]`\n  return IDENTIFIER.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`\n}\n\nfunction describeKind(value: unknown): JsonKind {\n  if (value === null) return \"null\"\n  if (Array.isArray(value)) return \"array\"\n  switch (typeof value) {\n    case \"string\":\n      return \"string\"\n    case \"number\":\n      return \"number\"\n    case \"boolean\":\n      return \"boolean\"\n    case \"undefined\":\n      return \"undefined\"\n    case \"bigint\":\n      return \"bigint\"\n    case \"function\":\n      return \"function\"\n    case \"symbol\":\n      return \"symbol\"\n    default:\n      return \"object\"\n  }\n}\n\n/**\n * The children of a container, in document order.\n *\n * Arrays are walked by index rather than with `map`, so that a sparse array — which\n * `JSON.parse` cannot produce but a live array can — yields `undefined` for its holes\n * instead of dropping them and misnumbering every entry after.\n */\nfunction entriesOf(value: unknown, kind: JsonKind): Array<[string | number, unknown]> {\n  if (kind === \"array\") {\n    const arr = value as unknown[]\n    const out: Array<[string | number, unknown]> = []\n    for (let i = 0; i < arr.length; i++) out.push([i, arr[i]])\n    return out\n  }\n  if (kind === \"object\") return Object.entries(value as Record<string, unknown>)\n  return []\n}\n\n/**\n * Walk the document into the flat list of rows currently on screen.\n *\n * Only expanded containers are descended into, so the cost tracks what is actually\n * visible rather than the size of the document. Cycles are caught with a set of the\n * containers on the current path: a value pointing back at one of its own ancestors is\n * drawn as `[Circular]` and not followed, which is what stops a `defaultExpandedDepth`\n * of `Infinity` from running forever on an object that references itself.\n */\nfunction flattenJson(options: {\n  data: unknown\n  isExpanded: (path: string, depth: number) => boolean\n  limitFor: (path: string) => number\n}): JsonRow[] {\n  const { data, isExpanded, limitFor } = options\n  const rows: JsonRow[] = []\n  const ancestors = new Set<object>()\n\n  const walk = (\n    key: string | number | null,\n    value: unknown,\n    path: string,\n    depth: number,\n    pos: number,\n    setsize: number\n  ) => {\n    const container = typeof value === \"object\" && value !== null\n    const kind: JsonKind = container && ancestors.has(value) ? \"circular\" : describeKind(value)\n    const entries = kind === \"circular\" ? [] : entriesOf(value, kind)\n    // An empty object or array is a leaf: there is nothing behind the arrow, so it reads\n    // as `{}` on one line instead of offering a disclosure that reveals nothing.\n    const expandable = entries.length > 0\n    const expanded = expandable && isExpanded(path, depth)\n\n    rows.push({\n      type: \"value\",\n      path,\n      key,\n      kind,\n      value,\n      depth,\n      expandable,\n      expanded,\n      size: entries.length,\n      pos,\n      setsize,\n    })\n    if (!expanded) return\n\n    ancestors.add(value as object)\n    const shown = Math.min(entries.length, limitFor(path))\n    const hidden = entries.length - shown\n    const childCount = shown + (hidden > 0 ? 1 : 0)\n    for (let i = 0; i < shown; i++) {\n      const [childKey, childValue] = entries[i]\n      walk(childKey, childValue, jsonPath(path, childKey), depth + 1, i + 1, childCount)\n    }\n    if (hidden > 0) {\n      rows.push({\n        type: \"more\",\n        // Every path `jsonPath` builds ends in an identifier or a bracket, and a key\n        // holding a space is bracket-quoted, so no data row can ever be named this.\n        path: `${path} more`,\n        parentPath: path,\n        depth: depth + 1,\n        hidden,\n        pos: childCount,\n        setsize: childCount,\n      })\n    }\n    ancestors.delete(value as object)\n  }\n\n  walk(null, data, \"$\", 0, 1, 1)\n  return rows\n}\n\n/**\n * Group thousands without `toLocaleString`, whose output follows the runtime's locale.\n * On a server-rendered page that means a count can be formatted one way in Node and\n * another in the browser, which React reports as a hydration mismatch.\n */\nfunction groupDigits(value: number): string {\n  return String(value).replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\")\n}\n\nfunction countLabel(kind: JsonKind, size: number): string {\n  const noun = kind === \"array\" ? \"item\" : \"key\"\n  return `${groupDigits(size)} ${noun}${size === 1 ? \"\" : \"s\"}`\n}\n\n/** How a scalar is written on its row. Containers are drawn by the component itself. */\nfunction formatScalar(value: unknown, kind: JsonKind, maxStringLength: number): string {\n  switch (kind) {\n    case \"string\": {\n      const text = value as string\n      // Quote through JSON.stringify so a newline or a quote inside the string is escaped\n      // rather than breaking the row apart. When the string is too long the ellipsis goes\n      // inside the closing quote, so what is on screen still reads as a string.\n      if (text.length <= maxStringLength) return JSON.stringify(text)\n      return `${JSON.stringify(text.slice(0, maxStringLength)).slice(0, -1)}…\"`\n    }\n    case \"number\":\n      // String(-0) is \"0\", which quietly turns one JSON number into a different one.\n      return Object.is(value, -0) ? \"-0\" : String(value)\n    case \"bigint\":\n      return `${String(value)}n`\n    case \"boolean\":\n      return String(value)\n    case \"null\":\n      return \"null\"\n    case \"undefined\":\n      return \"undefined\"\n    case \"function\":\n      return \"function\"\n    case \"symbol\":\n      return String(value)\n    case \"circular\":\n      return \"[Circular]\"\n    default:\n      return \"\"\n  }\n}\n\nconst SCALAR_TONE: Partial<Record<JsonKind, string>> = {\n  string: \"text-emerald-600 dark:text-emerald-400\",\n  number: \"text-blue-600 dark:text-blue-400\",\n  bigint: \"text-blue-600 dark:text-blue-400\",\n  boolean: \"text-violet-600 dark:text-violet-400\",\n}\n\n/**\n * A JSON value you can actually read: collapsible, typed and colour-coded, with big\n * collections paged instead of dumped.\n *\n * Hand it whatever `JSON.parse` gave you — an API response, a log line's payload, a\n * config file, a webhook body — and it renders the whole document. Objects and arrays\n * open and close, scalars are coloured by type, every container says how many entries it\n * holds, and an array of 40,000 elements draws the first hundred behind a \"… 39,900\n * more\" row rather than trying to mount all of them.\n *\n * It is the ARIA tree pattern, so the whole viewer is a single Tab stop: Up/Down walk\n * the rows actually on screen, Right opens a container and then steps into it, Left\n * closes it or jumps out to the parent, Home/End hit the ends, and Enter/Space toggle a\n * row (or ask a \"… N more\" row for its next page). `onSelect` hands back the row's\n * accessor path — `$.items[0].id`, ready to paste into code — together with its live\n * value, which is the hook for a copy button or a \"filter to this\" action.\n *\n * Open state is held as the difference from `defaultExpandedDepth` rather than as a set\n * of open paths, so replacing `data` with the next response leaves the view opened to\n * the same depth instead of collapsing to a single unreadable root row.\n */\nexport function JsonViewer({\n  data,\n  defaultExpandedDepth = 1,\n  rootLabel,\n  maxItemsPerNode = 100,\n  maxStringLength = 120,\n  onSelect,\n  indent = 14,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: JsonViewerProps) {\n  // Paths whose open state differs from what `defaultExpandedDepth` would give them.\n  // Storing the deviation rather than the open set is what lets a new `data` prop keep\n  // the reader's depth: a set of literal paths would no longer match anything in it.\n  const [toggled, setToggled] = React.useState<Set<string>>(() => new Set())\n  // Containers the reader has asked to see more of, and how many entries to draw.\n  const [revealed, setRevealed] = React.useState<Map<string, number>>(() => new Map())\n  const [focusPath, setFocusPath] = React.useState<string | null>(null)\n\n  const rowRefs = React.useRef(new Map<string, HTMLLIElement>())\n\n  const rows = React.useMemo(\n    () =>\n      flattenJson({\n        data,\n        isExpanded: (path, depth) => (depth < defaultExpandedDepth) !== toggled.has(path),\n        limitFor: (path) => revealed.get(path) ?? maxItemsPerNode,\n      }),\n    [data, defaultExpandedDepth, toggled, revealed, maxItemsPerNode]\n  )\n\n  // The one row carrying tabIndex={0}. Falling back to the first row keeps the tree\n  // reachable by Tab before anything has been focused, and re-resolves when the row that\n  // had focus was collapsed out of existence.\n  const activePath = React.useMemo(() => {\n    if (focusPath && rows.some((r) => r.path === focusPath)) return focusPath\n    return rows[0]?.path ?? null\n  }, [rows, focusPath])\n\n  function focusRow(path: string) {\n    setFocusPath(path)\n    rowRefs.current.get(path)?.focus()\n  }\n\n  function setExpanded(index: number, next: boolean) {\n    const row = rows[index]\n    if (row.type !== \"value\" || !row.expandable || row.expanded === next) return\n\n    if (!next) {\n      // Closing a container unmounts its rows. If focus is inside, take it back to the\n      // row being closed, or it lands on <body> and drops the reader out of the tree.\n      // Descendants are found by position rather than by path prefix, because `$.a` is a\n      // prefix of the unrelated `$.ab`.\n      for (let i = index + 1; i < rows.length && rows[i].depth > row.depth; i++) {\n        if (rows[i].path === activePath) {\n          focusRow(row.path)\n          break\n        }\n      }\n    }\n\n    const base = row.depth < defaultExpandedDepth\n    setToggled((prev) => {\n      const nextSet = new Set(prev)\n      if (next === base) nextSet.delete(row.path)\n      else nextSet.add(row.path)\n      return nextSet\n    })\n  }\n\n  function revealMore(parentPath: string) {\n    setRevealed((prev) => {\n      const next = new Map(prev)\n      next.set(parentPath, (prev.get(parentPath) ?? maxItemsPerNode) + maxItemsPerNode)\n      return next\n    })\n  }\n\n  /** What a click, or Enter/Space, does to a row. */\n  function activate(index: number) {\n    const row = rows[index]\n    focusRow(row.path)\n    if (row.type === \"more\") {\n      revealMore(row.parentPath)\n      return\n    }\n    if (row.expandable) setExpanded(index, !row.expanded)\n    onSelect?.({ path: row.path, value: row.value })\n  }\n\n  function moveTo(index: number) {\n    const row = rows[Math.max(0, Math.min(index, rows.length - 1))]\n    if (row) focusRow(row.path)\n  }\n\n  function moveToParent(index: number) {\n    const depth = rows[index].depth\n    for (let i = index - 1; i >= 0; i--) {\n      if (rows[i].depth < depth) {\n        focusRow(rows[i].path)\n        return\n      }\n    }\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {\n    const index = rows.findIndex((r) => r.path === activePath)\n    if (index < 0) return\n    const row = rows[index]\n    const open = row.type === \"value\" && row.expandable && row.expanded\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        moveTo(index + 1)\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        moveTo(index - 1)\n        break\n      case \"Home\":\n        event.preventDefault()\n        moveTo(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        moveTo(rows.length - 1)\n        break\n      case \"ArrowRight\":\n        event.preventDefault()\n        // Right opens a closed container and, on one already open, steps into it.\n        if (row.type === \"value\" && row.expandable && !row.expanded) setExpanded(index, true)\n        else moveTo(index + 1)\n        break\n      case \"ArrowLeft\":\n        event.preventDefault()\n        if (open) setExpanded(index, false)\n        else moveToParent(index)\n        break\n      case \"Enter\":\n      case \" \":\n        event.preventDefault()\n        activate(index)\n        break\n      default:\n    }\n  }\n\n  return (\n    <ul\n      role=\"tree\"\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      onKeyDown={handleKeyDown}\n      className={cn(\"font-mono text-sm leading-relaxed\", className)}\n    >\n      {rows.map((row, index) => (\n        <li\n          key={row.path}\n          ref={(el) => {\n            if (el) rowRefs.current.set(row.path, el)\n            else rowRefs.current.delete(row.path)\n          }}\n          role=\"treeitem\"\n          aria-expanded={row.type === \"value\" && row.expandable ? row.expanded : undefined}\n          aria-level={row.depth + 1}\n          aria-posinset={row.pos}\n          aria-setsize={row.setsize}\n          tabIndex={activePath === row.path ? 0 : -1}\n          onClick={() => activate(index)}\n          style={{ paddingLeft: row.depth * indent + 4 }}\n          className={cn(\n            \"flex cursor-pointer select-none items-center gap-1 rounded-sm py-0.5 pr-2\",\n            \"hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n          )}\n        >\n          {row.type === \"value\" && row.expandable ? (\n            <ChevronRight\n              className={cn(\n                \"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform\",\n                row.expanded && \"rotate-90\"\n              )}\n              aria-hidden=\"true\"\n            />\n          ) : (\n            <span className=\"h-3.5 w-3.5 shrink-0\" aria-hidden=\"true\" />\n          )}\n\n          {row.type === \"more\" ? (\n            <span className=\"text-muted-foreground underline decoration-dotted underline-offset-2\">\n              … {groupDigits(row.hidden)} more\n            </span>\n          ) : (\n            <>\n              {(row.key !== null || rootLabel !== undefined) && (\n                <>\n                  <span className=\"text-foreground\">\n                    {row.key === null ? rootLabel : String(row.key)}\n                  </span>\n                  <span className=\"text-muted-foreground\">:</span>\n                </>\n              )}\n\n              {row.kind === \"object\" || row.kind === \"array\" ? (\n                <>\n                  <span className=\"text-muted-foreground\">\n                    {row.size === 0\n                      ? row.kind === \"array\"\n                        ? \"[]\"\n                        : \"{}\"\n                      : row.expanded\n                        ? row.kind === \"array\"\n                          ? \"[\"\n                          : \"{\"\n                        : row.kind === \"array\"\n                          ? \"[ … ]\"\n                          : \"{ … }\"}\n                  </span>\n                  {row.size > 0 && (\n                    <span className=\"ml-1 text-xs text-muted-foreground/70\">\n                      {countLabel(row.kind, row.size)}\n                    </span>\n                  )}\n                </>\n              ) : (\n                <span\n                  // The full string stays reachable on hover once the row elides it.\n                  title={\n                    row.kind === \"string\" && (row.value as string).length > maxStringLength\n                      ? (row.value as string)\n                      : undefined\n                  }\n                  className={cn(\n                    // min-w-0 is what lets `truncate` work here: a flex child defaults to\n                    // min-width:auto and refuses to shrink, so without it a long value\n                    // pushes the row wider instead of ending in an ellipsis.\n                    \"min-w-0 truncate\",\n                    SCALAR_TONE[row.kind] ?? \"text-muted-foreground\",\n                    (row.kind === \"circular\" || row.kind === \"function\") && \"italic\"\n                  )}\n                >\n                  {formatScalar(row.value, row.kind, maxStringLength)}\n                </span>\n              )}\n            </>\n          )}\n        </li>\n      ))}\n    </ul>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}