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.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
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. That row is itself a
role="treeitem" in the roving tab stop (every child of role="tree" must be a
treeitem, and skipping it would leave everything past maxChildren
unreachable by keyboard); its button is tabIndex={-1}, the click target
only, never a stray Tab stop in the middle of the tree.
- 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 are built by one childPath(path, key, isIndex) helper: `[3]` for an
array index, `.name` for a key matching /^[A-Za-z_$][\w$]*$/, and
`["app.kubernetes.io/name"]` (JSON.stringify(key)) for any other key, e.g.
`root.items[3].name` / `root.labels["app.kubernetes.io/name"]`. The path is
the row's React key, its ref-map key, its expansion flag and the copy-path
payload, so plain `${path}.${key}` concatenation is a bug: `{"a.b": 1}` and
`{a: {b: 2}}` would share `root.a.b`, and the copied accessor wouldn't parse.
- 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, "Show N more" rows included; 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
(from a more-row: to its container); Enter/Space toggles expand-collapse
(or the truncated-string reveal on a leaf), and on a more-row reveals the
next page and moves focus to the first entry it revealed — that row only
mounts on the next commit, so park its path in a ref and focus it from an
effect. Enter/Space whose target is a button (a copy button) is left to the
button's own click, or the same key would also toggle the focused row.
- 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: `ƒ name()`,
`[Circular]`, `123n`, `Invalid Date`) 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. Every outcome is visible: a
rejected write or a missing Clipboard API flips the same icon to a
destructive CopyX for 2s — a silent no-op reads as a broken button — and one
persistent sr-only role="status" aria-live="polite" region at the root says
"Value copied" / "Path copied" / "Copy failed", since the icon swap alone
tells a screen reader nothing.
- 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 values are styled by one KIND_CLASS lookup built on text
tokens, with weight and italics carrying what hue can't: string
text-foreground/80, number/bigint text-foreground font-medium, boolean
text-primary font-medium, null/undefined text-muted-foreground italic,
date/symbol/function text-foreground italic; circular (and undefined) render
italic text-muted-foreground as anomaly/absence markers. NOT the chart
palette: a theme whose --chart-* ramp is monochrome draws string values at
~1.5:1 against the card in light mode and date/function/symbol at ~1.3:1 in
dark. Search highlight is bg-primary/25. No aria-selected on any treeitem:
the tree has no selection model, and aria-selected={false} on every row
makes some screen readers say "not selected" on each one. 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 styling: edit the KIND_CLASS record, or add more kinds to it — one
lookup table drives every leaf's colour and weight. Swapping in
var(--chart-N) colours is fine for a palette with real hues; measure each
against bg-card in both themes first.
- 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,root.labels["app.kubernetes.io/name"]for a key that isn't an identifier) 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 (ƒ name(),[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.