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
"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. 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-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, 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.

On This Page