Componenteditors1.3 KB gzipZero dependencies

JSON Viewer

Collapsible JSON tree with mono keys and color-typed values

Preview

Live Preview
{name: "Demo"count: 42active: trueitems:
[ "a" "b" "c" ]
}

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add json-viewer

Or install the full npm package:

npm install bigbullui
import { JsonViewer } from "@/components/ui/json-viewer";

<JsonViewer value={json} />

Usage guidance

Use JSON Viewer when a ticket stub surface needs Collapsible JSON tree with mono keys and color-typed values Keep props minimal and prefer composition with sibling stubs.

Avoid JSON Viewer for long form flows or dense data grids where a dedicated layout block fits better.

Source code

json-viewer.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

type JsonValue = string | number | boolean | null | JsonObject | JsonArray;
type JsonObject = { [key: string]: JsonValue };
type JsonArray = JsonValue[];

export interface JsonViewerProps extends React.HTMLAttributes<HTMLDivElement> {
  value: JsonValue;
  className?: string;
}

function renderJsonValue(
  value: JsonValue,
  depth: number,
  toggle: (path: string) => void,
  collapsed: Record<string, true>,
  path: string,
): React.ReactNode {
  const indent = "  ".repeat(depth);

  if (value === null) {
    return (
      <span key="null" className="text-muted-foreground">
        {indent}null
      </span>
    );
  }

  if (typeof value === "boolean") {
    return (
      <span key="bool" className="text-destructive">
        {indent}{String(value)}
      </span>
    );
  }

  if (typeof value === "number") {
    return (
      <span key="num" className="text-primary-foreground">
        {indent}{String(value)}
      </span>
    );
  }

  if (typeof value === "string") {
    return (
      <span key={`str-${path}`} className="text-accent-foreground">
        {indent}&quot;{value}&quot;
      </span>
    );
  }

  if (Array.isArray(value)) {
    const isCollapsed = collapsed[path] === true;
    return (
      <div key={`arr-${path}`} className="motion-reduce:transition-none">
        <button
          onClick={() => toggle(path)}
          aria-expanded={!isCollapsed}
          aria-label={isCollapsed ? "Expand array" : "Collapse array"}
          className={cn(
            "cursor-pointer text-[10px] uppercase text-muted-foreground hover:text-foreground transition-colors",
            "flex items-center gap-1",
          )}
        >
          <svg
            width="8"
            height="8"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2}
            style={{ transform: isCollapsed ? "rotate(-90deg)" : "none" }}
          >
            <path d="M6 9l6 6 6-6" />
          </svg>
          {depth > 0 ? " collapse" : ""}
        </button>
        <span className="ms-2 text-[10px] text-muted-foreground">{indent}[</span>
        {!isCollapsed && value.map((item, idx) => (
          <React.Fragment key={idx}>
            {renderJsonValue(item, depth + 1, toggle, collapsed, `${path}/${idx}`)}
          </React.Fragment>
        ))}
        <span className="ms-2 text-[10px] text-muted-foreground">{indent}]</span>
      </div>
    );
  }

  if (typeof value === "object") {
    const keys = Object.keys(value);
    const isCollapsed = collapsed[path] === true;
    return (
      <div key={`obj-${path}`} className="motion-reduce:transition-none">
        <button
          onClick={() => toggle(path)}
          aria-expanded={!isCollapsed}
          aria-label={isCollapsed ? "Expand object" : "Collapse object"}
          className={cn(
            "cursor-pointer text-[10px] uppercase text-muted-foreground hover:text-foreground transition-colors",
            "flex items-center gap-1",
          )}
        >
          <svg
            width="8"
            height="8"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2}
            style={{ transform: isCollapsed ? "rotate(-90deg)" : "none" }}
          >
            <path d="M6 9l6 6 6-6" />
          </svg>
          {depth > 0 ? " collapse" : ""}
        </button>
        <span className="ms-2 text-[10px] text-muted-foreground">{indent}{"{"}</span>
        {!isCollapsed && keys.map((key) => (
          <React.Fragment key={key}>
            <span className={cn("text-accent-foreground", "ms-2")}>{key}</span>
            <span className="ms-2 text-[10px] text-muted-foreground">{":"}</span>
            {renderJsonValue(value[key as keyof JsonObject], depth + 1, toggle, collapsed, `${path}/${key}`)}
          </React.Fragment>
        ))}
        <span className="ms-2 text-[10px] text-muted-foreground">{indent}{"}"}</span>
      </div>
    );
  }

  return null;
}

export function JsonViewer({
  value,
  className,
  ...props
}: JsonViewerProps) {
  const [collapsed, setCollapsed] = React.useState<Record<string, true>>({});
  const toggle = React.useCallback((path: string) => {
    setCollapsed((prev) => {
      const next = { ...prev };
      if (next[path]) delete next[path];
      else next[path] = true;
      return next;
    });
  }, []);
  return (
    <div
      className={cn(
        "w-full rounded-md border border-border/60 bg-card p-4",
        "motion-reduce:transition-none",
        className,
      )}
      {...props}
    >
      {renderJsonValue(value, 0, toggle, collapsed, "root")}
    </div>
  );
}

Props

PropTypeDescription
valueJsonValue
className?string