Skip to content
UI Kit
Accent color

JSON viewer

JSON you can read. A tree that opens and closes with the keyboard, counts on closed branches, values tinted by type, and a click on a key copies its path.

One of 2 components in Code and JSON

4 variants

Variants

Tree

Two levels open at the start, chevrons and arrows open the rest, guide lines show the depth. Open all, close all and copy sit in the bar, the path of a clicked key shows below.

Open in Studio

Table

An array of objects as a table with a column per key, a switch back to the tree. Nested values show their size, missing ones a dot.

Open in Studio

API response

The answer of a request with method, path, status, time and size, the body as a tree and the headers on a second tab. The status light pulses twice on arrival.

Open in Studio

Details

What JSON viewer brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/json-viewer.tsx
Brings along
utilskiticoncopy-button
npm packages
None
Languages
English and German, 21 texts of its own

Props

The JsonViewerProps interface from components/ui/json-viewer.tsx, with the comments from the code.

  • variantoptional
    JsonViewerVariant

    treetablesearchresponse

  • datarequired
    Json | string

    The data, as a value or as JSON text

  • labeloptional
    string

    Accessible name and the title above

  • depthoptional
    number

    Levels open at the start. Default 2.

  • defaultQueryoptional
    string

    search: a query at the start, for demos

  • requestoptional
    string

    response: the request line, e.g. "GET /v1/orders/4821"

  • statusoptional
    number

    response: HTTP status code

  • statusTextoptional
    string

    response: status text, e.g. "OK"

  • durationoptional
    number

    response: duration in ms

  • headersoptional
    Record<string, string>

    response: response headers

Built-in texts

JSON viewer writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 21 texts
jsonEnglish JSON dataGerman JSON-Daten
itemsEnglish itemsGerman Einträge
keysEnglish keysGerman Schlüssel
expandAllEnglish Open allGerman Alle öffnen
collapseAllEnglish Close allGerman Alle schließen
copyEnglish Copy JSONGerman JSON kopieren
copiedEnglish CopiedGerman Kopiert
copyPathEnglish Copy pathGerman Pfad kopieren
pathCopiedEnglish Path copiedGerman Pfad kopiert
keyHintEnglish Click a key to copy its pathGerman Einen Schlüssel anklicken kopiert seinen Pfad
viewEnglish ViewGerman Ansicht
searchEnglish Search keys and valuesGerman Schlüssel und Werte suchen
matchesEnglish matchesGerman Treffer
noMatchEnglish Nothing matches. Try a key name or a value.German Nichts gefunden. Versuch einen Schlüssel oder einen Wert.
tableEnglish TableGerman Tabelle
rawEnglish TreeGerman Baum
bodyEnglish BodyGerman Inhalt
headersEnglish HeadersGerman Header
statusEnglish StatusGerman Status
timeEnglish TimeGerman Zeit
sizeEnglish SizeGerman Größe

Accessibility

Read from the source of the component.

  • JSON viewer handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-checked, aria-expanded, aria-label, aria-level, aria-live, aria-posinset and aria-setsize, so screen readers announce purpose and state.
  • Uses the roles group, radio, radiogroup, region, tree and treeitem.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 531 lines
components/ui/json-viewer.tsx
'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))]">&quot;{v}&quot;</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))]">&quot;<Mark text={v} q={q} />&quot;</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>
  );
}

Frequent questions

How do I install JSON viewer?

With aiw add @ai-website/json-viewer. The command puts components/ui/json-viewer.tsx into your project, the code is yours from then on. It brings along icon and copy-button. It needs no further npm packages.

Which variants does JSON viewer have?

4 variants, Tree, Table, Search and API response. You pick one with the variant prop, for example variant="search".

Can I translate the texts of JSON viewer?

Yes. JSON viewer carries 21 texts of its own in English and German, such as “JSON data”, “items” and “keys”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with JSON viewer?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find JSON viewer with aiw_components and install it with aiw_add @ai-website/json-viewer. Name the variant in your prompt, for example variant="table", and the assistant writes your content straight into the props.

Can I adjust JSON viewer without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching JsonViewer code to copy.

Often searched

Jump to

Copied to the clipboard