Skip to content

JSON viewer

A collapsible, syntax-colored JSON tree with search, filtering, copy for any value or its path, and optional inline editing.

Status
Beta
Category
Content
Adoption
Not used yet
import { JsonViewer } from "@oration/canon/components/json-viewer";
packages/canon/src/components/json-viewer.tsx

Delivered to Cedarline ERP200 OK in 212 ms

Request body4 keys
"id": "evt_8Kp3Vd",
"type": "call.analyzed",
"created_at": "2026-09-28T15:42:09Z",
"data": {
"call_id": "call_7Hq2Nw",
"agent": {
"id": "agt_payment_status",
"name": "Payments desk"
"caller": {
"phone": "+13125550142",
"vendor_id": "V-10482"
"duration_ms": 184200,
"analysis": {
"intent": "payment_status",
"resolved": true,
"amount_discussed": 20625,
"follow_up_required": false,
"callback_requested_at": null,

Arrow keys move between rows and open or close them. C copies a value, P copies its path. Slash searches.

import { JsonViewer } from "@oration/canon/components/json-viewer";import { StatusLabel } from "@oration/canon/components/status-dot";export function Hero() {    const payload = {        id: "evt_8Kp3Vd",        type: "call.analyzed",        created_at: "2026-09-28T15:42:09Z",        data: {            call_id: "call_7Hq2Nw",            agent: { id: "agt_payment_status", name: "Payments desk" },            caller: { phone: "+13125550142", vendor_id: "V-10482" },            duration_ms: 184200,            analysis: {                intent: "payment_status",                resolved: true,                invoice_numbers: ["INV-20931", "INV-20947"],                amount_discussed: 20625,                follow_up_required: false,                callback_requested_at: null,                payment: {                    status: "scheduled",                    method: "ACH",                    run_date: "2026-09-29",                },            },        },    };    return (        <div className="flex w-full max-w-lg flex-col gap-2">            <p className="flex items-center justify-between gap-3 text-13">                <span className="font-medium text-foreground">                    Delivered to Cedarline ERP                </span>                <StatusLabel tone="success">200 OK in 212 ms</StatusLabel>            </p>            <JsonViewer                title="Request body"                value={payload}                defaultExpandDepth={2}            />        </div>    );}

Usage#

JSON viewer shows a payload people inspect as a collapsible tree: quoted keys, braces and commas, so it reads and copies like real JSON, with values colored by type. Every row copies its value or its JSONPath ($.data.analysis.payment.method), the toolbar folds or opens everything, and search finds keys, values and paths, then filters the tree down to the matches. Pass the value itself, never JSON.stringify output: the viewer needs the structure to fold, search and copy paths. With editable, every key and value edits where it stands, in the same type and type size, so a person adjusting a mock response never leaves the document they're reading.

When to use

  • For a payload people inspect: a webhook request body, an API response, a tool call's input and output, an audit log event.
  • For structured output people check field by field, such as post-call analysis or a mapped caller lookup.
  • For a response body that may or may not be JSON. Pass the string; text that isn't JSON renders as written.
  • Inside a card use variant="well"; inside another well, such as Tool chip, use variant="bare".
  • With editable, for JSON people adjust in place: a tool's mock response, test values for a call, a sample webhook body. Pass baseline and pair it with a save bar.

When not to use

  • For a request people run, such as a curl command, or a URL and headers. Use Code block
  • For defining fields, their types, descriptions and whether they're required. That's a schema, not a document. Use JSON schema builder
  • For a handful of labeled fields people read rather than inspect. Lay them out as a description list. Use Settings section
  • For a before-and-after of fields. Use Diff

The Machine Mono Rule

Keys and values are Geist Mono because a machine wrote them. Counts in collapsed summaries, the header, search and every control stay in Geist Sans with tabular figures.

The Tint Well Rule

The default viewer lifts like Code block. Inside a card it becomes a 70% Well Gray well with variant="well", never a raised block inside a raised card.

Syntax inks

Strings are Tag Green ink, numbers Tag Amber ink, booleans Tag Violet ink and null Slate Meta, the same inks Canon's code samples use. Keys stay Graphite Ink and punctuation Slate Meta, so the data carries the color and the labels don't.

The Quiet Indigo Rule

Indigo appears only where the system spends it: the focused row, the current search match and its marks, the ring around an open editor and the unsaved-changes dot. Matches never turn amber or yellow, and an editable viewer never adds a filled button of its own; Save belongs to the page.

Anatomy#

Response 2005 keys
"payment_id": "PMT-58213",
"amount": 20625,
"scheduled": true,

Arrow keys move between rows and open or close them. C copies a value, P copies its path. Slash searches.

  1. Title. 12px medium Slate Meta in a 36px bar with a hairline bottom. Name what the payload is: Request body, Response 200, analysis.json.
  2. Summary. The root's size in 12px tabular figures: 5 keys, 240 items, or Empty.
  3. Toolbar. 24px ghost icon buttons with tooltips: search (from 10 values up), expand all and collapse all (when anything nests) and copy the whole document. Expand all and collapse all disable when they would do nothing.
  4. Chevron and guides. A 12px chevron that turns 90° as a branch opens, in a 16px column. Each level indents 16px and draws a hairline guide under its parent's chevron.
  5. Key and value. 12px Geist Mono at a 20px line. Long strings wrap at the row's indent; past 280 characters they clip with Show all 1,240 characters.
  6. Collapsed summary. A folded branch reads { 3 keys } or [ 2 items ], the count in a 4px-corner ink chip. Clicking it, the key or the chevron opens the branch.
  7. Inline editor. Editable only. A field in the same 12px mono, color and line as the text it replaces, on White Plane with a 1px indigo ring and a soft 3px halo drawn as a shadow, so rows never shift. String quotes stay outside it.
  8. Type chip. Editable only. A 20px Slate Meta chip after the value naming its type, String or Number, with a menu of all six. New fields and nulls read Detect from value until you pick one.
  9. Unsaved dot. With baseline, a 5px indigo dot in the gutter beside each field that differs from it, and beside a folded branch with a change inside.
  10. Add key. Editable only. A quiet Slate Meta row that closes the document: a plus and Add key (or Add item for an array).

Examples#

Editable

Click a value or key to edit it where it stands, a boolean to flip it, or Add key to append. The type chip beside a field changes its type. baseline marks unsaved fields with the indigo dot until Save.

Mock response7 keys
"payment_id": "PMT-58213",
"status": "scheduled",
"method": "ACH",
"amount": 20625,
"remittance_sent": false,
"invoices": [
"INV-20931",
"INV-20947"
"note": null
Add key

Arrow keys move between rows and open or close them. Enter edits a value, F2 renames a key, Delete removes a field, Command Enter adds one below, Command Z undoes. C copies a value, P copies its path. Slash searches.

Returned when the agent calls get_payment_status

import { Button } from "@oration/canon/components/button";import { JsonViewer } from "@oration/canon/components/json-viewer";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Editable() {    const saved = {        payment_id: "PMT-58213",        status: "scheduled",        method: "ACH",        amount: 20625,        remittance_sent: false,        invoices: ["INV-20931", "INV-20947"],        note: null,    };    const [value, setValue] = React.useState<unknown>(saved);    const [baseline, setBaseline] = React.useState<unknown>(saved);    const dirty = JSON.stringify(value) !== JSON.stringify(baseline);    return (        <div className="flex w-full max-w-lg flex-col gap-3">            <JsonViewer                title="Mock response"                value={value}                onChange={setValue}                baseline={baseline}                editable            />            <div className="flex items-center justify-between gap-3">                <p className="text-13 text-muted-foreground">                    {dirty                        ? "Unsaved changes"                        : "Returned when the agent calls get_payment_status"}                </p>                <div className="flex shrink-0 gap-2">                    <Button                        variant="outline"                        size="sm"                        disabled={!dirty}                        onClick={() => setValue(baseline)}                    >                        Discard                    </Button>                    <Button                        size="sm"                        disabled={!dirty}                        onClick={() => {                            setBaseline(value);                            toast.add({ title: "Mock response saved" });                        }}                    >                        Save                    </Button>                </div>            </div>        </div>    );}

Values only

editable={{ keys: false, structure: false }} keeps a schema's shape fixed: values change, keys and fields don't.

Test values4 keys
"resolved": true,
"call_reason": "payment_status",
"amount_discussed": 12480,
"follow_up": {
"needed": false,
"due_in_days": null

Arrow keys move between rows and open or close them. Enter edits a value, F2 renames a key, Delete removes a field, Command Enter adds one below, Command Z undoes. C copies a value, P copies its path.

import { JsonViewer } from "@oration/canon/components/json-viewer";import * as React from "react";export function ValuesOnly() {    const [value, setValue] = React.useState<unknown>({        resolved: true,        call_reason: "payment_status",        amount_discussed: 12480,        follow_up: { needed: false, due_in_days: null },    });    return (        <JsonViewer            title="Test values"            value={value}            onChange={setValue}            editable={{ keys: false, structure: false }}            className="w-full max-w-md"        />    );}

Inside a card

Post-call analysis output in its settings card. The well variant keeps it flat, and 7 values open fully.

Sample output

Your current fields, filled from a call with Northwind Freight.

analysis.json7 keys
"resolved": true,
"call_reason": "payment_status",
"invoice_numbers": [
"INV-48213",
"INV-48240"
"amount_discussed": 12480,
"needed": false,
"due_in_days": null,
"sentiment": "positive"

Arrow keys move between rows and open or close them. C copies a value, P copies its path. Slash searches.

import { JsonViewer } from "@oration/canon/components/json-viewer";export function InACard() {    const output = {        resolved: true,        call_reason: "payment_status",        invoice_numbers: ["INV-48213", "INV-48240"],        amount_discussed: 12480,        needed: false,        due_in_days: null,        sentiment: "positive",    };    return (        <section className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 shadow-border">            <div>                <h3 className="text-sm font-semibold text-foreground">                    Sample output                </h3>                <p className="mt-0.5 text-13 text-muted-foreground">                    Your current fields, filled from a call with Northwind                    Freight.                </p>            </div>            <JsonViewer title="analysis.json" value={output} variant="well" />        </section>    );}

In a tool call

Tool chip opens its input and output in bare viewers inside its own well, each capped at 240px.

The payment for INV-20931 is scheduled for Tuesday's ACH run.

Input2 keys
"invoice_number": "INV-20931",
"vendor_id": "V-10482"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Output5 keys
"payment_id": "PMT-58213",
"status": "scheduled",
"method": "ACH",
"scheduled_run": "2026-09-29",
"remittance": {
"email": "ar@northwindfreight.com",
"sent": false

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

import { ToolChip } from "@oration/canon/components/ai/tool-chip";export function InAToolChip() {    return (        <div className="flex w-full max-w-lg flex-col gap-2 rounded-xl bg-card p-4 shadow-border">            <p className="text-13 text-foreground">                The payment for INV-20931 is scheduled for Tuesday's ACH run.            </p>            <ToolChip                defaultOpen                name="get_payment_status"                label="Payment PMT-58213 is scheduled"                status="done"                duration={610}                input={{ invoice_number: "INV-20931", vendor_id: "V-10482" }}                output={{                    payment_id: "PMT-58213",                    status: "scheduled",                    method: "ACH",                    scheduled_run: "2026-09-29",                    remittance: {                        email: "ar@northwindfreight.com",                        sent: false,                    },                }}            />        </div>    );}

A long array

240 remittance lines open one level deep and reveal 100 at a time. Search for held and every held line shows. maxHeight keeps the block at 320px.

Remittance lines3 keys
"run_id": "run_0925_ach",
"supplier": "Halcyon",
"lines": [
Show 100 more of 140

Arrow keys move between rows and open or close them. C copies a value, P copies its path. Slash searches.

import { JsonViewer } from "@oration/canon/components/json-viewer";export function LongArray() {    const lines = Array.from({ length: 240 }, (_, i) => ({        invoice: `INV-${21000 + i}`,        amount: Math.round((((i * 7919) % 9000) + 400) * 100) / 100,        status: i % 9 === 0 ? "held" : "paid",    }));    return (        <JsonViewer            title="Remittance lines"            value={{ run_id: "run_0925_ach", supplier: "Halcyon", lines }}            defaultExpandDepth={1}            maxHeight={320}            className="w-full max-w-lg"        />    );}

A body that may not be JSON

A webhook response is a string. JSON text is parsed into a tree; anything else renders as written.

Response2 keys
"ok": true,
"received": "evt_8Kp3Vd"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Response
Connection timed out after 10s
import { JsonViewer } from "@oration/canon/components/json-viewer";export function TextResponse() {    return (        <div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">            <JsonViewer                title="Response"                value={'{"ok":true,"received":"evt_8Kp3Vd"}'}            />            <JsonViewer                title="Response"                value="Connection timed out after 10s"            />        </div>    );}

States#

States
StateTreatment
RestPayloads of up to 80 values open fully; larger ones open two levels. defaultExpandDepth overrides both.
Row hoverThe row takes a 4% ink tint and shows Copy path and Copy value at its right end, over a short fade so long values slide under them. Their tooltips show the path and the P and C keys.
Row focusKeyboard focus fills the row with a 6% indigo tint and a 30% inset indigo ring, and keeps its actions showing.
CopiedThe row's copy icon becomes a Ledger Green check for 1.6s and a polite live region announces what was copied.
SearchingA 28px search field opens under the header with the match count, previous and next, and Show only matches. Matching text is marked in a 15% indigo tint; the current match's row takes the 6% row tint and its marks deepen to 30%.
FilteredWith Show only matches on (the default), the tree keeps matches, their ancestors for context and everything inside a matching branch.
No matchesWith the filter on, the body says No keys or values match “vndr”. and offers Clear search. With it off, the count reads No matches and the tree stays whole.
Long arrayBranches past 100 children end in Show 100 more of 140. Searching lifts the limit so every match can show.
TextA string that isn't a JSON document renders as wrapped mono text with only the copy button, as a plain-text error body would. It stays read-only with editable.
Editable hoverKeys and values take a 7% ink box under the pointer with a text cursor; booleans a pointer. The row's actions become Add field below, Delete and a More actions menu.
EditingThe value or key turns into the inline editor with its text selected. The row's comma and actions step aside until it closes.
InvalidThe ring and halo turn Signal Red and the reason sits after the field in 12px red Geist Sans: Enter a number, like 12480, “status” already exists. The field stays open.
New fieldA row with an indigo plus in the chevron column, an empty key field (objects only) and a value field, at the spot it will land.
UnsavedChanged fields carry the indigo dot until baseline catches up, usually on Save.
DeletedThe field leaves at once, focus moves to its neighbor, and a toast offers Undo.

Behavior#

  • Paths are JSONPath: $.vendor.id, $.lines[3].amount, and $["first name"] for keys that aren't identifiers. It's the notation caller ID response mappings already use.
  • Copy value writes a string without its quotes, a number or boolean as written, and an object or array as two-space JSON. The header button copies the whole document.
  • Alt-click a chevron, key or summary to open or close the whole branch beneath it.
  • Search is case-insensitive and matches keys, values and paths. A query with a dot, a bracket or a leading $ also matches by path, so payment.method finds $.data.analysis.payment.method.
  • Branches that hold a match open while you search, without changing what you had open. Closing search keeps the current match's branch open and moves focus to it.
  • A string that starts with { or [ is parsed as JSON; any other string renders as text. Dates serialize through toJSON, undefined and functions drop as JSON.stringify would, and a circular reference shows as [Circular].
  • The tree is translate="no", so browser translation never rewrites keys.
  • The viewer sets aria-live="off" on itself, so folding inside a live region (a test result, a run log) isn't read out row by row.
  • Editing keeps a value's type: an ID typed as 2290 into a string stays the string "2290". A number field accepts 12,480 and saves 12480. Change the type from the chip or the row's Change type menu.
  • New fields and nulls detect their type as you type: true is a boolean, 12480 a number, a pasted {…} or […] an object or array, "…" a string, and anything else text. A leading zero (0042) stays text, so codes keep their zeros. Empty is null.
  • Clicking a boolean flips it. Renaming a key keeps its position. Changing a value to an empty object or array opens a new field inside it.
  • Enter saves, Escape cancels and Tab saves and moves to the next key or value. Clicking away saves too, unless the text is invalid; then the field closes unchanged and the reason is announced.
  • Changing a filled object or array to another type replaces its children, so a toast says how many and offers Undo.
  • Every edit calls onChange(value, change) with the whole new document and { kind, path }. Pass the value back to control it, or don't and the viewer keeps its own copy. A new value from outside replaces the document and clears undo.
  • ⌘Z and ⇧⌘Z undo and redo up to 100 steps while focus is in the viewer, outside a field.
  • Fields edited during a filtered search stay visible until the query changes, even when they no longer match.

Do and don't#

Output4 keys
"payment_id": "PMT-58213",
"status": "scheduled",
"method": "ACH",
"invoices": [
"INV-20931",
"INV-20947"

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Do. Pass the value and let the viewer fold, color and copy paths.
Output
{
  "payment_id": "PMT-58213",
  "status": "scheduled",
  "method": "ACH",
  "invoices": [
    "INV-20931",
    "INV-20947"
  ]
}
Don't. Pass JSON.stringify(value, null, 2) to Code block. People lose folding, search, types and paths, and long payloads grow the sheet.
Output4 keys
"payment_id": "PMT-58213",
"status": "scheduled",
"method": "ACH",

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Do. Use variant="well" inside a card, so the viewer reads as part of it.
Output4 keys
"payment_id": "PMT-58213",
"status": "scheduled",
"method": "ACH",

Arrow keys move between rows and open or close them. C copies a value, P copies its path.

Don't. Drop the default raised viewer into a card. It becomes a card inside a card.

Content#

  • Title the viewer with what the payload is: Request body, Response 200, Event JSON, a file name such as analysis.json, or a content type when that is what people check.
  • Give each viewer on a page a distinct title. It names the copy button (Copy request body) and the search field.
  • Keep status and timing outside the viewer, in a status label beside its title: 200 OK in 212 ms.
  • Show sample data from Cedarline's world, and secrets as environment variables or masked values, never a live key.
  • Editing errors name the fix, not the rule: Enter a number, like 12480, Enter a key, “status” already exists.

Accessibility#

  • The tree is one tab stop with a roving focus (role="tree", treeitem rows with level, position and expanded state). Each row's name is its key and value without punctuation, such as amount: 20625 or payment, object, 3 keys.
  • A visually hidden hint, linked by aria-describedby, explains the arrow keys and the C and P shortcuts.
  • Row copy buttons are pointer shortcuts and stay out of the tab order; C and P do the same from the keyboard. Copies are announced in a polite live region.
  • The search field is labeled after the title, and the match count is announced politely as Match 2 of 5.
  • Closing search returns focus to the current match, or to the search button when there was none.
  • Rows fade in over 150ms only after the first render, and not at all with reduced motion; the chevron turn stops too.
  • Editable rows add unsaved change to their name when they differ from baseline. Editors are labeled Value of amount, Key status, New key and New value, and an error is linked with aria-describedby.
  • Saves, renames, additions, deletions, moves and undo are announced politely. Every row action is in the More actions menu, which Shift+F10 opens, so nothing depends on hover.
Keyboard interactions
KeysAction
TabMoves into the tree at the last focused row.
↓↑Moves to the next or previous row.
→Opens a closed branch, or moves to its first child.
←Closes an open branch, or moves to its parent.
Alt→Opens the whole branch.
HomeEndMoves to the first or last row.
EnterOpens or closes a branch, shows a clipped string in full, or shows the next 100 items.
CCopies the row's value. ⌘C does too when nothing is selected.
PCopies the row's JSONPath.
/Opens search. ⌘F does too while focus is inside the viewer.
EnterIn search, moves to the next match; Shift+Enter moves back.
EscIn search, clears the query, then closes search.
EnterEditable: edits the row's value, or flips a boolean.
F2Editable: renames the row's key.
EnterIn a field, saves. Shift+Enter adds a line to a string; ⌘Enter saves and starts a field below.
TabIn a field, saves and moves to the next key or value; Shift+Tab moves back.
EscIn a field, cancels and returns to the row.
⌘EnterEditable: adds a field below the row.
⇧⌘EnterEditable: adds a field inside an object or array.
DeleteEditable: deletes the row, with Undo.
⌘DEditable: duplicates the row.
Alt↑Editable: moves the row up; Alt+↓ moves it down.
⌘ZEditable: undoes; ⇧⌘Z redoes.
ShiftF10Editable: opens the row's More actions menu.

Design tokens#

Design tokens
TokenUsed for
--surfaceRow Mist behind the default viewer
shadow-borderThe default viewer's hairline lift
--mutedThe well variant at 70%
--borderHeader and search hairlines, indent guides
--foregroundKeys; the row hover and summary chip as ink tints
--muted-foregroundPunctuation, null, title and counts
--tag-green-fgString values
--tag-amber-fgNumber values
--tag-violet-fgBoolean values
--primaryFocused row, current match and search marks
--successThe copied check
--ringThe inline editor's ring and halo
--destructiveAn invalid editor and its message
--backgroundThe inline editor's fill
--font-monoKeys and values

API reference#

JsonViewer

A titled, searchable JSON tree. Takes only the props below.

Props of JsonViewer
PropTypeDefaultDescription
valueRequiredunknownNo defaultThe data to show. A string that starts with { or [ is parsed; any other string renders as text.
titlestring"JSON"Shown in the header; names the copy button and the search field.
variant"surface" | "well" | "bare""surface"surface lifts like Code block, for sheets and pages. well is a flat 70% Well Gray region for inside cards. bare has no fill or header rule, for a section inside another well.
defaultExpandDepthnumberNo defaultLevels open on mount; 0 shows only the top-level keys. Defaults to everything for up to 80 values and 2 beyond that.
maxHeightnumber | stringNo defaultCaps the tree's height; it scrolls inside the viewer past that.
editableboolean | { keys?: boolean; structure?: boolean; types?: boolean }falseTurns on inline editing. true allows everything. An object turns parts off: keys (rename), structure (add, delete, duplicate, move) and types (the type chip and Change type), each true unless set false.
onChange(value: unknown, change: JsonViewerChange) => voidNo defaultCalled with the whole new document after every edit, undo and redo.
baselineunknownNo defaultThe saved document. Fields that differ from it carry the unsaved-changes dot.
classNamestringNo defaultMerged onto the outer container, for width and margins.

JsonViewerChange

The second argument to onChange.

Props of JsonViewerChange
PropTypeDefaultDescription
kindRequired"edit" | "rename" | "add" | "remove" | "move" | "undo" | "redo"No defaultWhat happened.
pathRequiredstringNo defaultJSONPath of the field after the change, such as $.invoices[2]; $ for undo and redo.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Rows aren't virtualized. Branches reveal 100 children at a time, but Expand all on a very large document still renders every row.

There's no raw text view; select across rows or use the header copy button for the exact JSON.

Array items show no index. The index is in the row's path tooltip and its accessible name.

Rows don't drag to reorder; use Move up and Move down or Alt+↑ and Alt+↓.

Editing doesn't check the document against a schema. Use editable={{ keys: false, structure: false }} when the shape is fixed.