JSON Diff
A structural diff of two JSON values as one tree — added, removed and changed keys marked by sign and wording as well as colour, changed leaves showing before → after inline, unchanged subtrees folded behind counts, and arrays paired by index or by an identity field.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/json-diff.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "JsonDiff" component: a STRUCTURAL diff of
two JSON-shaped values, rendered as one tree. Dependencies: lucide-react for
icons and a cn() class merger. No diff library and no text diffing — the two
values are walked node by node, never serialised and compared as lines.
Contract
- export const JsonDiff = React.forwardRef<HTMLDivElement, JsonDiffProps>;
remaining native div props spread on the root, className merged with cn().
- export type DiffStatus = "added" | "removed" | "changed" | "unchanged".
- JsonDiffProps extends React.HTMLAttributes<HTMLDivElement>:
left: unknown // the before value
right: unknown // the after value
leftLabel = "Before", rightLabel = "After"
rootLabel = "root" // first path segment and root row label
arrayKey?: string // pair array elements by this field
collapseUnchanged = true
maxValueLength = 80 // elide rendered values longer than this
showBreadcrumb = true // path of the focused row, under the tree
maxHeight?: number // px cap on the scroll pane; omit to grow
onFocusPath?: (path: string, status: DiffStatus) => void
- The tree is DERIVED, never stored: one useMemo over (left, right, rootLabel,
arrayKey) builds every node. Local state is only the expansion overrides
(Map<id, boolean>), the roving focus id, and one transient sentence.
- Node shape: { id, parentId, order, depth, pos, size, label, path, kind,
status, leftText?, rightText?, children, counts, keyNote? }.
id positional ("0/2/1"), NOT the path: a key literally named "a.b"
aliases root.a.b and would collide as a React key.
order pre-order index; this is what "next change" compares against.
label a key name, [3], or [id=u_2]. path is the same segments joined.
counts { added, removed, changed } over the subtree, counted once per
change root — never once per descendant.
Behavior
- Pairing, decided per node:
both plain objects -> the union of own keys: left keys in left order, then
right-only keys appended. The before side is the spine, so a re-ordered
response never reshuffles the tree and key order is not a difference.
both arrays -> by index (i against i; the tail of the longer side is
added or removed), or by identity when arrayKey is set.
one side absent -> the node is "added" or "removed" as a WHOLE.
anything else -> a leaf verdict, including object turning into a
primitive and object turning into array: two shapes with nothing in
common cannot be walked together, so it is one "changed" rendered as
"{2 keys} → [1 item]".
- Absence means "no such key", tracked with a private ABSENT sentinel and own-key
membership — NOT value === undefined. { a: undefined } against {} is a real
difference, and a diff that flattens the two lies about the payload.
- Leaf equality is Object.is, plus two rules: two Dates compare by getTime (so
Invalid Date equals Invalid Date), and two sides that both closed a cycle
compare equal because neither can be walked further. Say the consequences out
loud in the docs: NaN equals NaN, 0 and -0 differ.
- Identity matching (arrayKey): every element of BOTH arrays must be an object
carrying a unique string, number or boolean under that field. The moment one
is missing, non-primitive or repeated, THAT array falls back to index matching
and says so on its own row ("matched by index — id is missing or repeated
here"). Half-keyed matching would pair the wrong rows and invent changes that
never happened. Matched rows are labelled [id=u_2], so a pure reorder becomes
invisible; index matching reports a single insert as a wall of changes. Both
answers are correct to different questions, and the data decides per array.
Keyed rows are painted in the LEFT array's order with right-only elements
appended, exactly like object keys: the before side sets the order on both
branches of the walk, so an insert lands at the end instead of shoving the
rows under it down the screen.
- Verdict roll-up: a container is "changed" when any child differs, "unchanged"
otherwise. Counts are summed from the children EXCEPT under an added or
removed node, which counts 1 and stops descending — a five-key block that
arrived is one addition, not five.
- Folding: a container opens by default only when its status is "changed".
Unchanged subtrees and wholly added or removed ones read fine as one summary
line ("{…} 12 keys", "[…] 3 items") and stay shut. collapseUnchanged={false}
opens everything. Reader toggles are stored as overrides ON TOP of that
default, so the default keeps working for the rows nobody touched.
- Change navigation: the change roots (changed leaves plus added/removed nodes,
never their descendants) are collected in document order — the same list the
counts report. Next/previous moves to the next one by order, wrapping at both
ends, OPENS EVERY ANCESTOR of the target first (whatever the reader had folded
away) and announces "Change 3 of 7: deployment.env.LOG_LEVEL — changed". The
target row may only be mounted by the commit that opened its ancestors, so the
handler writes the id into a ref and one dependency-less effect consumes it
once and moves DOM focus. One ref, written and read synchronously — no timeout.
- Collapsing can destroy the row the reader is standing on, so every expansion
change goes through one function: when the focused node would stop being
visible it walks up to the nearest surviving ancestor (the root at worst),
makes it the roving tab stop, and takes DOM focus only if the tree already
held it — a toolbar press must leave the person on the button they just used.
Focus never lands on <body>.
- Keyboard on the tree (roving tabindex: exactly one row is a tab stop):
ArrowDown / ArrowUp move one visible row
ArrowRight expand; if already open, move to the first child
ArrowLeft collapse; if already closed, move to the parent
Home / End first / last visible row
Enter / Space toggle the focused container. preventDefault first:
Space scrolls the page and Enter submits the form
this tree may be sitting in
n / p next / previous change, case-insensitive, skipped
while Ctrl/Cmd/Alt is held
* expand every container
The handler bails out on event.nativeEvent.isComposing (mid-composition every
key belongs to the IME) and on already-handled events. Every one of these has
a pointer equivalent in the toolbar — previous/next change, Expand all, Fold
unchanged — so nothing is keyboard-only and nothing is pointer-only.
- Refusals are sentences, never silence: jumping with two identical sides answers
"Before and After are identical — there is no change to jump to."; expanding a
childless root answers likewise. The sentence renders under the tree in a
polite, atomic role="status" and clears itself after 4s — clearing is what lets
an identical second refusal be announced again instead of being swallowed as a
no-change.
- ARIA: the pane is role="tree" with an accessible name naming both sides and
aria-describedby pointing at the summary line; rows are role="treeitem" with
aria-level, aria-posinset, aria-setsize, and aria-expanded on containers only.
The tree has NO selection model, so no aria-selected is set — focus is the
only cursor. The verdict never rests on colour: a +/-/~ gutter sign, an
sr-only word ("Added: ", "Removed: ", "Changed: "), a strike-through on the
old value and an arrow between the two carry it as well.
- Buttons use aria-disabled plus a guard in the handler, never the native
attribute: the browser blurs a disabled node the instant it goes inert, which
would drop a keyboard reader on the document body mid-review.
- Degenerate inputs: cycles render as [Circular] and stop the walk (the ancestor
WeakSet is per side, so passing the same object as both sides is still fine);
two primitives make a one-row tree; empty containers render {} and []; class
instances are walked by their own enumerable keys; Dates compare by ISO text.
- A new left/right identity means a new tree, so expansion, focus and the message
reset during render (no effect needed). A fresh object literal written inline
in JSX counts as new on every render — hold the two sides in state, a ref, or a
module constant.
- Cleanup: the single message timer is cleared before every replacement and on
unmount. There are no listeners, observers, rAFs or floating layers to leak.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch: pane bg-card + border; rows tinted
bg-primary/10 (added), bg-destructive/10 (removed), bg-muted (changed) and
untinted when unchanged; marks and values text-primary / text-destructive /
text-foreground with everything quiet on text-muted-foreground; focus is
ring-2 ring-ring ring-inset; toolbar buttons are border + bg-background with
hover:bg-accent hover:text-accent-foreground and aria-disabled:opacity-40.
Every className merged through cn().
- The tree is font-mono with whitespace-nowrap rows inside overflow-auto, so a
deep path scrolls sideways instead of wrapping into an unreadable stack.
Indent is depth * 16px of padding-left on the row body, which keeps the hit
area spanning the full width at every level.
- The only animation is the chevron rotation, and it carries
motion-reduce:transition-none. Nothing about the diff depends on motion.
- Partially changed container rows carry roll-up chips (+2 -1 ~3) with sr-only
expansions, so a folded subtree still reports what is inside it. An added or
removed container gets none: it IS the single change, and a "+1" there would
read as "one addition inside" and fight the gutter sign — its size ("{4 keys}")
is the useful number instead. The focused row's path is drawn as a breadcrumb
under the tree with its verdict.
Customization levers
- Density: row py-0.5 and INDENT_PX = 16 are the two sizing dials; maxHeight
caps the pane; maxValueLength decides where long values are elided.
- Slots worth keeping or cutting: the breadcrumb (showBreadcrumb), the count
chips, the two side labels, the whole toolbar (keep n / p and the live region
if you drop it), the visible status line (keep the role="status" element even
if the visible copy goes).
- Fold policy is one function, defaultExpanded. Return true for "added" as well
to open brand-new branches, or key it off node.depth for "open two levels and
no further".
- Verdict colours are four token maps (STATUS_ROW / STATUS_MARK / the chips /
the breadcrumb badge). Swap bg-primary/10 for var(--chart-2) style tokens if
your palette has real hues — but keep the gutter sign and the sr-only word, or
the diff stops being readable in greyscale and to a screen reader.
- Array identity: arrayKey is one field name applied everywhere. For mixed
payloads change it to (path: string) => string | undefined and read it in the
array branch; keep the fallback and its note, they are what make a wrong guess
survivable instead of silently wrong.
- Equality is one function, sameLeaf. Loosen it to == for an API that flips "3"
and 3, or return "unchanged" early for noisy paths (updatedAt, requestId) —
an ignore list belongs there rather than in a post-filter, because the counts
and the change list come off the same walk.
- Reading direction: swapping left and right turns additions into removals. Ship
it as a button that swaps the two props; the component is symmetric and needs
no flag of its own.Concepts
- Structural walk, not a text diff — the two values are compared node by node, so the unit of change is a key path rather than a line. Re-ordering keys, re-indenting or re-serialising the payload produces no change at all, which is exactly what a line-based diff cannot promise.
- Absent is not
undefined— a key that does not exist is tracked with a private sentinel, not by testing the value, so{ a: undefined }against{}reports a removal instead of quietly reading as equal. The same discipline is what keepsNaNequal toNaNand0apart from-0. - Identity matching, and the fallback that admits it — with
arrayKeyset, array elements pair by their id and a pure reorder disappears; the instant one element is missing that id or two repeat it, that array drops back to index pairing and prints the reason on its own row, because half-keyed matching invents changes that never happened. - One change, one count — a whole subtree that arrived or left counts once and stays folded behind its size. A block of five new keys reads as one addition, so the summary and the jump list agree with what a reviewer would actually call a change.
- Fold what has nothing to say — only containers holding a partial change open on arrival; everything else is a summary line with a count, and reader toggles are stored as overrides on top of that default rather than replacing it, so untouched rows keep behaving.
- Jump, then hand over focus — next / previous change opens every ancestor of the target before moving there, and because the target row may only exist after that commit, the id travels through one ref that a dependency-less effect consumes. Collapsing works the same way in reverse: a row that disappears under the reader hands focus to its nearest surviving ancestor, never to the document.
Live Cursors
A multiplayer cursor overlay — labelled peer pointers interpolated between low-rate updates, chart-token colour per peer, idle fade, relayed click ripples and a spoken roster, with the transport left to you.
JWT Decoder
A paste-a-token JWT inspector: header and payload as labelled claims, registered claims explained, exp/nbf/iat judged against an injected instant, and a permanent notice that the signature is never verified.