Skip to content

JsonViewer

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/json-viewer.json
See it liveA webhook payload with a thousand-entry array in it, a document where a number arrived as a string, and one that is nothing but a null.→

What to reach for when a product has to show a response, a settings file, a payload — data the reader needs to understand, not edit. The alternative it replaces is JSON.stringify(value, null, 2) in a <pre>, which is fine for twenty lines and useless for two hundred: nothing folds, nothing is findable, and the shape of the document is somewhere inside the indentation.

<JsonViewer
value={payload}
label={t('webhook.payload')}
onActivate={(row) => copy(row.path)}
/>

It opens what it can show, not everything two levels down. A branch opens on arrival when it is within two levels and holds at most twenty entries. The size half is the half that matters: a 1204-entry array at the top level is inside any depth bound worth having, and under depth alone this viewer rendered 1216 rows before the reader had touched it. branchPaths(value, { depth, size }) in json-rows is there when you want different bounds.

A closed branch says how many it holds, rather than previewing its contents: a preview of the first entries reads as though those are all of them.

A string is quoted and a number is not, and that is not decoration. "1" and 1 are different values, and a number that arrived as a string is the commonest bug in any payload. The quotes say so to every reader; the --syntax-* colour says it faster to those who see it. Never the colour alone.

null is drawn as the word. A key present and null is not a key missing, and an empty cell says the second when the data says the first.

One tab stop, and the arrows move a cursor inside it. A document of four hundred rows with a tabIndex on each is four hundred stops between whatever is above it and whatever is below — the arrangement a radio group has, and for the same reason.

Key
↓ ↑ The next and previous visible row
→ Open a closed branch; step into an open one; nothing on a leaf
← Close an open branch; otherwise go to the parent
Home End The first and last row
Enter Space Open or close, and report it

→ does nothing on a leaf rather than falling through to the next row, which would make it a second ↓ and lose the reader their place in the nesting. ← on a leaf goes to the parent, which is how a reader gets out of a deep branch without walking back up through every sibling. Both come from the ARIA tree pattern, and both are what a hand-rolled tree leaves out after implementing Up and Down.

onActivate hands back the whole row, including its path — $.items[3].name — which is what “copy this value” and “copy this path” hang on. Keys that dot notation would break are bracketed, so the path resolves: $["user name"], and $["a.b"] for the single key a.b, which written as $.a.b would silently point at something else.

It is not TreeView with JSON poured in. A row here is a key and a value, and TreeView’s row is one ReactNode label — with JSON inside it, nothing can colour the value by type, tell an index from a name, or offer the path. What the two genuinely share is the arithmetic of which rows are visible, and that is shared: json-rows is the sibling of tree-rows.

Props

Read from the types in ui/json-viewer.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

PropTypeDefault
valuerequiredJsonValueThe document to show: anything JSON.parse returns.
openReadonlySet<string>Which branches are open, by path. Uncontrolled if omitted, starting with the top two levels open (a branch of twenty entries or more stays shut) - the shape of an answer rather than the whole document.
onOpenChange(open: Set<string>) => voidCalled with the new set of open paths whenever a branch is opened or closed. Pair it with open to control the viewer.
onActivate(row: JsonRow) => voidTold which row was activated, with its path and its value. What a product hangs "copy this" or "go to this setting" on.
labelrequiredstringWhat a screen reader calls it. Required, and without a default: a string this component invents is a string the product cannot translate.
rootstring'$'The path the document starts at, for a viewer showing one field of a larger record. Paths are then written so they still mean something in that record.
classNamestring

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 19 rules applied and passed, and none failed. The accessibility report has every item.

Left to a person, where axe could not decide: color-contrast.

History

Each release that changed ui/json-viewer.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.39.0
    • fix draw only promised inks on fills 6ccd89f
  3. v0.25.0
    • feat markdown through one stylesheet, and code whose colours are yours 5f27eb8