JSON viewer
A collapsible view of any JSON value, coloured by type and copyable whole.
A client component: branches open and shut. Values are coloured by JSON type — strings success, numbers info, booleans warning, null muted and italic — and keys stay in the foreground, so the shape reads before the content does. A shut branch says how big it is, "{3 keys}" or "[5 items]", and its toggle is a real button carrying aria-expanded and a name that says which branch it opens. Only the branches a reader has actually clicked are remembered; everything else answers from defaultExpanded, so a new payload opens the way the first one did. defaultExpanded takes a number as a depth: 1 opens the root and nothing below it. A value already on its own ancestor path renders as [Circular] rather than recursing, in the tree and in the copied text alike, so a node carrying a back-reference cannot blow the stack during render; the same object under two sibling keys is not a cycle and stays expandable. Nothing caps how much it renders, so reach for defaultExpanded as a depth rather than true when a payload holds big arrays.
Install
npx shadcn@latest add @vibra/json-viewerNeeds the @vibra registry in your components.json — set it up once.
Examples
import { JsonViewer } from "@/components/ui/json-viewer"
const deployment = {
id: "dpl_9f21c4a7",
project: "vibra-web",
state: "READY",
target: "production",
createdAt: "2026-09-04T14:22:08.412Z",
buildMs: 108_420,
creator: { username: "olivia.martin", email: "olivia.martin@vibra.example" },
source: {
provider: "git",
repo: "vibra/web",
branch: "main",
commit: "4c1a7f2",
message: "fix(checkout): guard against an empty cart on Safari",
},
regions: ["iad1", "fra1", "hnd1"],
env: { NODE_ENV: "production", NEXT_PUBLIC_API: "https://api.vibra.example", DEBUG: false },
checks: [
{ name: "lint", status: "passed", ms: 6_140 },
{ name: "types", status: "passed", ms: 21_880 },
{ name: "unit", status: "passed", ms: 44_310 },
],
alias: null,
}
export default function JsonViewerDemo() {
return <JsonViewer className="w-full" data={deployment} rootName="deployment" copyable maxHeight={320} />
}One level open
A webhook body opened to depth 1, so the shape reads before the detail.
import { JsonViewer } from "@/components/ui/json-viewer"
const webhook = {
event: "invoice.payment_failed",
id: "evt_1P4kZ2Hs9",
livemode: true,
created: 1_788_567_128,
data: {
object: {
id: "in_1P4kZ0Hs9",
customer: "cus_QhT21f",
amount_due: 24_900,
currency: "usd",
attempt_count: 2,
next_payment_attempt: 1_788_653_528,
last_error: {
code: "card_declined",
decline_code: "insufficient_funds",
message: "Your card has insufficient funds.",
},
lines: [
{ description: "Team plan — 10 seats", amount: 19_900 },
{ description: "Overage — 4,120 runs", amount: 5_000 },
],
},
},
request: { id: "req_8mQ2xB", idempotency_key: null },
}
export default function JsonViewerCollapsed() {
return (
<div className="flex w-full flex-col gap-2">
<p className="text-sm text-muted-foreground">
One level open: the shape first, the detail on demand.
</p>
<JsonViewer className="w-full" data={webhook} defaultExpanded={1} copyable />
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | unknown | — | Any value — objects, arrays, and primitives all render. |
| defaultExpanded | boolean | number | true | true opens everything, false nothing, a number opens that many levels. |
| rootName | string | — | A name for the top-level value, e.g. "deployment". |
| copyable | boolean | false | Puts a copy button in the top-right corner that writes the whole payload. |
| maxHeight | number | — | Pixels; the tree scrolls past it. |
| className | string | — | Merged onto the div root; the remaining div props are spread onto it too. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { CopyButton } from "@/components/ui/copy-button"
type Branch = { kind: "object" | "array"; entries: [string, unknown][] }
const BRACKETS = { object: ["{", "}"], array: ["[", "]"] } as const
// Indentation per level, and the width of the toggle a row without children
// has to make up for, so leaves line up with their siblings' labels.
const INDENT_PX = 12
const TOGGLE_PX = 16
// What a value that points back up its own branch renders as, in the tree and
// in the copied text alike.
const CIRCULAR = "[Circular]"
/** Reads a value as a branch — an object or an array with its entries — or null for a leaf. */
function asBranch(value: unknown): Branch | null {
if (Array.isArray(value)) {
return { kind: "array", entries: value.map((item, index) => [String(index), item]) }
}
if (typeof value === "object" && value !== null) {
return { kind: "object", entries: Object.entries(value as Record<string, unknown>) }
}
return null
}
function summarise({ kind, entries }: Branch): string {
const [open, close] = BRACKETS[kind]
const noun = kind === "array" ? "item" : "key"
return `${open}${entries.length} ${noun}${entries.length === 1 ? "" : "s"}${close}`
}
/**
* The text and class a leaf renders as — strings quoted, numbers tabular, null
* muted and italic. The paints are status tokens, not chart tokens: the chart
* palette is tuned for 3:1 mark fills, and as 12px text it left strings at
* 2.88:1 on the light surface.
*/
function leafPaint(value: unknown): { text: string; className: string } {
if (typeof value === "string") return { text: JSON.stringify(value), className: "text-success" }
if (typeof value === "number" || typeof value === "bigint") {
return { text: String(value), className: "text-info tabular-nums" }
}
if (typeof value === "boolean") return { text: String(value), className: "text-warning" }
if (value === null) return { text: "null", className: "text-muted-foreground italic" }
if (value === undefined) return { text: "undefined", className: "text-muted-foreground italic" }
// Functions and symbols never survive JSON, but they do reach this component.
return { text: String(value), className: "text-muted-foreground italic" }
}
// A cycle would make JSON.stringify throw where the copy button expects a
// string. The replacer tracks the ancestor path rather than every object it has
// ever seen — `this` is the holder of the current value, so unwinding to it
// leaves exactly the chain above `value` — which cuts a real cycle and still
// writes a shared reference out twice, the same rule the tree renders by.
function safeStringify(data: unknown): string {
const ancestors: object[] = []
return JSON.stringify(
data,
function (this: unknown, _key: string, value: unknown) {
if (typeof value !== "object" || value === null) return value
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
if (ancestors.includes(value)) return CIRCULAR
ancestors.push(value)
return value
},
2
)
}
function JsonKey({ name }: { name: string | undefined }) {
if (name === undefined) return null
return (
<>
<span className="text-foreground">{name}</span>
<span className="text-muted-foreground">: </span>
</>
)
}
type NodeProps = {
name?: string
value: unknown
depth: number
path: string
/** Every container from the root down to this node's parent. */
ancestors: readonly object[]
isOpen: (path: string, depth: number) => boolean
toggle: (path: string, open: boolean) => void
}
function JsonNode({ name, value, depth, path, ancestors, isOpen, toggle }: NodeProps) {
const branch = asBranch(value)
const rowIndent = { paddingInlineStart: `${depth * INDENT_PX}px` }
const leafIndent = { paddingInlineStart: `${depth * INDENT_PX + TOGGLE_PX}px` }
// A value already on its own ancestor path would recurse until the stack
// blows, and `data` is not promised to have come from JSON.parse — a node
// with a `parent`, a DOM element, anything with a back-reference reaches
// here. The same object under two sibling keys is not a cycle and stays
// expandable, because only the chain above this node is checked.
if (branch !== null && ancestors.includes(value as object)) {
return (
<div
data-slot="json-viewer-node"
data-circular="true"
className="flex"
style={leafIndent}
>
<JsonKey name={name} />
<span className="text-muted-foreground italic">{CIRCULAR}</span>
</div>
)
}
// A leaf, or a branch with nothing in it: no toggle, nothing to collapse.
if (branch === null || branch.entries.length === 0) {
const paint =
branch === null
? leafPaint(value)
: { text: BRACKETS[branch.kind].join(""), className: "text-muted-foreground" }
return (
<div data-slot="json-viewer-node" className="flex" style={leafIndent}>
<JsonKey name={name} />
<span className={cn("break-all", paint.className)}>{paint.text}</span>
</div>
)
}
const open = isOpen(path, depth)
const [openBracket, closeBracket] = BRACKETS[branch.kind]
return (
<div data-slot="json-viewer-node" data-expanded={open || undefined}>
<div className="flex items-start" style={rowIndent}>
<button
type="button"
data-slot="json-viewer-toggle"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name ?? "root"}`}
onClick={() => toggle(path, open)}
className="mt-px flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-ring"
>
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-3 transition-transform duration-(--duration-fast) ease-(--ease-standard) rtl:-scale-x-100",
open && "rotate-90 rtl:-rotate-90"
)}
/>
</button>
<JsonKey name={name} />
<span className="text-muted-foreground">{open ? openBracket : summarise(branch)}</span>
</div>
{open ? (
<>
{branch.entries.map(([key, child]) => (
<JsonNode
key={key}
name={key}
value={child}
depth={depth + 1}
// Length-prefixed, not dot-joined: a key may itself contain the
// separator, and two nodes must never share one open/shut state.
path={`${path}/${key.length}:${key}`}
ancestors={[...ancestors, value as object]}
isOpen={isOpen}
toggle={toggle}
/>
))}
<div className="flex text-muted-foreground" style={leafIndent}>
{closeBracket}
</div>
</>
) : null}
</div>
)
}
const EMPTY_ANCESTORS: readonly object[] = []
export type JsonViewerProps = React.ComponentProps<"div"> & {
data: unknown
/** true opens everything, false nothing, a number opens that many levels. */
defaultExpanded?: boolean | number
/** A name for the top-level value, e.g. "payload". */
rootName?: string
copyable?: boolean
/** Pixels; the tree scrolls past it. */
maxHeight?: number
}
/** A collapsible view of any JSON value, coloured by type and copyable whole. */
function JsonViewer({
className,
data,
defaultExpanded = true,
rootName,
copyable = false,
maxHeight,
...props
}: JsonViewerProps) {
// Only the nodes a reader has actually clicked are remembered; every other
// node answers from `defaultExpanded`, so a new payload opens the same way
// the first one did.
const [overrides, setOverrides] = React.useState<Record<string, boolean>>({})
const isOpen = React.useCallback(
(path: string, depth: number) => {
if (Object.hasOwn(overrides, path)) return overrides[path]
if (typeof defaultExpanded === "number") return depth < defaultExpanded
return defaultExpanded
},
[overrides, defaultExpanded]
)
// The node passes its own state back rather than having this recompute it:
// the caller already resolved the override and the depth default.
const toggle = React.useCallback((path: string, open: boolean) => {
setOverrides((current) => ({ ...current, [path]: !open }))
}, [])
return (
<div
data-slot="json-viewer"
className={cn("relative rounded-lg border bg-muted/40 font-mono text-xs", className)}
{...props}
>
{copyable ? (
<CopyButton
value={safeStringify(data)}
size="icon-xs"
className="absolute end-1.5 top-1.5 z-10 bg-background/80"
/>
) : null}
<div
data-slot="json-viewer-body"
className="overflow-auto p-3 leading-6"
style={maxHeight === undefined ? undefined : { maxHeight: `${maxHeight}px` }}
>
<JsonNode
name={rootName}
value={data}
depth={0}
path="$"
ancestors={EMPTY_ANCESTORS}
isOpen={isOpen}
toggle={toggle}
/>
</div>
</div>
)
}
export { JsonViewer }