JSON-Ansicht
JSON, das man lesen kann. Ein Baum, der sich per Tastatur öffnet und schließt, Anzahlen an geschlossenen Zweigen, Werte nach Typ getönt, und ein Klick auf einen Schlüssel kopiert seinen Pfad.
Eine von 2 Components in Code und JSON
Variants
Details
Was JSON-Ansicht mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Code und JSON
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/json-viewer.tsx
- Bringt mit
- utilskiticoncopy-button
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 21 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle JsonViewerProps aus components/ui/json-viewer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalJsonViewerVarianttreetablesearchresponse
dataPflichtJson | stringThe data, as a value or as JSON text
labeloptionalstringAccessible name and the title above
depthoptionalnumberLevels open at the start. Default 2.
defaultQueryoptionalstringsearch: a query at the start, for demos
requestoptionalstringresponse: the request line, e.g. "GET /v1/orders/4821"
statusoptionalnumberresponse: HTTP status code
statusTextoptionalstringresponse: status text, e.g. "OK"
durationoptionalnumberresponse: duration in ms
headersoptionalRecord<string, string>response: response headers
Eingebaute Texte
Diese Texte schreibt JSON-Ansicht selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 21 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| json | Englisch JSON data | Deutsch JSON-Daten |
| items | Englisch items | Deutsch Einträge |
| keys | Englisch keys | Deutsch Schlüssel |
| expandAll | Englisch Open all | Deutsch Alle öffnen |
| collapseAll | Englisch Close all | Deutsch Alle schließen |
| copy | Englisch Copy JSON | Deutsch JSON kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| copyPath | Englisch Copy path | Deutsch Pfad kopieren |
| pathCopied | Englisch Path copied | Deutsch Pfad kopiert |
| keyHint | Englisch Click a key to copy its path | Deutsch Einen Schlüssel anklicken kopiert seinen Pfad |
| view | Englisch View | Deutsch Ansicht |
| search | Englisch Search keys and values | Deutsch Schlüssel und Werte suchen |
| matches | Englisch matches | Deutsch Treffer |
| noMatch | Englisch Nothing matches. Try a key name or a value. | Deutsch Nichts gefunden. Versuch einen Schlüssel oder einen Wert. |
| table | Englisch Table | Deutsch Tabelle |
| raw | Englisch Tree | Deutsch Baum |
| body | Englisch Body | Deutsch Inhalt |
| headers | Englisch Headers | Deutsch Header |
| status | Englisch Status | Deutsch Status |
| time | Englisch Time | Deutsch Zeit |
| size | Englisch Size | Deutsch Größe |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- JSON-Ansicht reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-checked, aria-expanded, aria-label, aria-level, aria-live, aria-posinset und aria-setsize, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, radio, radiogroup, region, tree und treeitem.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 531 Zeilen
'use client';
import { useId, useMemo, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { useCopy } from '@/components/ui/copy-button';
/*
* JSON you can read: a tree that opens and closes with the tree pattern of
* the APG (arrows, Home, End, * opens siblings), values tinted by type in
* the accent of the project, counts on closed branches, a click on a key
* copies its path ($.order.items[0].price). Four looks: the tree, an array
* as a table, the tree with search that opens the matching paths, and an API
* response with status, timing and headers. One typeface, no monospace:
* digits are tabular and the indent is a fixed width per level.
*/
const TEXTS = {
en: {
json: 'JSON data', items: 'items', keys: 'keys', expandAll: 'Open all', collapseAll: 'Close all', copy: 'Copy JSON', copied: 'Copied', copyPath: 'Copy path', pathCopied: 'Path copied', keyHint: 'Click a key to copy its path', view: 'View',
search: 'Search keys and values', matches: 'matches', noMatch: 'Nothing matches. Try a key name or a value.', table: 'Table', raw: 'Tree', body: 'Body', headers: 'Headers', status: 'Status', time: 'Time', size: 'Size',
},
de: {
json: 'JSON-Daten', items: 'Einträge', keys: 'Schlüssel', expandAll: 'Alle öffnen', collapseAll: 'Alle schließen', copy: 'JSON kopieren', copied: 'Kopiert', copyPath: 'Pfad kopieren', pathCopied: 'Pfad kopiert', keyHint: 'Einen Schlüssel anklicken kopiert seinen Pfad', view: 'Ansicht',
search: 'Schlüssel und Werte suchen', matches: 'Treffer', noMatch: 'Nichts gefunden. Versuch einen Schlüssel oder einen Wert.', table: 'Tabelle', raw: 'Baum', body: 'Inhalt', headers: 'Header', status: 'Status', time: 'Zeit', size: 'Größe',
},
};
type Texts = (typeof TEXTS)['en'];
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
const isBranch = (v: Json): v is Json[] | { [k: string]: Json } => v !== null && typeof v === 'object';
const childPath = (path: string, key: string | number) => (typeof key === 'number' ? `${path}[${key}]` : /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}["${key}"]`);
const entries = (v: Json[] | { [k: string]: Json }): [string | number, Json][] => (Array.isArray(v) ? v.map((x, i) => [i, x]) : Object.entries(v));
/** Every branch path, for open all */
function branchPaths(v: Json, path = '$', out: string[] = []) {
if (isBranch(v)) {
out.push(path);
for (const [k, c] of entries(v)) branchPaths(c, childPath(path, k), out);
}
return out;
}
/** Paths of the branches that hold a match, so the search can open them */
function searchPaths(v: Json, q: string, path = '$', key: string | number = '', hits = new Set<string>(), open = new Set<string>()): { hits: Set<string>; open: Set<string> } {
const matchKey = typeof key === 'string' && key.toLowerCase().includes(q);
const matchVal = !isBranch(v) && String(v).toLowerCase().includes(q);
if (q && (matchKey || matchVal)) {
hits.add(path);
// open every parent on the way
let p = path;
while (p.length > 1) {
p = p.replace(/(\.[^.[\]]+|\[[^\]]+\])$/, '');
open.add(p || '$');
}
}
if (isBranch(v)) for (const [k, c] of entries(v)) searchPaths(c, q, childPath(path, k), k, hits, open);
return { hits, open };
}
function ValueText({ v }: { v: Json }) {
if (v === null) return <span className="italic text-subtle-foreground">null</span>;
if (typeof v === 'string') return <span className="break-all text-[color-mix(in_oklab,var(--primary)_42%,var(--muted-foreground))]">"{v}"</span>;
if (typeof v === 'number') return <span className="tabular-nums text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{v}</span>;
if (typeof v === 'boolean') return <span className="font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{String(v)}</span>;
return null;
}
/** Marks a search hit inside a text */
function Mark({ text, q }: { text: string; q: string }) {
if (!q) return <>{text}</>;
const i = text.toLowerCase().indexOf(q);
if (i < 0) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--warning)_45%,transparent)] text-inherit">{text.slice(i, i + q.length)}</mark>
{text.slice(i + q.length)}
</>
);
}
interface TreeProps {
data: Json;
t: Texts;
open: Set<string>;
setOpen: (s: Set<string>) => void;
q?: string;
hits?: Set<string>;
label?: string;
onCopyPath?: (path: string) => void;
}
/** The tree itself, with roving focus over the visible rows */
function JsonTree({ data, t, open, setOpen, q = '', hits, label, onCopyPath }: TreeProps) {
const base = useId();
const [focus, setFocus] = useState('$');
// The visible rows in order, for the keyboard
const rows = useMemo(() => {
const out: { path: string; parent: string | null; branch: boolean }[] = [];
const walk = (v: Json, path: string, parent: string | null) => {
out.push({ path, parent, branch: isBranch(v) });
if (isBranch(v) && open.has(path)) for (const [k, c] of entries(v)) walk(c, childPath(path, k), path);
};
walk(data, '$', null);
return out;
}, [data, open]);
const idOf = (path: string) => `${base}-${path.replace(/[^\w]/g, '_')}`;
const go = (path: string) => {
setFocus(path);
requestAnimationFrame(() => document.getElementById(idOf(path))?.focus());
};
const toggle = (path: string, to?: boolean) => {
const next = new Set(open);
const want = to ?? !open.has(path);
if (want) next.add(path);
else next.delete(path);
setOpen(next);
};
const onKey = (e: React.KeyboardEvent, path: string) => {
const i = rows.findIndex((r) => r.path === path);
const r = rows[i];
const k = e.key;
if (k === 'ArrowDown' && rows[i + 1]) go(rows[i + 1].path);
else if (k === 'ArrowUp' && rows[i - 1]) go(rows[i - 1].path);
else if (k === 'Home') go(rows[0].path);
else if (k === 'End') go(rows[rows.length - 1].path);
else if (k === 'ArrowRight' && r.branch) open.has(path) ? rows[i + 1] && go(rows[i + 1].path) : toggle(path, true);
else if (k === 'ArrowLeft') r.branch && open.has(path) ? toggle(path, false) : r.parent && go(r.parent);
else if ((k === 'Enter' || k === ' ') && r.branch) toggle(path);
else if (k === 'c' && (e.metaKey || e.ctrlKey) === false) onCopyPath?.(path);
else if (k === '*' && r.parent) {
const sib = rows.filter((x) => x.parent === r.parent && x.branch).map((x) => x.path);
setOpen(new Set([...open, ...sib]));
} else return;
e.preventDefault();
};
const node = (v: Json, path: string, key: string | number | null, level: number, last: boolean, pos: number, size: number): React.ReactNode => {
const branch = isBranch(v);
const isOpen = branch && open.has(path);
const list = branch ? entries(v) : [];
const brackets = Array.isArray(v) ? ['[', ']'] : ['{', '}'];
const hit = hits?.has(path);
const comma = last ? null : <span className="text-subtle-foreground">,</span>;
return (
<li
key={path}
id={idOf(path)}
role="treeitem"
aria-level={level}
aria-setsize={size}
aria-posinset={pos}
aria-expanded={branch ? isOpen : undefined}
tabIndex={focus === path ? 0 : -1}
onFocus={(e) => e.target === e.currentTarget && setFocus(path)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
e.stopPropagation();
onKey(e, path);
}}
className="group/row outline-none"
>
<div
onClick={() => {
setFocus(path);
if (branch) toggle(path);
}}
className={cn(
'relative flex min-h-7 cursor-default items-start rounded-md py-0.5 pe-2 text-[13.5px] leading-6 transition-colors duration-150 hover:bg-accent group-focus-visible/row:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] max-sm:min-h-11 max-sm:py-2.5',
hit && 'bg-[color-mix(in_oklab,var(--warning)_10%,transparent)]',
)}
style={{ paddingInlineStart: 6 + (level - 1) * 18 }}
>
<span aria-hidden className="mt-1 grid size-4 shrink-0 place-items-center text-subtle-foreground">
{branch && <Icon name="chevron-right" size={13} strokeWidth={2} className={cn('transition-[rotate] duration-200', isOpen && 'rotate-90')} />}
</span>
<span className="ms-1 min-w-0 flex-1">
{key !== null && (
<>
<button
type="button"
tabIndex={-1}
title={t.copyPath}
onClick={(e) => {
e.stopPropagation();
onCopyPath?.(path);
}}
className={cn('rounded-[3px] font-medium hover:underline hover:decoration-dotted hover:underline-offset-4', typeof key === 'number' ? 'tabular-nums text-subtle-foreground' : 'text-foreground')}
>
{typeof key === 'number' ? key : <Mark text={key} q={q} />}
</button>
<span className="text-subtle-foreground">: </span>
</>
)}
{branch ? (
<>
<span className="text-subtle-foreground">{brackets[0]}</span>
{!isOpen && (
<>
<span className="mx-1 rounded bg-muted px-1.5 text-[11.5px] tabular-nums text-muted-foreground">
{list.length} {Array.isArray(v) ? t.items : t.keys}
</span>
<span className="text-subtle-foreground">{brackets[1]}</span>
{comma}
</>
)}
</>
) : (
<>
{typeof v === 'string' && q ? <span className="break-all text-[color-mix(in_oklab,var(--primary)_42%,var(--muted-foreground))]">"<Mark text={v} q={q} />"</span> : <ValueText v={v} />}
{comma}
</>
)}
</span>
</div>
{branch && isOpen && (
<>
<ul role="group" className="relative">
{/* The guide line of the level */}
<span aria-hidden className="pointer-events-none absolute bottom-1 top-0 w-px bg-border" style={{ insetInlineStart: 6 + (level - 1) * 18 + 7.5 }} />
{list.map(([k, c], j) => node(c, childPath(path, k), k, level + 1, j === list.length - 1, j + 1, list.length))}
</ul>
<div className="text-[13.5px] leading-6 text-subtle-foreground" style={{ paddingInlineStart: 6 + (level - 1) * 18 + 20 }}>
{brackets[1]}
{comma}
</div>
</>
)}
</li>
);
};
return (
<ul role="tree" aria-label={label ?? t.json} className="kitfx-json">
{node(data, '$', null, 1, true, 1, 1)}
</ul>
);
}
export type JsonViewerVariant = 'tree' | 'table' | 'search' | 'response';
export interface JsonViewerProps {
variant?: JsonViewerVariant;
/** The data, as a value or as JSON text */
data: Json | string;
/** Accessible name and the title above */
label?: string;
/** Levels open at the start. Default 2. */
depth?: number;
/** search: a query at the start, for demos */
defaultQuery?: string;
/** response: the request line, e.g. "GET /v1/orders/4821" */
request?: string;
/** response: HTTP status code */
status?: number;
/** response: status text, e.g. "OK" */
statusText?: string;
/** response: duration in ms */
duration?: number;
/** response: response headers */
headers?: Record<string, string>;
labels?: Partial<Texts>;
className?: string;
}
/**
* JSON as a tree, a table, a searchable tree or an API response.
* <JsonViewer label="order.json" data={order} depth={2} />
* <JsonViewer variant="table" data={rows} />
* <JsonViewer variant="search" data={config} defaultQuery="timeout" />
* <JsonViewer variant="response" request="GET /v1/orders/4821" status={200} duration={182} data={order} headers={…} />
*/
export function JsonViewer({ variant = 'tree', data: raw, label, depth = 2, defaultQuery = '', request, status = 200, statusText, duration, headers, labels, className }: JsonViewerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const data = useMemo<Json>(() => {
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw) as Json;
} catch {
return raw;
}
}, [raw]);
const startOpen = useMemo(() => {
const s = new Set<string>();
const walk = (v: Json, path: string, level: number) => {
if (!isBranch(v) || level > depth) return;
s.add(path);
for (const [k, c] of entries(v)) walk(c, childPath(path, k), level + 1);
};
walk(data, '$', 1);
return s;
}, [data, depth]);
const [open, setOpen] = useState<Set<string>>(startOpen);
const [query, setQuery] = useState(defaultQuery);
const [view, setView] = useState<'table' | 'tree'>('table');
const [tab, setTab] = useState<'body' | 'headers'>('body');
const [copyState, copy] = useCopy();
const [pathState, copyPath] = useCopy();
const [lastPath, setLastPath] = useState('');
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
const q = query.trim().toLowerCase();
const found = useMemo(() => (q ? searchPaths(data, q) : null), [data, q]);
const shownOpen = found ? new Set([...open, ...found.open]) : open;
const bytes = new TextEncoder().encode(JSON.stringify(data)).length;
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 1 })} kB`);
const onCopyPath = (p: string) => {
setLastPath(p);
copyPath(p);
};
const toolbar = (extra?: React.ReactNode, tree = true) => (
<div className="flex min-h-12 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5">
{extra}
<span className="ms-auto flex items-center gap-0.5">
{tree && <ToolButton icon="maximize" label={t.expandAll} onClick={() => setOpen(new Set(branchPaths(data)))} />}
{tree && <ToolButton icon="minimize" label={t.collapseAll} onClick={() => setOpen(new Set(['$']))} />}
<ToolButton icon={copyState === 'copied' ? 'check' : 'copy'} label={copyState === 'copied' ? t.copied : t.copy} onClick={() => copy(text)} />
</span>
</div>
);
const pathLine = (
<p aria-live="polite" className="flex min-h-9 items-center gap-2 border-t border-border px-4 text-[12px] text-subtle-foreground">
{pathState === 'copied' ? (
<>
<Icon name="check" size={13} className="text-success" />
<span>{t.pathCopied}</span>
<span className="truncate font-medium text-foreground">{lastPath}</span>
</>
) : (
<span>{t.keyHint}</span>
)}
</p>
);
const shell = (children: React.ReactNode) => <div className={cn('w-full overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>{children}</div>;
const treeBox = (h = 'max-h-[26rem]') => (
<div className={cn('kit-scrollbar relative overflow-auto p-2', h)}>
<JsonTree data={data} t={t} open={shownOpen} setOpen={setOpen} q={q} hits={found?.hits} label={label} onCopyPath={onCopyPath} />
</div>
);
/* ---------- table: an array of objects ---------- */
if (variant === 'table') {
const rows = Array.isArray(data) ? (data.filter((r) => isBranch(r) && !Array.isArray(r)) as { [k: string]: Json }[]) : [];
const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
return shell(
<>
{toolbar(
<>
{label && <span className="me-2 text-[13px] font-semibold text-foreground">{label}</span>}
<Segmented label={t.view} value={view} onChange={setView} options={[['table', t.table], ['tree', t.raw]]} />
<span className="text-[12px] tabular-nums text-subtle-foreground">
{rows.length} {t.items}
</span>
</>,
view === 'tree',
)}
{view === 'table' ? (
<>
{/* Phones get a card per row, larger screens the table */}
<ul className="grid gap-px bg-border sm:hidden">
{rows.map((r, i) => (
<li key={i} className="bg-card px-4 py-3">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-[13px]">
{cols.map((c) => (
<div key={c} className="contents">
<dt className="text-muted-foreground">{c}</dt>
<dd className="min-w-0 truncate text-end">{r[c] === undefined ? <span className="text-subtle-foreground">·</span> : isBranch(r[c]) ? <span className="rounded bg-muted px-1.5 text-[11.5px] text-muted-foreground">{Array.isArray(r[c]) ? `[${(r[c] as Json[]).length}]` : '{…}'}</span> : <ValueText v={r[c]} />}</dd>
</div>
))}
</dl>
</li>
))}
</ul>
<div tabIndex={0} role="region" aria-label={label ?? t.json} className="kit-scrollbar overflow-x-auto outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)] max-sm:hidden">
<table className="w-full min-w-[34rem] text-left text-[13px]">
<caption className="sr-only">{label ?? t.json}</caption>
<thead>
<tr className="border-b border-border">
{cols.map((c) => (
<th key={c} scope="col" className="whitespace-nowrap px-4 py-2.5 text-[12px] font-medium text-muted-foreground">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b border-border transition-colors last:border-b-0 hover:bg-accent/60">
{cols.map((c) => (
<td key={c} className="whitespace-nowrap px-4 py-2.5 align-top">
{r[c] === undefined ? <span className="text-subtle-foreground">·</span> : isBranch(r[c]) ? <span className="rounded bg-muted px-1.5 text-[11.5px] text-muted-foreground">{Array.isArray(r[c]) ? `[${(r[c] as Json[]).length}]` : '{…}'}</span> : <ValueText v={r[c]} />}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
treeBox()
)}
</>,
);
}
/* ---------- search ---------- */
if (variant === 'search') {
const count = found?.hits.size ?? 0;
return shell(
<>
<div className="flex items-center gap-2 border-b border-border px-3">
<Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
<input
type="search"
aria-label={t.search}
placeholder={t.search}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-12 w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-sm [&::-webkit-search-cancel-button]:hidden"
/>
{q && (
<span key={count} aria-live="polite" className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[12px] tabular-nums text-muted-foreground transition-[scale] duration-200 starting:scale-110">
{count} {t.matches}
</span>
)}
</div>
{q && count === 0 ? <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t.noMatch}</p> : treeBox()}
{pathLine}
</>,
);
}
/* ---------- response: an API answer ---------- */
if (variant === 'response') {
const ok = status < 300;
const warn = status >= 400 && status < 500;
return shell(
<>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-4 py-3">
{request && (
<span className="flex min-w-0 items-center gap-2 text-[13px]">
<span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 py-0.5 text-[11px] font-semibold tracking-[0.04em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{request.split(' ')[0]}</span>
<span className="truncate font-medium text-foreground">{request.split(' ').slice(1).join(' ')}</span>
</span>
)}
<span className="ms-auto flex items-center gap-3 text-[12px] tabular-nums text-muted-foreground">
<span className="flex items-center gap-1.5">
<span aria-hidden className={cn('relative size-2 rounded-full', ok ? 'bg-success' : warn ? 'bg-warning' : 'bg-destructive')}>
{ok && <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-50 motion-reduce:hidden" style={{ animationIterationCount: 2 }} />}
</span>
<span className="sr-only">{t.status}</span>
<span className="font-semibold text-foreground">{status}</span> {statusText}
</span>
{duration !== undefined && (
<span>
<span className="sr-only">{t.time} </span>
{duration} ms
</span>
)}
<span>
<span className="sr-only">{t.size} </span>
{fmtSize(bytes)}
</span>
</span>
</div>
{toolbar(<Segmented label={t.view} value={tab} onChange={setTab} options={[['body', t.body], ['headers', `${t.headers}${headers ? ` ${Object.keys(headers).length}` : ''}`]]} />)}
{tab === 'body' ? (
treeBox('max-h-[22rem]')
) : (
<dl className="grid grid-cols-[minmax(0,1fr)] p-2 text-[13px] sm:grid-cols-[auto_minmax(0,1fr)]">
{Object.entries(headers ?? {}).map(([k, v]) => (
<div key={k} className="contents">
<dt className="px-3 pt-2 font-medium text-muted-foreground sm:py-1.5">{k}</dt>
<dd className="break-all px-3 pb-2 text-foreground sm:py-1.5">{v}</dd>
</div>
))}
</dl>
)}
</>,
);
}
/* ---------- tree ---------- */
return shell(
<>
{toolbar(label && <span className="flex items-center gap-2 text-[13px] font-semibold text-foreground"><Icon name="file-code" size={15} className="text-muted-foreground" />{label}</span>)}
{treeBox()}
{pathLine}
</>,
);
}
function ToolButton({ icon, label, onClick }: { icon: 'maximize' | 'minimize' | 'copy' | 'check'; label: string; onClick: () => void }) {
return (
<button type="button" aria-label={label} title={label} onClick={onClick} className="grid size-11 place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,scale] duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] focus-visible:text-foreground active:scale-90 sm:size-8">
<Icon name={icon} size={15} className={cn(icon === 'check' && 'text-success')} />
</button>
);
}
function Segmented<T extends string>({ label, value, onChange, options }: { label: string; value: T; onChange: (v: T) => void; options: [T, string][] }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className="inline-flex h-12 rounded-lg bg-muted p-0.5 sm:h-8">
{options.map(([v, l]) => (
<button
key={v}
type="button"
role="radio"
aria-checked={value === v}
onClick={() => onChange(v)}
onKeyDown={(e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
const i = options.findIndex((o) => o[0] === v);
const j = (i + (e.key === 'ArrowRight' ? 1 : -1) + options.length) % options.length;
onChange(options[j][0]);
(ref.current?.querySelectorAll('button')[j] as HTMLButtonElement | undefined)?.focus();
}}
tabIndex={value === v ? 0 : -1}
className={cn('rounded-[7px] px-3 text-[12.5px] font-medium outline-none transition-[background-color,color,box-shadow] duration-200 focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', value === v ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
>
{l}
</button>
))}
</div>
);
}Häufige Fragen
Wie installiere ich JSON-Ansicht?
Mit aiw add @ai-website/json-viewer. Der Befehl legt die Datei components/ui/json-viewer.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und copy-button. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat JSON-Ansicht?
4 Varianten, Baum, Tabelle, Suche und API-Antwort. Du wählst sie mit der Prop variant, zum Beispiel variant="search".
Kann ich die Texte von JSON-Ansicht übersetzen?
Ja. JSON-Ansicht bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „JSON-Daten“, „Einträge“ und „Schlüssel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent JSON-Ansicht einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini JSON-Ansicht mit aiw_components und installieren sie mit aiw_add @ai-website/json-viewer. Nenn im Prompt die Variante, etwa variant="table", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich JSON-Ansicht ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden JsonViewer-Code zum Kopieren.