JSON Viewer
An interactive, collapsible JSON tree with line numbers, colour-coded values and array truncation.
Preview
@php
$data = [
'name' => 'jml/brok',
'version' => '1.1.0',
'open' => true,
'components' => ['button', 'timeline', 'heatmap', 'flip-clock'],
'meta' => [
'license' => 'MIT',
'downloads' => 1280,
'nested' => ['level' => 2, 'items' => [1, 2, 3]],
],
];
@endphp
<x-ui.json-viewer :data="$data" title="package.json" :default-expanded="2" class="w-full max-w-md" />
Installation
php artisan ui:add json-viewer
Note
This component ships an Alpine behavior module at
resources/js/ui/json-viewer.js. Import it once from your bundle so it registers on alpine:init:
import './json-viewer.js';
Registry contract
php artisan ui:add json-viewer
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/json-viewer.blade.php -
resources/js/ui/json-viewer.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: JSON Viewer (`json-viewer`)
An interactive, collapsible JSON tree with line numbers, colour-coded values and array truncation.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add json-viewer
```
## Usage
```blade
@php
$data = [
'name' => 'jml/brok',
'version' => '1.1.0',
'open' => true,
'components' => ['button', 'timeline', 'heatmap', 'flip-clock'],
'meta' => [
'license' => 'MIT',
'downloads' => 1280,
'nested' => ['level' => 2, 'items' => [1, 2, 3]],
],
];
@endphp
<x-ui.json-viewer :data="$data" title="package.json" :default-expanded="2" class="w-full max-w-md" />
```
## Props
- `data` (array, default `[]`) — JSON-serializable value to render as an interactive tree.
- `title` (mixed|null, default `null`) — Optional header label shown above the tree.
- `showLineNumbers` (bool, default `true`) — Shows a line-number gutter beside each row.
- `showColorIndent` (bool, default `false`) — Colors the indentation guides to make nesting depth easier to scan.
- `collapseOn` (string, default `click`) — Interaction that toggles a node open or closed, such as click.
- `defaultExpanded` (bool, default `false`) — Whether nodes start expanded instead of collapsed.
- `truncation` (mixed|null, default `null`) — Maximum array items shown before the rest collapse behind a count.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Displaying an arbitrary JSON value, such as an API response or config object, as an interactive, collapsible tree.
- Letting a developer or support user inspect a payload with line numbers or truncated arrays.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The data has a small, fixed shape meant for end users; render it with table or stat instead of a raw tree.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:json-viewer>` tag (or `<x-ui.json-viewer>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/json-viewer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/json-viewer.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | array | [] | JSON-serializable value to render as an interactive tree. |
| title | mixed | null | null | Optional header label shown above the tree. |
| showLineNumbers | bool | true | Shows a line-number gutter beside each row. |
| showColorIndent | bool | false | Colors the indentation guides to make nesting depth easier to scan. |
| collapseOn | string | click | Interaction that toggles a node open or closed, such as click. |
| defaultExpanded | bool | false | Whether nodes start expanded instead of collapsed. |
| truncation | mixed | null | null | Maximum array items shown before the rest collapse behind a count. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders the tree client-side from the data prop; nodes collapse and expand on the collapseOn interaction, click by default.
- defaultExpanded controls the tree's initial open state, and truncation caps how many array items render before the rest collapse behind a count.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Displaying an arbitrary JSON value, such as an API response or config object, as an interactive, collapsible tree.
- Letting a developer or support user inspect a payload with line numbers or truncated arrays.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The data has a small, fixed shape meant for end users; render it with table or stat instead of a raw tree.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The rendered tree runs left-to-right (dir="ltr") regardless of page direction, since JSON syntax itself is not bidi-aware.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
@php
$data = [
'name' => 'jml/brok',
'version' => '1.1.0',
'open' => true,
'components' => ['button', 'timeline', 'heatmap', 'flip-clock'],
'meta' => [
'license' => 'MIT',
'downloads' => 1280,
'nested' => ['level' => 2, 'items' => [1, 2, 3]],
],
];
@endphp
<x-ui.json-viewer :data="$data" title="package.json" :default-expanded="2" class="w-full max-w-md" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'data' => [],
'title' => null,
'showLineNumbers' => true,
'showColorIndent' => false,
'collapseOn' => 'click',
'defaultExpanded' => false,
'truncation' => null,
])
@php
// The tree is rendered client-side from this config so collapse/expand and
// array truncation stay interactive; values are emitted as JSON via @js.
$config = [
'data' => $data,
'showLineNumbers' => (bool) $showLineNumbers,
'showColorIndent' => (bool) $showColorIndent,
'collapseOn' => $collapseOn,
'defaultExpanded' => $defaultExpanded,
'truncation' => $truncation,
];
@endphp
<div
x-data="uiJsonViewer(@js($config))"
data-slot="json-viewer"
{{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-border bg-card text-card-foreground']) }}
>
@if ($title)
<div class="border-b border-border bg-muted/40 px-4 py-2 text-sm font-medium text-foreground">{{ $title }}</div>
@endif
<div x-ref="body" class="overflow-auto p-4 font-mono text-sm leading-relaxed" dir="ltr"></div>
</div>
/**
* JSON viewer behavior (spec §19 behavior layer).
*
* Renders an interactive, collapsible tree from a plain JS value passed in via
* x-data. Objects/arrays collapse on click (or double-click); long arrays are
* truncated with an inline "show more" row. Line numbers are recomputed from
* the currently-visible rows on every toggle so the gutter stays contiguous.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiJsonViewer', (config = {}) => ({
data: config.data,
showLineNumbers: config.showLineNumbers ?? true,
showColorIndent: config.showColorIndent ?? false,
collapseOn: config.collapseOn || 'click',
defaultExpanded: config.defaultExpanded ?? false,
truncation: Object.assign({ enabled: true, itemsPerArray: 5 }, config.truncation || {}),
indentColors: ['border-primary/50', 'border-success/50', 'border-info/50', 'border-warning/50', 'border-destructive/50'],
init() {
this.body = this.$refs.body;
this.render();
},
maxDepth() {
if (typeof this.defaultExpanded === 'number') return this.defaultExpanded;
return this.defaultExpanded ? Infinity : 1;
},
render() {
this.body.innerHTML = '';
const frag = document.createDocumentFragment();
this.build(frag, null, this.data, 0, true);
this.body.appendChild(frag);
this.renumber();
},
// ── Element builders ────────────────────────────────────────────────
makeRow(depth) {
const row = document.createElement('div');
row.className = 'ui-json-row flex items-start';
const gutter = document.createElement('span');
gutter.className = 'ui-json-gutter w-10 shrink-0 select-none pe-3 text-end text-muted-foreground'
+ (this.showLineNumbers ? '' : ' hidden');
const content = document.createElement('span');
content.className = 'ui-json-content whitespace-pre-wrap break-words';
content.style.paddingInlineStart = (depth * 1.25) + 'rem';
row.append(gutter, content);
return { row, content };
},
span(text, cls) {
const s = document.createElement('span');
if (cls) s.className = cls;
s.textContent = text;
return s;
},
keySpan(key) {
if (key === null) return null;
const wrap = document.createElement('span');
wrap.append(this.span(JSON.stringify(key), 'text-foreground font-medium'), this.span(': ', 'text-muted-foreground'));
return wrap;
},
valueSpan(value) {
const t = value === null ? 'null' : typeof value;
const map = {
string: 'text-success',
number: 'text-info',
boolean: 'text-warning',
null: 'text-destructive',
};
const text = t === 'string' ? JSON.stringify(value) : String(value);
return this.span(text, map[t] || 'text-foreground');
},
// ── Recursive build ─────────────────────────────────────────────────
build(parent, key, value, depth, isLast) {
const isArr = Array.isArray(value);
const isObj = value !== null && typeof value === 'object';
if (!isObj) {
const { row, content } = this.makeRow(depth);
const ks = this.keySpan(key);
if (ks) content.append(ks);
content.append(this.valueSpan(value));
if (!isLast) content.append(this.span(',', 'text-muted-foreground'));
parent.append(row);
return;
}
const entries = isArr ? value.map((v, i) => [i, v]) : Object.entries(value);
const open = isArr ? '[' : '{';
const close = isArr ? ']' : '}';
const expanded = depth < this.maxDepth();
// Opening row
const { row: openRow, content: openContent } = this.makeRow(depth);
const caret = this.span(expanded ? '▾' : '▸', 'ui-json-caret me-1 inline-block w-3 cursor-pointer text-muted-foreground transition-transform');
openContent.append(caret);
const ks = this.keySpan(key);
if (ks) openContent.append(ks);
openContent.append(this.span(open, 'text-muted-foreground'));
const preview = this.span(` … ${entries.length} ${isArr ? 'items' : 'keys'} ${close}${isLast ? '' : ','}`, 'ui-json-preview text-muted-foreground');
preview.classList.toggle('hidden', expanded);
openContent.append(preview);
parent.append(openRow);
// Children
const children = document.createElement('div');
children.className = 'ui-json-children';
if (this.showColorIndent) {
children.className += ' border-s-2 ms-2 ' + this.indentColors[depth % this.indentColors.length];
}
children.classList.toggle('hidden', !expanded);
const limit = this.truncation.enabled ? this.truncation.itemsPerArray : Infinity;
const shown = isArr && entries.length > limit ? entries.slice(0, limit) : entries;
shown.forEach(([k, v], i) => {
const last = i === entries.length - 1;
this.build(children, isArr ? null : k, v, depth + 1, last && !(isArr && entries.length > limit));
});
if (isArr && entries.length > limit) {
const { row, content } = this.makeRow(depth + 1);
const more = this.span(`… ${entries.length - limit} more`, 'cursor-pointer rounded bg-muted px-1.5 text-xs text-muted-foreground hover:text-foreground');
more.addEventListener('click', () => {
const frag = document.createDocumentFragment();
entries.slice(limit).forEach(([_key, v], i) => {
this.build(frag, null, v, depth + 1, i === entries.length - limit - 1);
});
children.replaceChild(frag, row);
this.renumber();
});
content.append(more);
children.append(row);
}
parent.append(children);
// Closing row
const { row: closeRow, content: closeContent } = this.makeRow(depth);
closeContent.append(this.span(close + (isLast ? '' : ','), 'text-muted-foreground'));
closeRow.classList.toggle('hidden', !expanded);
parent.append(closeRow);
// Toggle
const evt = this.collapseOn === 'doubleClick' ? 'dblclick' : 'click';
const toggle = () => {
const nowExpanded = children.classList.contains('hidden');
children.classList.toggle('hidden', !nowExpanded);
closeRow.classList.toggle('hidden', !nowExpanded);
preview.classList.toggle('hidden', nowExpanded);
caret.textContent = nowExpanded ? '▾' : '▸';
this.renumber();
};
caret.addEventListener('click', (e) => { e.stopPropagation(); toggle(); });
openRow.style.cursor = 'pointer';
openRow.addEventListener(evt, toggle);
},
// Renumber the visible gutters (rows inside a collapsed branch are skipped).
renumber() {
if (!this.showLineNumbers) return;
let n = 0;
this.body.querySelectorAll('.ui-json-row').forEach((row) => {
const gutter = row.querySelector('.ui-json-gutter');
if (row.closest('.ui-json-children.hidden')) {
gutter.textContent = '';
} else {
gutter.textContent = ++n;
}
});
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0