Developer
JSON tree
A collapsible viewer for JSON-like data with typed values and a copyable path per row.
- Built on
ARIA treeClipboard API- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.8 kB
- Dependencies
- React
- deploy{3}
- service: "checkout"
- replicas: 3
- checks: {2}
Code
import { JsonTree } from "@/components/equip/json-tree";
const deploy = {
service: "checkout",
replicas: 3,
checks: { http: true, db: true },
};
export default function Demo() {
return <JsonTree label="Deploy payload" rootName="deploy" data={deploy} style={{ width: 216 }} />;
}Examples
Nested payload
Hover a row or press Ctrl+C or Cmd+C on it to copy its path, such as data.lines[0].amount or data.customer["billing-region"].
- event{5}
- id: "evt_3PqA9c"
- type: "invoice.payment_failed"
- created: 1741776000
- livemode: false
- data: {5}
- customer: {3}
- amount_due: 4900
- attempt_count: 2
- next_attempt: null
- lines: [2]
Code
import { JsonTree } from "@/components/equip/json-tree";
const event = {
id: "evt_3PqA9c",
type: "invoice.payment_failed",
created: 1741776000,
livemode: false,
data: {
customer: { id: "cus_R8x2", email: "ops@example.com", "billing-region": "eu-west-1" },
amount_due: 4900,
attempt_count: 2,
next_attempt: null,
lines: [
{ description: "Team plan, 5 seats", amount: 4500 },
{ description: "Extra build minutes", amount: 400 },
],
},
};
export default function Example() {
return <JsonTree label="Webhook event" rootName="event" data={event} defaultExpandDepth={2} style={{ maxWidth: "min(420px, 100%)" }} />;
}Large arrays
Only the first 100 items render; a row at the end shows the next 100, and it is reachable with the arrow keys.
- samples[260]
- 0: {2}
- 1: {2}
- 2: {2}
- 3: {2}
- 4: {2}
- 5: {2}
- 6: {2}
- 7: {2}
- 8: {2}
- 9: {2}
- 10: {2}
- 11: {2}
- 12: {2}
- 13: {2}
- 14: {2}
- 15: {2}
- 16: {2}
- 17: {2}
- 18: {2}
- 19: {2}
- 20: {2}
- 21: {2}
- 22: {2}
- 23: {2}
- 24: {2}
- 25: {2}
- 26: {2}
- 27: {2}
- 28: {2}
- 29: {2}
- 30: {2}
- 31: {2}
- 32: {2}
- 33: {2}
- 34: {2}
- 35: {2}
- 36: {2}
- 37: {2}
- 38: {2}
- 39: {2}
- 40: {2}
- 41: {2}
- 42: {2}
- 43: {2}
- 44: {2}
- 45: {2}
- 46: {2}
- 47: {2}
- 48: {2}
- 49: {2}
- 50: {2}
- 51: {2}
- 52: {2}
- 53: {2}
- 54: {2}
- 55: {2}
- 56: {2}
- 57: {2}
- 58: {2}
- 59: {2}
- 60: {2}
- 61: {2}
- 62: {2}
- 63: {2}
- 64: {2}
- 65: {2}
- 66: {2}
- 67: {2}
- 68: {2}
- 69: {2}
- 70: {2}
- 71: {2}
- 72: {2}
- 73: {2}
- 74: {2}
- 75: {2}
- 76: {2}
- 77: {2}
- 78: {2}
- 79: {2}
- 80: {2}
- 81: {2}
- 82: {2}
- 83: {2}
- 84: {2}
- 85: {2}
- 86: {2}
- 87: {2}
- 88: {2}
- 89: {2}
- 90: {2}
- 91: {2}
- 92: {2}
- 93: {2}
- 94: {2}
- 95: {2}
- 96: {2}
- 97: {2}
- 98: {2}
- 99: {2}
- Show 100 more
Code
import { JsonTree } from "@/components/equip/json-tree";
const samples = Array.from({ length: 260 }, (_, i) => ({ t: i * 15, p95: 120 + ((i * 53) % 90) }));
export default function Example() {
return (
<JsonTree
label="Latency samples"
rootName="samples"
data={samples}
style={{ maxWidth: 360, maxHeight: 280 }}
/>
);
}Circular references and sorted keys
A value that points back at one of its ancestors shows as [Circular] instead of recursing forever.
- team{3}
- children: [1]
- 0: {4}
- children: [0]
- name: "observability"
- owner: "Sam Lee"
- parent: [Circular]
- name: "platform"
- owner: "Priya Raman"
Code
"use client";
import { JsonTree } from "@/components/equip/json-tree";
type Node = { name: string; owner: string; parent?: Node; children: Node[] };
const team: Node = { name: "platform", owner: "Priya Raman", children: [] };
const child: Node = { name: "observability", owner: "Sam Lee", parent: team, children: [] };
team.children.push(child);
export default function Example() {
return <JsonTree label="Team graph" rootName="team" data={team} defaultExpandDepth={3} sortKeys style={{ maxWidth: 360 }} />;
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
data | unknown | none | Any JSON-like value: objects, arrays, strings, numbers, booleans, null. |
label | string | none | Names the tree for assistive tech. |
defaultExpandDepth | number | 1 | How many levels start open. 0 shows only the root. |
rootName | string | none | Text shown on the root row. |
sortKeys | boolean | false | Lists object keys in code point order instead of insertion order. |
...rest | ComponentProps<"div"> | none | Every native div attribute, including style for a max height. |
Anatomy
| root | .eq-json-tree | Scrolls when given a max height. |
|---|---|---|
| tree | .eq-json-tree-list | Role tree; rows are a flat list with aria-level. |
| row | .eq-json-tree-row | Role treeitem. |
| chevron | .eq-json-tree-chevron | |
| spacer | .eq-json-tree-spacer | Keeps leaves aligned with branches. |
| key | .eq-json-tree-key | |
| punctuation | .eq-json-tree-punct | |
| count | .eq-json-tree-count | {3} for objects, [12] for arrays. |
| value | .eq-json-tree-value | data-kind sets the tone. |
| show more | .eq-json-tree-more | |
| copy button | .eq-json-tree-copy | |
| copied tick | .eq-json-tree-tick | |
| status | .eq-json-tree-status | Polite live region that confirms a copy. |
Keyboard
| ArrowDownArrowUp | Moves to the next or previous visible row. |
|---|---|
| ArrowRight | Opens a closed branch, or moves to its first child. |
| ArrowLeft | Closes an open branch, or moves to the parent. |
| HomeEnd | Moves to the first or last visible row. |
| EnterSpace | Opens or closes a branch, or shows the next 100 items on a show more row. |
| Ctrl+CCmd+C | Copies the focused row's path, such as user.roles[0], unless text is selected. |
Notes
- Values that point back at one of their ancestors render as [Circular] instead of recursing.
- Arrays and objects render 100 entries at a time; the show more row is part of the arrow key order.
- Only open branches are walked, so a large closed payload costs one row.
- Strings over 80 characters are cut with an ellipsis and the full value is in the title tooltip.
- Keys that are not identifiers are bracket quoted in copied paths, such as headers["content-type"].
- The copy button is hidden from assistive tech; Ctrl+C or Cmd+C on a row does the same and is announced.
- Maps, Sets and class instances show their own enumerable keys only.
- Copying uses the async Clipboard API, which needs a secure context; elsewhere it falls back to document.execCommand("copy") and announces a failure if that is blocked too.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/json-tree.tsx379 lines
"use client";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, KeyboardEvent } from "react";
import "./json-tree.css";
export type JsonTreeProps = Omit<ComponentProps<"div">, "children"> & {
/** Any JSON-like value: objects, arrays, strings, numbers, booleans, null. */
data: unknown;
/** Names the tree for assistive tech. */
label: string;
/** How many levels start open. 0 shows only the root. */
defaultExpandDepth?: number;
/** Text shown on the root row. */
rootName?: string;
/** Lists object keys in code point order instead of insertion order. */
sortKeys?: boolean;
};
type Kind = "object" | "array" | "string" | "number" | "boolean" | "null" | "undefined" | "circular" | "other";
type Row =
| {
type: "node";
id: string;
path: string;
parent: string | null;
depth: number;
name: string | null;
kind: Kind;
text: string;
full?: string;
size: number;
open: boolean;
pos: number;
setSize: number;
}
| { type: "more"; id: string; path: string; parent: string; depth: number; count: number; pos: number; setSize: number };
const PAGE = 100;
const MAX_STRING = 80;
const IDENT = /^[A-Za-z_$][\w$]*$/;
function childPath(path: string, key: string | number) {
if (typeof key === "number") return `${path}[${key}]`;
if (IDENT.test(key)) return path ? `${path}.${key}` : key;
return `${path}[${JSON.stringify(key)}]`;
}
function describe(value: unknown, ancestors: object[]): { kind: Kind; text: string; full?: string } {
if (value === null) return { kind: "null", text: "null" };
switch (typeof value) {
case "string": {
const shown = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value;
return { kind: "string", text: JSON.stringify(shown), full: value.length > MAX_STRING ? value : undefined };
}
case "number":
return { kind: "number", text: String(value) };
case "bigint":
return { kind: "number", text: `${value}n` };
case "boolean":
return { kind: "boolean", text: String(value) };
case "undefined":
return { kind: "undefined", text: "undefined" };
case "object":
if (value instanceof Date) {
return { kind: "string", text: Number.isNaN(value.getTime()) ? "Invalid date" : value.toISOString() };
}
if (ancestors.includes(value)) return { kind: "circular", text: "[Circular]" };
return { kind: Array.isArray(value) ? "array" : "object", text: "" };
case "function":
return { kind: "other", text: `ƒ ${value.name || "anonymous"}()` };
default:
return { kind: "other", text: String(value) };
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
const back = document.activeElement;
document.body.append(area);
area.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
area.remove();
if (back instanceof HTMLElement) back.focus();
return ok;
}
}
export function JsonTree({
data,
label,
defaultExpandDepth = 1,
rootName,
sortKeys = false,
className,
...rest
}: JsonTreeProps) {
const [toggled, setToggled] = useState<Map<string, boolean>>(() => new Map());
const [shown, setShown] = useState<Map<string, number>>(() => new Map());
const [active, setActive] = useState("");
const [copied, setCopied] = useState<string | null>(null);
const [status, setStatus] = useState("");
const rowEls = useRef(new Map<string, HTMLLIElement>());
const pendingFocus = useRef<number | null>(null);
// After "show more", the first new row takes the place (and the focus) of the more row.
useEffect(() => {
const index = pendingFocus.current;
if (index === null) return;
pendingFocus.current = null;
const row = rows[Math.min(index, rows.length - 1)];
setActive(row.id);
rowEls.current.get(row.id)?.focus();
});
useEffect(() => {
if (copied === null) return;
const t = setTimeout(() => setCopied(null), 1600);
return () => clearTimeout(t);
}, [copied]);
const rows: Row[] = [];
function walk(
value: unknown,
path: string,
name: string | null,
parent: string | null,
depth: number,
ancestors: object[],
pos: number,
setSize: number,
) {
const d = describe(value, ancestors);
const container = d.kind === "object" || d.kind === "array";
const obj = container && typeof value === "object" && value !== null ? value : null;
const keys: (string | number)[] = obj
? Array.isArray(obj)
? obj.map((_, i) => i)
: sortKeys
? Object.keys(obj).sort()
: Object.keys(obj)
: [];
const open = obj ? (toggled.get(path) ?? depth < defaultExpandDepth) : false;
rows.push({
type: "node",
id: path,
path,
parent,
depth,
name,
kind: d.kind,
text: d.text,
full: d.full,
size: keys.length,
open,
pos,
setSize,
});
if (!obj || !open) return;
const limit = shown.get(path) ?? PAGE;
const visible = keys.slice(0, limit);
const more = keys.length - visible.length;
const childSet = visible.length + (more > 0 ? 1 : 0);
const nextAncestors = [...ancestors, obj];
visible.forEach((k, i) => {
const child = (obj as Record<string | number, unknown>)[k];
walk(child, childPath(path, k), String(k), path, depth + 1, nextAncestors, i + 1, childSet);
});
if (more > 0) {
rows.push({
type: "more",
id: `${path}\u0000more`,
path,
parent: path,
depth: depth + 1,
count: Math.min(PAGE, more),
pos: childSet,
setSize: childSet,
});
}
}
walk(data, "", rootName ?? null, null, 0, [], 1, 1);
const activeIndex = Math.max(0, rows.findIndex((r) => r.id === active));
const activeId = rows[activeIndex].id;
function focusRow(id: string) {
setActive(id);
rowEls.current.get(id)?.focus();
}
function setOpen(path: string, open: boolean) {
setToggled((m) => new Map(m).set(path, open));
}
function revealMore(row: Row) {
const index = rows.indexOf(row);
if (rowEls.current.get(row.id) === document.activeElement) pendingFocus.current = index;
setShown((m) => new Map(m).set(row.path, (m.get(row.path) ?? PAGE) + PAGE));
}
async function copy(path: string) {
const ok = await copyText(path);
if (ok) {
setCopied(path);
setStatus(`Copied ${path}`);
} else {
setStatus("Copy failed");
}
}
function activate(row: Row) {
if (row.type === "more") revealMore(row);
else if (row.kind === "object" || row.kind === "array") setOpen(row.path, !row.open);
}
function onKeyDown(e: KeyboardEvent<HTMLLIElement>, row: Row, index: number) {
const expandable = row.type === "node" && (row.kind === "object" || row.kind === "array");
switch (e.key) {
case "ArrowDown":
if (index < rows.length - 1) focusRow(rows[index + 1].id);
break;
case "ArrowUp":
if (index > 0) focusRow(rows[index - 1].id);
break;
case "Home":
focusRow(rows[0].id);
break;
case "End":
focusRow(rows[rows.length - 1].id);
break;
case "ArrowRight":
if (!expandable || row.type !== "node") break;
if (!row.open) setOpen(row.path, true);
else if (row.size > 0) focusRow(rows[index + 1].id);
break;
case "ArrowLeft":
if (row.type === "node" && expandable && row.open) setOpen(row.path, false);
else if (row.parent !== null) focusRow(row.parent);
break;
case "Enter":
case " ":
activate(row);
break;
case "c":
case "C":
if (!(e.ctrlKey || e.metaKey) || row.type !== "node" || row.depth === 0) return;
if (window.getSelection()?.isCollapsed === false) return;
void copy(row.path);
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
}
return (
<div {...rest} className={["eq-json-tree", className].filter(Boolean).join(" ")}>
<ul className="eq-json-tree-list" role="tree" aria-label={label}>
{rows.map((row, index) => {
const common = {
ref: (el: HTMLLIElement | null) => {
if (el) rowEls.current.set(row.id, el);
else rowEls.current.delete(row.id);
},
role: "treeitem",
"aria-level": row.depth + 1,
"aria-posinset": row.pos,
"aria-setsize": row.setSize,
tabIndex: row.id === activeId ? 0 : -1,
style: { "--_depth": row.depth } as CSSProperties,
onFocus: () => setActive(row.id),
onKeyDown: (e: KeyboardEvent<HTMLLIElement>) => onKeyDown(e, row, index),
};
if (row.type === "more") {
return (
<li
key={row.id}
{...common}
className="eq-json-tree-row eq-json-tree-more"
onClick={() => revealMore(row)}
>
<span className="eq-json-tree-spacer" aria-hidden="true" />
Show {row.count} more
</li>
);
}
const expandable = row.kind === "object" || row.kind === "array";
return (
<li
key={row.id}
{...common}
className="eq-json-tree-row"
aria-expanded={expandable ? row.open : undefined}
onClick={() => {
focusRow(row.id);
activate(row);
}}
>
{expandable ? (
<svg
className="eq-json-tree-chevron"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4.5 3 7.5 6l-3 3" />
</svg>
) : (
<span className="eq-json-tree-spacer" aria-hidden="true" />
)}
{row.name !== null && (
<span className="eq-json-tree-key">
{row.name}
{row.depth > 0 && <span className="eq-json-tree-punct">: </span>}
</span>
)}
{expandable ? (
<span className="eq-json-tree-count">
{row.kind === "array" ? `[${row.size}]` : `{${row.size}}`}
</span>
) : (
<span className="eq-json-tree-value" data-kind={row.kind} title={row.full}>
{row.text}
</span>
)}
{row.depth > 0 && (
<button
type="button"
className="eq-json-tree-copy"
tabIndex={-1}
aria-hidden="true"
title={`Copy path ${row.path}`}
data-copied={copied === row.path || undefined}
onClick={(e) => {
e.stopPropagation();
void copy(row.path);
}}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{copied === row.path ? (
<path key="tick" className="eq-json-tree-tick" d="m3.5 8.5 3 3 6-7" pathLength="1" />
) : (
<path key="copy" d="M5.5 5.5V3.5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-2M3.5 5.5h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" />
)}
</svg>
</button>
)}
</li>
);
})}
</ul>
<span className="eq-json-tree-status" role="status">
{status}
</span>
</div>
);
}components/equip/json-tree.css187 lines
@layer equip {
.eq-json-tree {
max-width: 100%;
overflow: auto;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
}
.eq-json-tree-list {
margin: 0;
padding: 0;
list-style: none;
}
.eq-json-tree-row {
position: relative;
display: flex;
align-items: center;
gap: var(--eq-space-1);
box-sizing: border-box;
height: var(--eq-h-s);
padding-inline: calc(var(--eq-space-2) + var(--_depth, 0) * var(--eq-space-4)) var(--eq-space-2);
border-radius: var(--eq-radius-s);
overflow: hidden;
white-space: nowrap;
cursor: default;
user-select: text;
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-json-tree-row[aria-expanded],
.eq-json-tree-more {
cursor: pointer;
}
@media (hover: hover) {
.eq-json-tree-row:hover {
background: var(--eq-tint);
}
.eq-json-tree-row:hover > .eq-json-tree-copy {
opacity: 1;
}
.eq-json-tree-copy:hover {
color: var(--eq-ink);
}
.eq-json-tree-more:hover {
color: var(--eq-ink);
}
}
.eq-json-tree-row:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-json-tree-chevron,
.eq-json-tree-spacer {
flex: none;
width: 12px;
height: 12px;
}
.eq-json-tree-chevron {
color: var(--eq-ink-3);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-json-tree-chevron:dir(rtl) {
scale: -1 1;
}
.eq-json-tree-row[aria-expanded="true"] > .eq-json-tree-chevron {
rotate: 90deg;
}
.eq-json-tree-row[aria-expanded="true"] > .eq-json-tree-chevron:dir(rtl) {
rotate: -90deg;
}
.eq-json-tree-key {
flex: none;
color: var(--eq-ink);
}
.eq-json-tree-punct,
.eq-json-tree-count {
color: var(--eq-ink-3);
}
.eq-json-tree-count {
font-variant-numeric: tabular-nums;
}
.eq-json-tree-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.eq-json-tree-value[data-kind="string"] {
color: var(--eq-ok);
}
.eq-json-tree-value[data-kind="number"] {
color: var(--eq-focus);
}
.eq-json-tree-value:is([data-kind="boolean"], [data-kind="null"]) {
color: var(--eq-warn);
}
.eq-json-tree-value:is([data-kind="undefined"], [data-kind="circular"], [data-kind="other"]) {
color: var(--eq-ink-3);
font-style: italic;
}
.eq-json-tree-more {
color: var(--eq-ink-2);
font-family: var(--eq-font);
font-variation-settings: normal;
font-weight: var(--eq-weight-strong);
}
.eq-json-tree-copy {
position: absolute;
inset-inline-end: 2px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
color: var(--eq-ink-2);
cursor: pointer;
opacity: 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-json-tree-row:focus-visible > .eq-json-tree-copy,
.eq-json-tree-copy[data-copied] {
opacity: 1;
}
.eq-json-tree-copy[data-copied] {
color: var(--eq-ok);
}
.eq-json-tree-copy:active {
scale: var(--eq-press);
}
.eq-json-tree-copy > svg {
width: 14px;
height: 14px;
}
.eq-json-tree-tick {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-json-tree-tick {
stroke-dashoffset: 1;
}
}
.eq-json-tree-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}