Skip to contentVibraUI
Data display

JSON viewer

A collapsible view of any JSON value, coloured by type and copyable whole.

A client component: branches open and shut. Values are coloured by JSON type — strings success, numbers info, booleans warning, null muted and italic — and keys stay in the foreground, so the shape reads before the content does. A shut branch says how big it is, "{3 keys}" or "[5 items]", and its toggle is a real button carrying aria-expanded and a name that says which branch it opens. Only the branches a reader has actually clicked are remembered; everything else answers from defaultExpanded, so a new payload opens the way the first one did. defaultExpanded takes a number as a depth: 1 opens the root and nothing below it. A value already on its own ancestor path renders as [Circular] rather than recursing, in the tree and in the copied text alike, so a node carrying a back-reference cannot blow the stack during render; the same object under two sibling keys is not a cycle and stays expandable. Nothing caps how much it renders, so reach for defaultExpanded as a depth rather than true when a payload holds big arrays.

Install

npx shadcn@latest add @vibra/json-viewer

Needs the @vibra registry in your components.json — set it up once.

Examples

One level open

A webhook body opened to depth 1, so the shape reads before the detail.

Props

PropTypeDefaultDescription
dataunknown—Any value — objects, arrays, and primitives all render.
defaultExpandedboolean | numbertruetrue opens everything, false nothing, a number opens that many levels.
rootNamestring—A name for the top-level value, e.g. "deployment".
copyablebooleanfalsePuts a copy button in the top-right corner that writes the whole payload.
maxHeightnumber—Pixels; the tree scrolls past it.
classNamestring—Merged onto the div root; the remaining div props are spread onto it too.

Dependencies

Source

components/ui/json-viewer.tsx
"use client"

import * as React from "react"
import { ChevronRightIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { CopyButton } from "@/components/ui/copy-button"

type Branch = { kind: "object" | "array"; entries: [string, unknown][] }

const BRACKETS = { object: ["{", "}"], array: ["[", "]"] } as const

// Indentation per level, and the width of the toggle a row without children
// has to make up for, so leaves line up with their siblings' labels.
const INDENT_PX = 12
const TOGGLE_PX = 16

// What a value that points back up its own branch renders as, in the tree and
// in the copied text alike.
const CIRCULAR = "[Circular]"

/** Reads a value as a branch — an object or an array with its entries — or null for a leaf. */
function asBranch(value: unknown): Branch | null {
  if (Array.isArray(value)) {
    return { kind: "array", entries: value.map((item, index) => [String(index), item]) }
  }
  if (typeof value === "object" && value !== null) {
    return { kind: "object", entries: Object.entries(value as Record<string, unknown>) }
  }
  return null
}

function summarise({ kind, entries }: Branch): string {
  const [open, close] = BRACKETS[kind]
  const noun = kind === "array" ? "item" : "key"
  return `${open}${entries.length} ${noun}${entries.length === 1 ? "" : "s"}${close}`
}

/**
 * The text and class a leaf renders as — strings quoted, numbers tabular, null
 * muted and italic. The paints are status tokens, not chart tokens: the chart
 * palette is tuned for 3:1 mark fills, and as 12px text it left strings at
 * 2.88:1 on the light surface.
 */
function leafPaint(value: unknown): { text: string; className: string } {
  if (typeof value === "string") return { text: JSON.stringify(value), className: "text-success" }
  if (typeof value === "number" || typeof value === "bigint") {
    return { text: String(value), className: "text-info tabular-nums" }
  }
  if (typeof value === "boolean") return { text: String(value), className: "text-warning" }
  if (value === null) return { text: "null", className: "text-muted-foreground italic" }
  if (value === undefined) return { text: "undefined", className: "text-muted-foreground italic" }
  // Functions and symbols never survive JSON, but they do reach this component.
  return { text: String(value), className: "text-muted-foreground italic" }
}

// A cycle would make JSON.stringify throw where the copy button expects a
// string. The replacer tracks the ancestor path rather than every object it has
// ever seen — `this` is the holder of the current value, so unwinding to it
// leaves exactly the chain above `value` — which cuts a real cycle and still
// writes a shared reference out twice, the same rule the tree renders by.
function safeStringify(data: unknown): string {
  const ancestors: object[] = []
  return JSON.stringify(
    data,
    function (this: unknown, _key: string, value: unknown) {
      if (typeof value !== "object" || value === null) return value
      while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
      if (ancestors.includes(value)) return CIRCULAR
      ancestors.push(value)
      return value
    },
    2
  )
}

function JsonKey({ name }: { name: string | undefined }) {
  if (name === undefined) return null
  return (
    <>
      <span className="text-foreground">{name}</span>
      <span className="text-muted-foreground">:&nbsp;</span>
    </>
  )
}

type NodeProps = {
  name?: string
  value: unknown
  depth: number
  path: string
  /** Every container from the root down to this node's parent. */
  ancestors: readonly object[]
  isOpen: (path: string, depth: number) => boolean
  toggle: (path: string, open: boolean) => void
}

function JsonNode({ name, value, depth, path, ancestors, isOpen, toggle }: NodeProps) {
  const branch = asBranch(value)
  const rowIndent = { paddingInlineStart: `${depth * INDENT_PX}px` }
  const leafIndent = { paddingInlineStart: `${depth * INDENT_PX + TOGGLE_PX}px` }

  // A value already on its own ancestor path would recurse until the stack
  // blows, and `data` is not promised to have come from JSON.parse — a node
  // with a `parent`, a DOM element, anything with a back-reference reaches
  // here. The same object under two sibling keys is not a cycle and stays
  // expandable, because only the chain above this node is checked.
  if (branch !== null && ancestors.includes(value as object)) {
    return (
      <div
        data-slot="json-viewer-node"
        data-circular="true"
        className="flex"
        style={leafIndent}
      >
        <JsonKey name={name} />
        <span className="text-muted-foreground italic">{CIRCULAR}</span>
      </div>
    )
  }

  // A leaf, or a branch with nothing in it: no toggle, nothing to collapse.
  if (branch === null || branch.entries.length === 0) {
    const paint =
      branch === null
        ? leafPaint(value)
        : { text: BRACKETS[branch.kind].join(""), className: "text-muted-foreground" }

    return (
      <div data-slot="json-viewer-node" className="flex" style={leafIndent}>
        <JsonKey name={name} />
        <span className={cn("break-all", paint.className)}>{paint.text}</span>
      </div>
    )
  }

  const open = isOpen(path, depth)
  const [openBracket, closeBracket] = BRACKETS[branch.kind]

  return (
    <div data-slot="json-viewer-node" data-expanded={open || undefined}>
      <div className="flex items-start" style={rowIndent}>
        <button
          type="button"
          data-slot="json-viewer-toggle"
          aria-expanded={open}
          aria-label={`${open ? "Collapse" : "Expand"} ${name ?? "root"}`}
          onClick={() => toggle(path, open)}
          className="mt-px flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-ring"
        >
          <ChevronRightIcon
            aria-hidden="true"
            className={cn(
              "size-3 transition-transform duration-(--duration-fast) ease-(--ease-standard) rtl:-scale-x-100",
              open && "rotate-90 rtl:-rotate-90"
            )}
          />
        </button>
        <JsonKey name={name} />
        <span className="text-muted-foreground">{open ? openBracket : summarise(branch)}</span>
      </div>

      {open ? (
        <>
          {branch.entries.map(([key, child]) => (
            <JsonNode
              key={key}
              name={key}
              value={child}
              depth={depth + 1}
              // Length-prefixed, not dot-joined: a key may itself contain the
              // separator, and two nodes must never share one open/shut state.
              path={`${path}/${key.length}:${key}`}
              ancestors={[...ancestors, value as object]}
              isOpen={isOpen}
              toggle={toggle}
            />
          ))}
          <div className="flex text-muted-foreground" style={leafIndent}>
            {closeBracket}
          </div>
        </>
      ) : null}
    </div>
  )
}

const EMPTY_ANCESTORS: readonly object[] = []

export type JsonViewerProps = React.ComponentProps<"div"> & {
  data: unknown
  /** true opens everything, false nothing, a number opens that many levels. */
  defaultExpanded?: boolean | number
  /** A name for the top-level value, e.g. "payload". */
  rootName?: string
  copyable?: boolean
  /** Pixels; the tree scrolls past it. */
  maxHeight?: number
}

/** A collapsible view of any JSON value, coloured by type and copyable whole. */
function JsonViewer({
  className,
  data,
  defaultExpanded = true,
  rootName,
  copyable = false,
  maxHeight,
  ...props
}: JsonViewerProps) {
  // Only the nodes a reader has actually clicked are remembered; every other
  // node answers from `defaultExpanded`, so a new payload opens the same way
  // the first one did.
  const [overrides, setOverrides] = React.useState<Record<string, boolean>>({})

  const isOpen = React.useCallback(
    (path: string, depth: number) => {
      if (Object.hasOwn(overrides, path)) return overrides[path]
      if (typeof defaultExpanded === "number") return depth < defaultExpanded
      return defaultExpanded
    },
    [overrides, defaultExpanded]
  )

  // The node passes its own state back rather than having this recompute it:
  // the caller already resolved the override and the depth default.
  const toggle = React.useCallback((path: string, open: boolean) => {
    setOverrides((current) => ({ ...current, [path]: !open }))
  }, [])

  return (
    <div
      data-slot="json-viewer"
      className={cn("relative rounded-lg border bg-muted/40 font-mono text-xs", className)}
      {...props}
    >
      {copyable ? (
        <CopyButton
          value={safeStringify(data)}
          size="icon-xs"
          className="absolute end-1.5 top-1.5 z-10 bg-background/80"
        />
      ) : null}
      <div
        data-slot="json-viewer-body"
        className="overflow-auto p-3 leading-6"
        style={maxHeight === undefined ? undefined : { maxHeight: `${maxHeight}px` }}
      >
        <JsonNode
          name={rootName}
          value={data}
          depth={0}
          path="$"
          ancestors={EMPTY_ANCESTORS}
          isOpen={isOpen}
          toggle={toggle}
        />
      </div>
    </div>
  )
}

export { JsonViewer }