Display

JSON Viewer

A collapsible, searchable JSON tree viewer with type-colored values, per-node copy value/path, and safe rendering of circular refs and every JS edge-case value.

  • Every copy outcome is visible: a rejected write or a missing Clipboard API flips the same icon to a destructive CopyX for 2s, exactly like the success check. A silent no-op reads as a broken button.
  • The "Show N more" row is itself a role="treeitem" with tabIndex=-1 (and its button likewise): every child of role="tree" must be a treeitem, and the tree owns a single roving tab stop, so a plain focusable button there would be a stray Tab stop in the middle of the tree.
Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronRight, Copy, CopyX, Link2 } from "lucide-react"
import { cn } from "@/lib/utils"

export interface JsonViewerProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Arbitrary JSON-shaped data. Anything not JSON-serializable (Date, function,
   * Symbol, BigInt, circular refs, NaN/Infinity, -0) is rendered instead of thrown. */
  data: unknown
  /** Label for the synthetic top-level node; also the prefix for every copied path. */
  rootLabel?: string
  /** How many ancestor levels start expanded (0 = only the collapsed root row). */
  defaultExpandedDepth?: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/json-viewer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "JsonViewer" component. No new npm
dependencies — plain React state + lucide-react icons.

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: data: unknown (required), rootLabel = "root", defaultExpandedDepth = 1,
  maxStringLength = 120, maxChildren = 100, showTypes = false, search = false,
  onCopyPath?: (path: string) => void.

Behavior
- Classify every value (never `any`, narrow `unknown`) into one of: object,
  array, string, number, boolean, null, undefined, function, symbol, bigint,
  date, circular. Circular detection uses a single WeakSet<object> mutated as
  a DFS ancestor stack (add before recursing into a container's children,
  delete right after) — any container value already in the set renders
  "[Circular]" instead of recursing, so cycles can never stack-overflow.
- Render a flat list of rows (not nested DOM) with aria-level={depth+1} —
  this is what keeps huge/deep trees cheap and keyboard nav a single flat
  array instead of a recursive tree walk.
- Containers (object/array): collapsed shows `{…} N keys` / `[…] N items`
  (or bare `{}`/`[]` when empty, no ellipsis); expanded shows just the
  opening bracket, children render as indented rows below. Only render up to
  maxChildren entries per container; if there are more, append a synthetic
  "Show N more" row that on click bumps that container's visible count by
  another maxChildren (state: Map<path, count>) — never render more than
  maxChildren rows at once for a huge array.
- defaultExpandedDepth: on mount (and whenever the `data` or `rootLabel`
  prop's identity changes — reset via the adjust-state-during-render
  pattern, comparing a prev-value state, not an effect), precompute the set
  of container paths where depth < defaultExpandedDepth and seed that as
  the expanded-paths Set<string>. Toggling a node later flips membership in
  that same Set; effective expanded = in the Set OR (search active AND this
  path is on a search hit's ancestor chain).
- Paths: dot for object keys, bracket for array indices, e.g.
  `root.items[3].name`. Every row knows its full path.
- Strings longer than maxStringLength render truncated (`"prefix…"`,
  properly re-escaped via JSON.stringify on the sliced prefix) with a
  click-to-expand toggle per node path (Set<string> of expanded-string
  paths) — clicking the value, or pressing Enter/Space while it's focused,
  toggles full/truncated.
- Keyboard, roving tabindex over the flat row list (role="tree" wraps
  role="treeitem" rows, real focus via a path->element ref Map, not
  aria-activedescendant): ArrowDown/Up move focus to the next/previous
  visible row; ArrowRight expands a collapsed container or moves focus into
  its first child if already expanded; ArrowLeft collapses an expanded
  container or moves focus to the parent; Enter/Space toggles
  expand-collapse (or the truncated-string reveal on a leaf).
- Every row exposes two icon buttons (opacity-0, revealed on row
  hover/focus-within) — "copy value" (JSON.stringify-like serialization of
  that node's subtree, handling function/symbol/bigint/Date/circular with
  readable non-JSON markers instead of throwing) and "copy path" (writes
  the dot/bracket path and also calls onCopyPath(path)). Both write to
  navigator.clipboard and show an inline checkmark confirmation for 2s via
  a single timeout ref cleared on unmount.
- search (when true): render a controlled search input above the tree. On
  every keystroke, scan the full `data` (bounded by a fixed node budget so
  a huge tree can't hang the tab) for keys/values containing the query
  (case-insensitive); matching containers' ancestor chain force-expands
  (independent of the manual expanded-paths Set, OR'd in at render time)
  and, if the match is inside a container beyond its current maxChildren
  window, that container's effective visible count is floored to include
  the matched index. Matching substrings in keys/values render wrapped in
  a highlighted <mark>.

Rendering & styling
- Semantic tokens only: text-foreground for keys, text-muted-foreground for
  punctuation (colons/brackets/ellipsis) and collapsed-preview counts,
  bg-muted/60 row hover, ring-ring focus-visible ring on the focused
  treeitem. Leaf value colors are the four spec'd families on var(--chart-1
  ..5): string chart-1, number/bigint chart-2, boolean chart-3, null/
  undefined chart-4, plus date/symbol/function on chart-5 as a fifth
  "exotic type" bucket; circular and undefined render italic
  text-muted-foreground instead of a chart color (they're anomaly/absence
  markers, not normal data). Search highlight is bg-primary/25. Merge
  className via cn(); the tree scrolls horizontally (overflow-x-auto,
  whitespace-nowrap rows) rather than wrapping or ellipsis-clipping a row,
  since this is inspector/code-like content.
- Chevron rotates 90° on expand via a CSS transition, motion-reduce:
  transition-none — collapse/expand itself (a state flip, not an animation)
  keeps working identically either way.

Customization levers
- Leaf color mapping: swap the KIND_COLOR record's var(--chart-N) values,
  or add more kinds to it — one lookup table drives every leaf's color.
- Row density: change the depth*16px indent step or the row's px/py to make
  the tree denser or roomier; font-mono can be dropped for a proportional
  font if you don't want a "code" look.
- maxChildren / maxStringLength: purely thresholds — raise them for a
  "show more up front" feel, lower them to keep huge payloads snappier.
- Drop the copy buttons entirely (or make them always-visible instead of
  hover-revealed) by removing the two CopyIconButton renders in
  JsonRowView — the rest of the tree (expand/collapse, search, keyboard
  nav) doesn't depend on them.
- Selection/click-to-select on leaves: the component intentionally has no
  selection model (only focus + expand state) — add an onSelect callback
  and an aria-selected-driven highlight class the same way FileTree does,
  if a consuming app needs "click a value to insert it" behavior.

Concepts

  • Ancestor WeakSet (cycle guard) — a single WeakSet<object> is threaded through recursion as a DFS ancestor stack (add on the way down, delete on the way back up); any container already in the set is a cycle and renders [Circular] instead of recursing, so no depth of circular reference can overflow the stack.
  • Flat rows, not nested DOM — the whole visible tree is built once into a flat array with aria-level per row, not a recursive component tree; this is what makes keyboard roving-tabindex a simple array walk and keeps huge trees cheap to diff.
  • Truncate-then-reveal — long strings and long containers use the same shape: show a bounded prefix plus a control (click-to-expand / "Show N more") that reveals more on demand, so nothing pathological ever renders in one pass.
  • Search auto-expand — a query scans the full data independently of what's currently expanded; any container on a match's ancestor chain is OR'd into the expanded state at render time (never mutates the user's manual expand/collapse choices), and truncated containers get their visible-count floor bumped so the match is actually on screen, not just "logically expanded."
  • Copy path vs copy value — path is the dot/bracket address (root.items[3].name) useful for referencing a field in code or a bug report; value is a JSON-like serialization of that node's subtree with honest non-JSON markers ([Function: x], [Circular], 123n) for the types JSON itself can't represent.

On This Page