JSON Viewer
A collapsible JSON tree: fold objects and arrays, see item counts when folded, and copy any value’s path.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/json-viewer.json'use client'
import { useState, type ReactNode } from 'react'
import './json-viewer.css'
export interface JsonViewerProps {
data: unknown
defaultDepth?: number
rootName?: string
fill?: 'dark' | 'light'
className?: string
}
const isObj = (v: unknown): v is Record<string, unknown> | unknown[] => typeof v === 'object' && v !== null
const childPath = (p: string, k: string | number) => (typeof k === 'number' ? `${p}[${k}]` : /^[A-Za-z_$][\w$]*$/.test(k) ? `${p}.${k}` : `${p}["${k}"]`)
/** Collapsible JSON tree with path copy. */
export function JsonViewer({ data, defaultDepth = 1, rootName = 'data', fill = 'dark', className }: JsonViewerProps) {
const [open, setOpen] = useState<Record<string, boolean>>({})
const [msg, setMsg] = useState('')
const [epoch, setEpoch] = useState<{ all: boolean | null }>({ all: null })
const isOpen = (path: string, depth: number) => open[path] ?? (epoch.all ?? depth < defaultDepth)
const prim = (v: unknown): ReactNode =>
typeof v === 'string' ? <span className="sd-jsv__s">"{v}"</span>
: typeof v === 'number' ? <span className="sd-jsv__n">{v}</span>
: typeof v === 'boolean' ? <span className="sd-jsv__b">{String(v)}</span>
: <span className="sd-jsv__null">null</span>
const copy = async (p: string) => { try { await navigator.clipboard.writeText(p) } catch {} setMsg(`Copied ${p}`) }
const node = (k: string | number | null, v: unknown, path: string, depth: number): ReactNode => {
const label = k === null ? null : <><span className="sd-jsv__k">{typeof k === 'number' ? k : `"${k}"`}</span><span className="sd-jsv__p">: </span></>
const cp = <button type="button" className="sd-jsv__cp" aria-label={`Copy path ${path}`} onClick={() => copy(path)}>copy path</button>
if (!isObj(v)) return <li key={path} role="treeitem" aria-selected={false}><div className="sd-jsv__row" style={{ '--d': depth } as React.CSSProperties}><span className="sd-jsv__sp" />{label}{prim(v)}{cp}</div></li>
const arr = Array.isArray(v)
const entries: [string | number, unknown][] = arr ? (v as unknown[]).map((x, i) => [i, x]) : Object.entries(v)
const o = isOpen(path, depth)
return (
<li key={path} role="treeitem" aria-expanded={o} aria-selected={false}>
<div className="sd-jsv__row" style={{ '--d': depth } as React.CSSProperties}>
<button type="button" className="sd-jsv__tg" aria-expanded={o} aria-label={`${o ? 'Collapse' : 'Expand'} ${path}`} onClick={() => setOpen((s) => ({ ...s, [path]: !o }))}><svg viewBox="0 0 10 10" aria-hidden><path d="m2 3.5 3 3 3-3" /></svg></button>
{label}<span className="sd-jsv__p">{arr ? '[' : '{'}</span>
{!o && <><span className="sd-jsv__sum">{entries.length}</span><span className="sd-jsv__p">{arr ? ']' : '}'}</span></>}
{cp}
</div>
{o && (
<>
<ul role="group">{entries.map(([ck, cv]) => node(ck, cv, childPath(path, ck), depth + 1))}</ul>
<div className="sd-jsv__row" style={{ '--d': depth } as React.CSSProperties}><span className="sd-jsv__sp" /><span className="sd-jsv__p">{arr ? ']' : '}'}</span></div>
</>
)}
</li>
)
}
return (
<div className={['sd-jsv', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-jsv__top"><span>response.json</span>
<button type="button" onClick={() => { setOpen({}); setEpoch({ all: true }) }}>Expand all</button>
<button type="button" onClick={() => { setOpen({}); setEpoch({ all: false }) }}>Collapse</button>
</div>
<ul className="sd-jsv__body" role="tree" aria-label="JSON">{node(null, data, rootName, 0)}</ul>
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-jsv { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-jsv[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-jsv button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-jsv :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-jsv .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-jsv { width: min(100%, 340px); border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-jsv__top { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 12px; color: var(--mute); }
.sd-jsv__top span { flex: 1; }
.sd-jsv__top button { height: 26px; padding: 0 8px; border-radius: 7px; font-size: 11.5px; }
.sd-jsv__top button:hover { color: var(--fg); background: var(--soft) !important; }
.sd-jsv__body { max-height: 340px; overflow: auto; padding: 6px 0; font: 12.5px/1.6 ui-monospace, 'JetBrains Mono', monospace; }
.sd-jsv ul { list-style: none; margin: 0; padding: 0; }
.sd-jsv__row { display: flex; align-items: center; gap: 4px; padding: 0 8px 0 calc(var(--d) * 16px + 8px); min-height: 24px; white-space: nowrap; }
.sd-jsv__row:hover { background: var(--soft); }
.sd-jsv__tg { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 4px; color: var(--mute); flex: none; }
.sd-jsv__tg svg { width: 9px; height: 9px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform 160ms; }
.sd-jsv__tg[aria-expanded='false'] svg { transform: rotate(-90deg); }
.sd-jsv__sp { width: 16px; flex: none; }
.sd-jsv__k { color: var(--fg); }
.sd-jsv__p { color: var(--mute); }
.sd-jsv__s { color: var(--mute); }
.sd-jsv__n { font-weight: 700; }
.sd-jsv__b { font-weight: 700; font-style: italic; }
.sd-jsv__null { color: var(--mute); font-style: italic; }
.sd-jsv__sum { color: var(--mute); padding: 0 4px; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line); font-size: 11px; }
.sd-jsv__cp { margin-left: auto; opacity: 0; font: 11px/1 ui-sans-serif, system-ui; padding: 3px 6px; border-radius: 5px; color: var(--mute); }
.sd-jsv__row:hover .sd-jsv__cp, .sd-jsv__cp:focus-visible { opacity: 1; }
.sd-jsv__cp:hover { color: var(--fg); background: var(--bg) !important; }
@media (prefers-reduced-motion: reduce) { .sd-jsv *, .sd-jsv *::before, .sd-jsv *::after { animation: none !important; transition: none !important; } }Overview
Objects and arrays fold with a chevron and show “{3}” or “[12]” when closed. Hovering a row reveals a copy button that copies its path, such as data.items[2].price.
Expand all / Collapse all sit in the header. Strings, numbers and booleans are told apart by weight and quotes rather than colour.
When to use
- API explorers, logs, debugging panels, admin tools.
When not to use
- End-user data: format it as a real UI.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/json-viewer.json$
npx shadcn@latest add https://sundaeui.dev/r/json-viewer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/json-viewer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/json-viewer.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy json-viewer.tsx and json-viewer.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { JsonViewer } from '@/components/sundae/json-viewer'
<JsonViewer />Props
| Prop | Type | Default |
|---|---|---|
data*Any JSON value. | unknown | — |
defaultDepthLevels open by default. | number | 1 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A tree with treeitems and aria-expanded on containers.
- Copy buttons are labelled with the path and announce success.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-jsv; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is JSON Viewer free?
- Yes. JSON Viewer is free for personal and commercial projects, with full source on this page.
- How do I install JSON Viewer?
- Run npx shadcn@latest add https://sundaeui.dev/r/json-viewer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does JSON Viewer support keyboard and screen readers?
- A tree with treeitems and aria-expanded on containers. Copy buttons are labelled with the path and announce success.
- Does JSON Viewer respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Stacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined Webhook Event Log
Data · RefinedOrg Chart
Data · RefinedLog Stream
Data · Refined- Pro
Scatter Plot
Data · Refined