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
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}
  • amount_due: 4900
  • attempt_count: 2
  • next_attempt: null
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]
  • 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}
  • 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

PropTypeDefaultWhat it does
dataunknownnoneAny JSON-like value: objects, arrays, strings, numbers, booleans, null.
labelstringnoneNames the tree for assistive tech.
defaultExpandDepthnumber1How many levels start open. 0 shows only the root.
rootNamestringnoneText shown on the root row.
sortKeysbooleanfalseLists object keys in code point order instead of insertion order.
...restComponentProps<"div">noneEvery native div attribute, including style for a max height.

Anatomy

root.eq-json-treeScrolls when given a max height.
tree.eq-json-tree-listRole tree; rows are a flat list with aria-level.
row.eq-json-tree-rowRole treeitem.
chevron.eq-json-tree-chevron
spacer.eq-json-tree-spacerKeeps 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-valuedata-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-statusPolite live region that confirms a copy.

Keyboard

ArrowDownArrowUpMoves to the next or previous visible row.
ArrowRightOpens a closed branch, or moves to its first child.
ArrowLeftCloses an open branch, or moves to the parent.
HomeEndMoves to the first or last visible row.
EnterSpaceOpens or closes a branch, or shows the next 100 items on a show more row.
Ctrl+CCmd+CCopies 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index