Skip to content
Brok UI

Loading…

No results

JSON Viewer

Open source

An interactive, collapsible JSON tree with line numbers, colour-coded values and array truncation.

Version
v1.0.3
Stability
stable
License
MIT
Related
Diff Viewer
CSV Viewer
PDF Viewer

Preview

package.json
previews.components.json-viewer.default.blade.php 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" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/json-viewer.blade.php
  • js resources/js/ui/json-viewer.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

json-viewer.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

json-viewer

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

Scannable data display

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
root
Theming hooks
json-viewer

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/json-viewer.blade.php Blade
@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>
resources/js/ui/json-viewer.js JS
/**
 * 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