Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, ChevronRight, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long a jump report or a refusal stays on screen before it clears itself. */
const MESSAGE_MS = 4000
/** Indent added per tree level, in px. */
const INDENT_PX = 16
/** Id of the synthetic root row, and where the roving tab stop starts. */
const ROOT_ID = "0"

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/json-diff.json

Prompt

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 keeps NaN equal to NaN and 0 apart from -0.
  • Identity matching, and the fallback that admits it — with arrayKey set, 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.

On This Page