JSON viewerDesign system
Product

JSON viewer

JSON display with line numbers, folding, search, copy, and download.

npx context-ds add json-viewer
In the product
Bare

Usage

Use it for

API responses in the playground.

Avoid

  • Sideways scrolling code

Dependencies: copy-button, tooltip, lucide-react

Example

examples/json-viewer.tsx
import { JsonViewer } from "@/components/product/json-viewer";const RESPONSE = {  success: true,  url: "https://acme.com/pricing",  status: 200,  title: "Pricing | Acme",  plans: [    { name: "Starter", price: 0, credits: 1000 },    { name: "Pro", price: 49, credits: 100000 },  ],  fetchedAt: "2026-10-17T09:12:00Z",  cached: false,  error: null,};export function Product() {  return (    <div className="h-80 w-full max-w-xl rounded-card bg-surface shadow-card">      <JsonViewer value={RESPONSE} className="h-full" />    </div>  );}export function Bare() {  return (    <div className="w-full max-w-xl overflow-hidden rounded-control border border-line-subtle">      <JsonViewer value={RESPONSE} bare bodyClassName="max-h-60" />    </div>  );}

Source

components/product/json-viewer.tsx
"use client";import * as React from "react";import { Check, ChevronDown, ChevronRight, Copy, Download, Search, X } from "lucide-react";import { useCopy } from "@/components/ui/copy-button";import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";import { cn } from "@/lib/utils";// A response as a code viewer: numbered, wrapped lines (never sideways scrolling), folding objects and arrays,// search with highlighted matches, copy and download. Keys in navy, strings in brand blue, numbers and booleans a// lighter blue, null gray. `bare` drops the toolbar, for a viewer that sits inside a card that has its own.type Json = string | number | boolean | null | Json[] | { [key: string]: Json };type Line =  | { kind: "open"; path: string; depth: number; label?: string; bracket: "{" | "[" }  | { kind: "close"; path: string; depth: number; bracket: "}" | "]"; comma: boolean }  | { kind: "folded"; path: string; depth: number; label?: string; bracket: "{" | "["; size: number; comma: boolean }  | { kind: "value"; path: string; depth: number; label?: string; value: Json; comma: boolean };const ROOT = "$";const isContainer = (v: Json): v is Json[] | { [key: string]: Json } => typeof v === "object" && v !== null;const entriesOf = (v: Json[] | { [key: string]: Json }): [string, Json][] => (Array.isArray(v) ? v.map((item, i) => [String(i), item]) : Object.entries(v));function linesOf(value: Json, folded: Set<string>, path = ROOT, depth = 0, label?: string, comma = false, lines: Line[] = []) {  if (!isContainer(value) || entriesOf(value).length === 0) {    lines.push({ kind: "value", path, depth, label, value, comma });    return lines;  }  const isArray = Array.isArray(value);  const entries = entriesOf(value);  if (folded.has(path)) {    lines.push({ kind: "folded", path, depth, label, bracket: isArray ? "[" : "{", size: entries.length, comma });    return lines;  }  lines.push({ kind: "open", path, depth, label, bracket: isArray ? "[" : "{" });  entries.forEach(([key, child], i) => linesOf(child, folded, `${path}/${key}`, depth + 1, isArray ? undefined : key, i < entries.length - 1, lines));  lines.push({ kind: "close", path, depth, bracket: isArray ? "]" : "}", comma });  return lines;}function containerPaths(value: Json, path = ROOT, paths: string[] = []) {  if (!isContainer(value)) return paths;  if (path !== ROOT && entriesOf(value).length > 0) paths.push(path);  for (const [key, child] of entriesOf(value)) containerPaths(child, `${path}/${key}`, paths);  return paths;}const valueClass = (v: Json) => (typeof v === "string" ? "text-fg-brand" : v === null ? "text-fg-subtle" : "text-blue-70");function Highlighted({ text, query }: { text: string; query: string }) {  if (!query) return <>{text}</>;  const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"));  return <>{parts.map((part, i) => (i % 2 === 1 ? <mark key={i} className="rounded-sm bg-blue-10 text-fg-brand ring-1 ring-brand/30">{part}</mark> : part))}</>;}function ToolButton({ label, onClick, children }: { label: string; onClick?: () => void; children: React.ReactNode }) {  return (    <Tooltip>      <TooltipTrigger asChild>        <button type="button" aria-label={label} onClick={onClick} className="grid size-8 shrink-0 place-items-center rounded-control border border-line text-fg-muted outline-none transition-colors duration-150 hover:border-line-strong hover:text-fg focus-visible:ring-2 focus-visible:ring-focus-ring [&_svg]:size-3.5">          {children}        </button>      </TooltipTrigger>      <TooltipContent side="top">{label}</TooltipContent>    </Tooltip>  );}export function JsonViewer({ value, fileName = "response.json", className, bodyClassName, bare = false }: { value: unknown; fileName?: string; className?: string; bodyClassName?: string; bare?: boolean }) {  const json = value as Json;  const [folded, setFolded] = React.useState<Set<string>>(() => new Set());  const [searching, setSearching] = React.useState(false);  const [query, setQuery] = React.useState("");  const { hasCopied: copied, copy } = useCopy();  const text = React.useMemo(() => JSON.stringify(value, null, 2), [value]);  const lines = React.useMemo(() => linesOf(json, folded), [json, folded]);  const all = React.useMemo(() => containerPaths(json), [json]);  const allFolded = all.length > 0 && all.every((p) => folded.has(p));  const q = query.trim();  const matches = q ? text.toLowerCase().split(q.toLowerCase()).length - 1 : 0;  const toggle = (path: string) =>    setFolded((prev) => {      const next = new Set(prev);      if (next.has(path)) next.delete(path);      else next.add(path);      return next;    });  return (    <div data-slot="json-viewer" className={cn("flex min-h-0 flex-col", className)}>      {!bare && (        <div className="flex h-12 shrink-0 items-center gap-2 border-b border-line-subtle px-4">          {searching ? (            <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-control border border-brand px-2.5 shadow-[0_0_0_3px_var(--ds-color-blue-10)]">              <label className="flex min-w-0 flex-1 items-center gap-2 self-stretch">                <Search className="size-3.5 shrink-0 text-fg-subtle" strokeWidth={1.5} />                <input                  autoFocus                  value={query}                  onChange={(e) => setQuery(e.target.value)}                  onKeyDown={(e) => e.key === "Escape" && setSearching(false)}                  placeholder="Search the response"                  aria-label="Search the response"                  className="min-w-0 flex-1 bg-transparent text-body-sm outline-none placeholder:text-fg-subtle"                />                {q && <span className="shrink-0 text-caption text-fg-subtle tabular-nums">{matches} found</span>}              </label>              <button                type="button"                aria-label="Close search"                onClick={() => {                  setSearching(false);                  setQuery("");                }}                className="grid size-5 shrink-0 place-items-center rounded-sm text-fg-subtle transition-colors duration-150 hover:text-fg"              >                <X className="size-3.5" strokeWidth={1.5} />              </button>            </div>          ) : (            <>              <span className="text-caption text-fg-subtle tabular-nums">{lines.length} lines</span>              <button                type="button"                onClick={() => setFolded(allFolded ? new Set() : new Set(all))}                disabled={all.length === 0}                className="ml-auto h-8 shrink-0 rounded-control border border-line px-3 text-body-sm text-fg-muted outline-none transition-colors duration-150 hover:border-line-strong hover:text-fg focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-40"              >                {allFolded ? "Expand all" : "Collapse all"}              </button>              <ToolButton label="Search" onClick={() => setSearching(true)}>                <Search strokeWidth={1.5} />              </ToolButton>            </>          )}          <ToolButton            label={copied ? "Copied" : "Copy JSON"}            onClick={() => copy(text)}          >            {copied ? <Check strokeWidth={1.5} /> : <Copy strokeWidth={1.5} />}          </ToolButton>          <ToolButton            label="Download JSON"            onClick={() => {              const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));              const a = document.createElement("a");              a.href = url;              a.download = fileName;              a.click();              URL.revokeObjectURL(url);            }}          >            <Download strokeWidth={1.5} />          </ToolButton>        </div>      )}      <div className={cn("ds-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto py-3 font-mono text-caption leading-code-card", bodyClassName)}>        <ol>          {lines.map((line, i) => {            const canFold = line.kind === "open" || line.kind === "folded";            const label =              "label" in line && line.label !== undefined ? (                <>                  <span className="text-navy-60">                    <Highlighted text={`"${line.label}"`} query={q} />                  </span>                  :{" "}                </>              ) : null;            let body: React.ReactNode;            if (line.kind === "open") body = <>{label}{line.bracket}</>;            else if (line.kind === "close") body = <>{line.bracket}{line.comma && ","}</>;            else if (line.kind === "folded")              body = (                <>                  {label}                  {line.bracket}                  <button type="button" onClick={() => toggle(line.path)} className="mx-1 rounded-sm bg-neutral-5 px-1.5 text-fg-subtle transition-colors duration-150 hover:text-fg">                    {line.size} {line.bracket === "[" ? (line.size === 1 ? "item" : "items") : line.size === 1 ? "key" : "keys"}                  </button>                  {line.bracket === "[" ? "]" : "}"}                  {line.comma && ","}                </>              );            else              body = (                <>                  {label}                  <span className={valueClass(line.value)}>                    <Highlighted text={JSON.stringify(line.value)} query={q} />                  </span>                  {line.comma && ","}                </>              );            return (              <li key={`${line.path}:${line.kind}`} className="flex hover:bg-neutral-5/60">                <span className="w-10 shrink-0 pr-2 text-right text-fg-subtle tabular-nums select-none">{i + 1}</span>                <span className="w-4 shrink-0">                  {canFold && (                    <button type="button" aria-label={line.kind === "open" ? "Collapse" : "Expand"} onClick={() => toggle(line.path)} className="grid h-[1.65em] w-4 place-items-center text-fg-subtle transition-colors duration-150 hover:text-fg">                      {line.kind === "open" ? <ChevronDown className="size-3" strokeWidth={2} /> : <ChevronRight className="size-3" strokeWidth={2} />}                    </button>                  )}                </span>                <span className="min-w-0 flex-1 pr-4 whitespace-pre-wrap text-fg [overflow-wrap:anywhere]" style={{ paddingLeft: `${line.depth * 2}ch` }}>                  {body}                </span>              </li>            );          })}        </ol>      </div>    </div>  );}

Reference: context-dashboard c0ddf7a src/components/playground/json-viewer.tsx.