Aadi

JSON Viewer

Component

About

A JSON tree for tool results and API payloads: search opens the path to every match and highlights it, folders fold, long strings cut with an expand control, and paths and values copy in one click.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds JSON Viewer to your project and fits it to your theme.

Code

How JSON Viewer is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { JsonViewer } from "@/components/ui/json-viewer"

/** Searches the tour types in, each opening the path to its matches. Empty shows the outline. */
const QUERIES = ["timed out", "503", "us-east-2", ""]

/** How long a choice holds a tour before it carries on. */
const RESUME_MS = 1800

/** True until `RESUME_MS` after the last `hold()`, for tours with their own state. */
function useHold() {
  const [held, setHeld] = React.useState(false)
  const timer = React.useRef(0)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])
  const hold = React.useCallback(() => {
    setHeld(true)
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setHeld(false), RESUME_MS)
  }, [])
  return [held, hold] as const
}

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches

const REST_MS = 1500

const TYPE_MS = 70

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

/** A `search_logs` tool result, the kind of payload an agent hands back. */
const RESULT = {
  query: "service:checkout level:error",
  took_ms: 184,
  hits: [
    {
      timestamp: "2026-10-21T09:14:03.218Z",
      service: "checkout",
      level: "error",
      message: "Payment intent timed out after 30000 ms",
      context: { region: "eu-west-1", attempt: 3, retryable: true },
    },
    {
      timestamp: "2026-10-21T09:14:07.902Z",
      service: "checkout",
      level: "error",
      message: "Upstream returned 503 from payments-gateway",
      context: { region: "eu-west-1", attempt: 1, upstream: "payments-gateway" },
    },
    {
      timestamp: "2026-10-21T09:15:41.006Z",
      service: "checkout",
      level: "warn",
      message: "Card declined: insufficient funds",
      context: { region: "us-east-2", attempt: 1, retryable: false },
    },
  ],
  total: 3,
  truncated: false,
}

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * A log search result from an agent's tool call, explored the way a person would: a search for
 * "timed out", then "503", then a region types in, and each one opens the tree to its matches and
 * highlights them. Loops. Type your own search and the tour waits for you.
 */
export default function JsonViewerPreview() {
  const [step, setStep] = React.useState({ query: 0, typed: QUERIES[0]!.length - 3 })
  const [held, hold] = useHold()
  const [touched, setTouched] = React.useState(false)

  React.useEffect(() => {
    if (reducedMotion() || held || touched) return
    const full = QUERIES[step.query]!
    const done = step.typed >= full.length
    const id = window.setTimeout(
      () =>
        setStep((s) =>
          done
            ? { query: (s.query + 1) % QUERIES.length, typed: 0 }
            : { query: s.query, typed: s.typed + 1 },
        ),
      done ? REST_MS : TYPE_MS,
    )
    return () => window.clearTimeout(id)
  }, [step, held, touched])

  // Back on tour after a visitor's own search, the next search starts fresh.
  React.useEffect(() => {
    if (held || !touched) return
    setTouched(false)
    setStep((s) => ({ query: (s.query + 1) % QUERIES.length, typed: 0 }))
  }, [held, touched])

  const query = QUERIES[step.query]!.slice(0, step.typed)

  return (
    <FitFrame minWidth="38rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div
          className="h-[21.5rem] w-full max-w-xl"
          onInput={() => {
            setTouched(true)
            hold()
          }}
          onPointerDown={() => {
            setTouched(true)
            hold()
          }}
        >
          <JsonViewer
            key={touched ? "mine" : `${step.query}:${query}`}
            data={RESULT}
            defaultExpandDepth={query ? 0 : 1}
            defaultQuery={query}
            maxHeight={286}
            label="search_logs result"
          />
        </div>
        <MoreOn slug="json-viewer">Search, fold, copy paths and values from any JSON. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

Explore more

Part of chatcn — browse the full library on 21st.dev.