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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/json-viewer.jsonPrompt
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-levelper 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.
Code Block
A self-contained, copyable code panel with its own regex syntax highlighter — line numbers, line highlighting, tabs and a wrap toggle, zero external highlighting engine.
Page Header
The app-shell page header — breadcrumb slot, icon, title, status meta, actions and an optional controlled tab strip, all responsive to a narrow container.