# JSON Viewer

> Collapsible JSON tree with syntax colors, key counts, expand/collapse all, copy value or path and long-string expansion.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/json-viewer
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/json-viewer.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/json-viewer-demo.tsx"
import { JsonViewer } from "@/components/json-viewer"

const order = {
  id: "ord_9f3a2c71",
  status: "paid",
  livemode: false,
  amount: 12850,
  currency: "usd",
  refunded: null,
  customer: {
    id: "cus_Q81xLm",
    name: "Ada Lovelace",
    email: "ada@analytical.engine",
    address: { line1: "12 St James's Square", city: "London", postal_code: "SW1Y 4JH", country: "GB" },
    tags: ["vip", "beta-tester", "newsletter"],
  },
  items: [
    { sku: "KB-75-TEAL", name: "Mechanical keyboard", qty: 1, price: 9900 },
    { sku: "CAP-PBT-XL", name: "PBT keycap set", qty: 2, price: 1475 },
  ],
  notes:
    "Please leave the parcel with the front desk if nobody answers the door. The concierge signs for deliveries between 8am and 6pm on weekdays, and the building closes at weekends.",
  metadata: { source: "web", campaign: "autumn-sale", experiment: { variant: "B", holdout: false } },
  created: 1762473600,
}

export default function JsonViewerDemo() {
  return (
    <div className="w-full max-w-xl min-w-0">
      <JsonViewer data={order} title="GET /v1/orders/ord_9f3a2c71" defaultExpandedDepth={2} />
    </div>
  )
}
```

## Source

```tsx title="components/json-viewer.tsx"
"use client"

import * as React from "react"
import { Check, ChevronRight, ChevronsDownUp, ChevronsUpDown, Copy, Link2 } from "lucide-react"

import { cn } from "@/lib/utils"

export interface JsonViewerProps extends Omit<React.ComponentProps<"div">, "children"> {
  data: unknown
  /** Header title */
  title?: string
  /** Levels expanded on first render (1 = only the root) */
  defaultExpandedDepth?: number
  /** Strings longer than this are truncated until expanded */
  maxStringLength?: number
}

type JsonType = "object" | "array" | "string" | "number" | "boolean" | "null" | "other"

interface Row {
  kind: "item" | "close"
  path: string
  parent: string | null
  keyLabel: string | null
  isIndex: boolean
  level: number
  type: JsonType
  value: unknown
  size: number
  expandable: boolean
  open: boolean
  pos: number
  setSize: number
}

const ROOT = "$"
const INDENT = 16

function typeOf(value: unknown): JsonType {
  if (value === null || value === undefined) return "null"
  if (Array.isArray(value)) return "array"
  const t = typeof value
  if (t === "object") return "object"
  if (t === "string" || t === "number" || t === "boolean") return t
  return "other"
}

function entriesOf(value: unknown, type: JsonType): [string, unknown][] {
  if (type === "array") return (value as unknown[]).map((v, i) => [String(i), v])
  if (type === "object") return Object.entries(value as Record<string, unknown>)
  return []
}

function childPath(path: string, key: string, isIndex: boolean) {
  if (isIndex) return `${path}[${key}]`
  return /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`
}

function collectBranches(value: unknown, path: string, depth: number, maxDepth: number, out: Set<string>) {
  const type = typeOf(value)
  const entries = entriesOf(value, type)
  if (entries.length === 0 || depth >= maxDepth) return
  out.add(path)
  for (const [k, v] of entries) collectBranches(v, childPath(path, k, type === "array"), depth + 1, maxDepth, out)
}

function flatten(
  value: unknown,
  keyLabel: string | null,
  isIndex: boolean,
  path: string,
  parent: string | null,
  level: number,
  pos: number,
  setSize: number,
  expanded: ReadonlySet<string>,
  out: Row[]
) {
  const type = typeOf(value)
  const entries = entriesOf(value, type)
  const expandable = entries.length > 0
  const open = expandable && expanded.has(path)
  out.push({ kind: "item", path, parent, keyLabel, isIndex, level, type, value, size: entries.length, expandable, open, pos, setSize })
  if (open) {
    entries.forEach(([k, v], i) =>
      flatten(v, k, type === "array", childPath(path, k, type === "array"), path, level + 1, i + 1, entries.length, expanded, out)
    )
    out.push({ kind: "close", path: `${path}#close`, parent: path, keyLabel: null, isIndex: false, level, type, value: null, size: 0, expandable: false, open: false, pos: 0, setSize: 0 })
  }
}

const valueTone: Record<JsonType, string> = {
  string: "text-emerald-600 dark:text-emerald-400",
  number: "text-amber-600 dark:text-amber-400",
  boolean: "text-sky-600 dark:text-sky-400",
  null: "text-rose-600 italic dark:text-rose-400",
  object: "text-muted-foreground",
  array: "text-muted-foreground",
  other: "text-muted-foreground",
}

function rawCopy(row: Row) {
  if (row.type === "string") return row.value as string
  if (row.type === "null") return "null"
  return JSON.stringify(row.value, null, 2) ?? String(row.value)
}

const toolButton =
  "inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"

const miniButton =
  "inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

export function JsonViewer({
  data,
  title = "JSON",
  defaultExpandedDepth = 2,
  maxStringLength = 72,
  className,
  ...props
}: JsonViewerProps) {
  const treeRef = React.useRef<HTMLDivElement>(null)
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
  const [expanded, setExpanded] = React.useState<ReadonlySet<string>>(() => {
    const set = new Set<string>()
    collectBranches(data, ROOT, 0, defaultExpandedDepth, set)
    return set
  })
  const [openStrings, setOpenStrings] = React.useState<ReadonlySet<string>>(() => new Set())
  const [activePath, setActivePath] = React.useState<string | null>(null)
  const [copied, setCopied] = React.useState<string | null>(null)

  React.useEffect(() => {
    return () => {
      if (timer.current) clearTimeout(timer.current)
    }
  }, [])

  const rows = React.useMemo(() => {
    const out: Row[] = []
    flatten(data, null, false, ROOT, null, 1, 1, 1, expanded, out)
    return out
  }, [data, expanded])

  const active = rows.some((r) => r.kind === "item" && r.path === activePath) ? activePath : ROOT

  const toggle = (path: string) =>
    setExpanded((cur) => {
      const next = new Set(cur)
      if (next.has(path)) next.delete(path)
      else next.add(path)
      return next
    })

  const toggleString = (path: string) =>
    setOpenStrings((cur) => {
      const next = new Set(cur)
      if (next.has(path)) next.delete(path)
      else next.add(path)
      return next
    })

  function expandAll() {
    const set = new Set<string>()
    collectBranches(data, ROOT, 0, Infinity, set)
    setExpanded(set)
  }

  function collapseAll() {
    setExpanded(new Set([ROOT]))
  }

  async function copy(text: string, id: string) {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(id)
      if (timer.current) clearTimeout(timer.current)
      timer.current = setTimeout(() => setCopied(null), 1400)
    } catch {
      // Clipboard unavailable (insecure context or denied permission); fail silently.
    }
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>, row: Row, isLongString: boolean) {
    if (e.target !== e.currentTarget) return
    const nodes = Array.from(treeRef.current?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? [])
    const idx = nodes.indexOf(e.currentTarget)
    const focusAt = (i: number) => nodes[Math.min(Math.max(i, 0), nodes.length - 1)]?.focus()
    if (e.ctrlKey || e.metaKey || e.altKey) return

    switch (e.key) {
      case "ArrowDown":
        e.preventDefault()
        focusAt(idx + 1)
        break
      case "ArrowUp":
        e.preventDefault()
        focusAt(idx - 1)
        break
      case "Home":
        e.preventDefault()
        focusAt(0)
        break
      case "End":
        e.preventDefault()
        focusAt(nodes.length - 1)
        break
      case "ArrowRight":
        e.preventDefault()
        if (row.expandable && !row.open) toggle(row.path)
        else if (row.open) focusAt(idx + 1)
        break
      case "ArrowLeft":
        e.preventDefault()
        if (row.open) toggle(row.path)
        else if (row.parent) nodes.find((n) => n.dataset.path === row.parent)?.focus()
        break
      case "Enter":
      case " ":
        e.preventDefault()
        if (row.expandable) toggle(row.path)
        else if (isLongString) toggleString(row.path)
        break
      case "c":
        void copy(rawCopy(row), `${row.path}|value`)
        break
      case "p":
        void copy(row.path, `${row.path}|path`)
        break
    }
  }

  return (
    <div className={cn("w-full min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm", className)} {...props}>
      <div className="flex min-h-10 flex-wrap items-center justify-between gap-x-3 border-b bg-muted/50 py-1 pr-1.5 pl-4">
        <span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{title}</span>
        <div className="flex items-center gap-0.5">
          <button type="button" onClick={expandAll} className={toolButton}>
            <ChevronsUpDown className="size-3.5" aria-hidden />
            Expand all
          </button>
          <button type="button" onClick={collapseAll} className={toolButton}>
            <ChevronsDownUp className="size-3.5" aria-hidden />
            Collapse all
          </button>
        </div>
      </div>
      <div className="max-h-96 overflow-auto p-2">
        <div ref={treeRef} role="tree" aria-label={`${title} tree. Press c to copy a value, p to copy its path.`}>
          {rows.map((row) => {
            if (row.kind === "close") {
              return (
                <div
                  key={row.path}
                  aria-hidden
                  className="py-0.5 font-mono text-[13px] leading-6 text-muted-foreground"
                  style={{ paddingLeft: row.level * INDENT + 20 }}
                >
                  {row.type === "array" ? "]" : "}"}
                </div>
              )
            }
            const isString = row.type === "string"
            const text = isString ? (row.value as string) : ""
            const isLong = isString && text.length > maxStringLength
            const stringOpen = openStrings.has(row.path)
            const valueCopied = copied === `${row.path}|value`
            const pathCopied = copied === `${row.path}|path`
            const label = row.keyLabel ?? "root"

            let display: string
            if (row.type === "object" || row.type === "array") {
              const [o, c] = row.type === "array" ? ["[", "]"] : ["{", "}"]
              display = row.expandable ? (row.open ? o : `${o}${row.size}${c}`) : `${o}${c}`
            } else if (isString) {
              display = `"${isLong && !stringOpen ? `${text.slice(0, maxStringLength)}…` : text}"`
            } else if (row.type === "null") {
              display = row.value === undefined ? "undefined" : "null"
            } else {
              display = String(row.value)
            }

            // The accessible name starts with the rendered text so it satisfies WCAG 2.5.3 (Label in Name)
            const visibleText = `${row.keyLabel === null ? "" : `${row.isIndex ? row.keyLabel : `"${row.keyLabel}"`}: `}${display}${isLong ? ` ${stringOpen ? "show less" : "show more"}` : ""}`
            const description =
              row.type === "object" || row.type === "array" ? `${row.type}, ${row.size} ${row.type === "array" ? "items" : "keys"}` : row.type

            return (
              <div
                key={row.path}
                role="treeitem"
                data-path={row.path}
                aria-level={row.level}
                aria-setsize={row.setSize}
                aria-posinset={row.pos}
                aria-expanded={row.expandable ? row.open : undefined}
                aria-selected={row.path === active}
                aria-label={`${visibleText} (${label}, ${description})`}
                tabIndex={row.path === active ? 0 : -1}
                onFocus={(e) => {
                  if (e.target === e.currentTarget) setActivePath(row.path)
                }}
                onClick={() => {
                  if (row.expandable) toggle(row.path)
                }}
                onKeyDown={(e) => onKeyDown(e, row, isLong)}
                className={cn(
                  "group flex items-start gap-1 rounded-md py-0.5 pr-1 font-mono text-[13px] leading-6 outline-none hover:bg-accent/60 focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
                  row.expandable && "cursor-pointer"
                )}
                style={{ paddingLeft: row.level * INDENT - 8 }}
              >
                <ChevronRight
                  aria-hidden
                  className={cn("mt-1 size-4 shrink-0 text-muted-foreground transition-transform", row.expandable ? (row.open ? "rotate-90" : "") : "opacity-0")}
                />
                <span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
                  {row.keyLabel !== null && (
                    <>
                      <span className={row.isIndex ? "text-muted-foreground" : "text-violet-600 dark:text-violet-400"}>
                        {row.isIndex ? row.keyLabel : `"${row.keyLabel}"`}
                      </span>
                      <span className="text-muted-foreground">: </span>
                    </>
                  )}
                  <span className={cn(valueTone[row.type], isString && "whitespace-pre-wrap")}>{display}</span>
                  {isLong && (
                    <button
                      type="button"
                      tabIndex={-1}
                      aria-label={stringOpen ? `Show less of ${label}` : `Show more of ${label}`}
                      onClick={(e) => {
                        e.stopPropagation()
                        toggleString(row.path)
                      }}
                      className="ml-1.5 rounded px-1 font-sans text-xs text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      {stringOpen ? "show less" : "show more"}
                    </button>
                  )}
                </span>
                <span className="flex shrink-0 items-center opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
                  <button
                    type="button"
                    tabIndex={-1}
                    aria-label={`Copy value of ${label}`}
                    onClick={(e) => {
                      e.stopPropagation()
                      void copy(rawCopy(row), `${row.path}|value`)
                    }}
                    className={miniButton}
                  >
                    {valueCopied ? <Check className="size-3.5 text-emerald-500" aria-hidden /> : <Copy className="size-3.5" aria-hidden />}
                  </button>
                  <button
                    type="button"
                    tabIndex={-1}
                    aria-label={`Copy path of ${label}`}
                    onClick={(e) => {
                      e.stopPropagation()
                      void copy(row.path, `${row.path}|path`)
                    }}
                    className={miniButton}
                  >
                    {pathCopied ? <Check className="size-3.5 text-emerald-500" aria-hidden /> : <Link2 className="size-3.5" aria-hidden />}
                  </button>
                </span>
              </div>
            )
          })}
        </div>
      </div>
      <p className="sr-only" aria-live="polite">
        {copied ? (copied.endsWith("|path") ? "Path copied" : "Value copied") : ""}
      </p>
    </div>
  )
}
```
