shadimage
vernostudio
uijson-viewsource: ok · refreshed

JSON View

A collapsible tree for inspecting a JSON payload, with syntax roles, search highlighting and the full ARIA tree keyboard model.

npx shadcn add "https://verno-studio.vercel.app/r/json-view.json"
free

Where it goes

kanban

Interaction

accordion

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 8:07:42 AM

components/json-view.tsx
"use client";

import { useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";

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

interface JsonViewProps {
  /** The value to render. Pass the object or array itself, never a JSON string. */
  readonly data: unknown;
  /** 0 collapses everything, 1 opens the first level. Deeper values open further. */
  readonly defaultExpandDepth?: number;
  /** Only while a search is active; `null` the rest of the time. */
  readonly highlightPattern?: RegExp | null;
  readonly className?: string;
}

interface JsonNode {
  readonly id: string;
  readonly label: string | null;
  readonly value: unknown;
  readonly depth: number;
  readonly children: readonly JsonNode[] | null;
}

const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/gu;

/** Every metacharacter here is a syntax character, so `\x` stays legal under `u`. */
const escapeRegExp = (value: string) => value.replace(REGEXP_SPECIAL, "\\$&");

/**
 * Builds the pattern `JsonView` highlights with. Terms are matched literally, so
 * a search for `a.b` cannot turn into a wildcard.
 */
export const makeJsonViewHighlightPattern = (terms: readonly string[]): RegExp | null => {
  const cleaned = terms.flatMap((term) => {
    const trimmed = term.trim();

    return trimmed ? [trimmed] : [];
  });

  if (cleaned.length === 0) {
    return null;
  }

  return new RegExp(cleaned.map(escapeRegExp).join("|"), "giu");
};

const isContainer = (value: unknown): value is Record<string, unknown> | unknown[] =>
  typeof value === "object" && value !== null;

const entriesOf = (value: Record<string, unknown> | unknown[]): [string, unknown][] =>
  Array.isArray(value) ? value.map((item, index) => [String(index), item]) : Object.entries(value);

const buildNode = (value: unknown, id: string, label: string | null, depth: number): JsonNode => {
  if (!isContainer(value)) {
    return { children: null, depth, id, label, value };
  }

  const entries = entriesOf(value);

  return {
    // An empty object has nothing to disclose, so it stays a leaf rather than
    // an expandable node whose group would be empty.
    children:
      entries.length === 0
        ? null
        : entries.map(([key, child]) => buildNode(child, `${id}.${key}`, key, depth + 1)),
    depth,
    id,
    label,
    value,
  };
};

const collectExpanded = (node: JsonNode, maxDepth: number, into: Set<string>): Set<string> => {
  if (node.children && node.depth < maxDepth) {
    into.add(node.id);

    for (const child of node.children) {
      collectExpanded(child, maxDepth, into);
    }
  }

  return into;
};

const flatten = (node: JsonNode, expanded: ReadonlySet<string>, into: JsonNode[]): JsonNode[] => {
  into.push(node);

  if (node.children && expanded.has(node.id)) {
    for (const child of node.children) {
      flatten(child, expanded, into);
    }
  }

  return into;
};

const bracketsOf = (value: unknown) =>
  Array.isArray(value) ? (["[", "]"] as const) : (["{", "}"] as const);

const toneOf = (value: unknown) => {
  if (typeof value === "string") {
    return "text-json-string";
  }
  if (typeof value === "number") {
    return "text-json-number";
  }
  if (typeof value === "boolean") {
    return "text-json-boolean";
  }
  return "text-gray-900";
};

const printValue = (value: unknown) =>
  typeof value === "string" ? JSON.stringify(value) : String(value);

const countLabel = (count: number) => (count === 1 ? "1 item" : `${count} items`);

const describe = (node: JsonNode) => {
  const name = node.label ?? "JSON";

  if (!node.children) {
    return isContainer(node.value)
      ? `${name}: empty ${Array.isArray(node.value) ? "array" : "object"}`
      : `${name}: ${printValue(node.value)}`;
  }

  const kind = Array.isArray(node.value) ? "array" : "object";

  return `${name}: ${kind}, ${countLabel(node.children.length)}`;
};

/** Wraps every match in a `<mark>` so a search can point at what it found. */
const Highlight = ({
  text,
  pattern,
}: {
  readonly text: string;
  readonly pattern?: RegExp | null;
}) => {
  if (!pattern) {
    return text;
  }

  const global = pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
  const parts: React.ReactNode[] = [];
  let cursor = 0;

  for (const match of text.matchAll(global)) {
    const at = match.index;

    // A pattern that can match nothing would otherwise loop forever.
    if (match[0].length === 0) {
      continue;
    }

    if (at > cursor) {
      parts.push(text.slice(cursor, at));
    }

    parts.push(
      <mark className="rounded-xs bg-json-highlight text-json-highlight-foreground" key={at}>
        {match[0]}
      </mark>,
    );
    cursor = at + match[0].length;
  }

  if (parts.length === 0) {
    return text;
  }

  if (cursor < text.length) {
    parts.push(text.slice(cursor));
  }

  return parts;
};

const Chevron = ({ open }: { readonly open: boolean }) => (
  <svg
    aria-hidden="true"
    className={cn(
      "size-4 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
      open && "rotate-90",
    )}
    fill="none"
    viewBox="0 0 16 16"
  >
    <path
      clipRule="evenodd"
      d="M6.75 3.94L7.28 4.47L10.1 7.29C10.49 7.68 10.49 8.32 10.1 8.71L7.28 11.53L6.75 12.06L5.69 11L6.22 10.47L8.69 8L6.22 5.53L5.69 5L6.75 3.94Z"
      fill="currentColor"
      fillRule="evenodd"
    />
  </svg>
);

export const JsonView = ({
  data,
  defaultExpandDepth = 1,
  highlightPattern = null,
  className,
}: JsonViewProps) => {
  const root = useMemo(() => buildNode(data, "$", null, 0), [data]);
  const [expanded, setExpanded] = useState(() =>
    collectExpanded(root, defaultExpandDepth, new Set<string>()),
  );
  const [activeId, setActiveId] = useState(root.id);
  // Built once. `useRef(new Map())` would allocate a Map on every render and
  // throw it away, since only the first argument is ever kept.
  const nodesRef = useRef<Map<string, HTMLDivElement> | null>(null);
  nodesRef.current ??= new Map();
  const nodes = nodesRef.current;

  const visible = useMemo(() => flatten(root, expanded, []), [root, expanded]);
  // A collapse can strip the focused node out of the tree; without this the
  // roving tabindex would land on nothing and the whole tree would leave the
  // tab order.
  const active = visible.some((node) => node.id === activeId) ? activeId : root.id;

  const toggle = (node: JsonNode, open?: boolean) => {
    if (!node.children) {
      return;
    }

    setExpanded((current) => {
      const next = new Set(current);
      const shouldOpen = open ?? !next.has(node.id);

      if (shouldOpen) {
        next.add(node.id);
      } else {
        next.delete(node.id);
      }

      return next;
    });
  };

  const focus = (id: string) => {
    setActiveId(id);
    nodes.get(id)?.focus();
  };

  // The node comes from the row the key was pressed on, not from state. Reading
  // `active` here meant the handler bound in the previous render answered with
  // the previously focused node, so the first arrow after a move was eaten.
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>, node: JsonNode) => {
    const index = visible.findIndex((entry) => entry.id === node.id);

    if (index === -1) {
      return;
    }

    // Every treeitem carries this handler, so an unclaimed keystroke would run
    // again on each ancestor on the way up. That is harmless for a move, but a
    // toggle would flip straight back.
    const claim = () => {
      event.preventDefault();
      event.stopPropagation();
    };

    const step = (to: number) => {
      const target = visible[to];

      if (target) {
        claim();
        focus(target.id);
      }
    };

    switch (event.key) {
      case "ArrowDown": {
        step(index + 1);
        break;
      }
      case "ArrowUp": {
        step(index - 1);
        break;
      }
      case "ArrowRight": {
        claim();
        // APG: the first press opens the node, the second walks into it, so a
        // node never expands and loses focus in the same keystroke.
        if (node.children && !expanded.has(node.id)) {
          toggle(node, true);
        } else if (node.children) {
          step(index + 1);
        }
        break;
      }
      case "ArrowLeft": {
        claim();
        if (node.children && expanded.has(node.id)) {
          toggle(node, false);
        } else {
          for (let at = index - 1; at >= 0; at -= 1) {
            const candidate = visible[at];

            if (candidate && candidate.depth < node.depth) {
              focus(candidate.id);
              break;
            }
          }
        }
        break;
      }
      case "Home": {
        step(0);
        break;
      }
      case "End": {
        step(visible.length - 1);
        break;
      }
      case "Enter":
      case " ": {
        if (node.children) {
          claim();
          toggle(node);
        }
        break;
      }
      default: {
        break;
      }
    }
  };

  const renderNode = (node: JsonNode, position: number, size: number) => {
    const open = Boolean(node.children) && expanded.has(node.id);
    const [openBracket, closeBracket] = bracketsOf(node.value);
    const container = isContainer(node.value);

    return (
      <div
        aria-expanded={node.children ? open : undefined}
        aria-label={describe(node)}
        aria-level={node.depth + 1}
        aria-posinset={position}
        aria-setsize={size}
        // Indent by depth, not by position: `first:` would match the first
        // child of every group and flatten one row at each level.
        className={cn("group", node.depth > 0 && "ps-4")}
        key={node.id}
        onClick={(event) => {
          // The treeitem box wraps its own subtree, so without this a click on
          // a child would toggle every ancestor on the way up.
          event.stopPropagation();
          toggle(node);
        }}
        onFocus={() => setActiveId(node.id)}
        onKeyDown={(event) => onKeyDown(event, node)}
        // A second click on a row that opens is a toggle, not a word select.
        // Drag still selects, and a leaf still selects on double click.
        onMouseDown={(event) => {
          event.stopPropagation();
          if (node.children && event.detail > 1) {
            event.preventDefault();
          }
        }}
        ref={(element) => {
          if (element) {
            nodes.set(node.id, element);
          } else {
            nodes.delete(node.id);
          }
        }}
        role="treeitem"
        tabIndex={node.id === active ? 0 : -1}
      >
        <div
          className={cn(
            "flex min-h-6 items-start gap-1 rounded-sm py-0.5",
            "group-focus-visible:outline-2 group-focus-visible:outline-solid group-focus-visible:outline-gray-1000 group-focus-visible:outline-offset-1",
            node.children &&
              "cursor-pointer touch-manipulation [@media(hover:hover)_and_(pointer:fine)]:hover:bg-gray-100",
          )}
        >
          <span
            aria-hidden="true"
            className={cn(
              "flex size-4 shrink-0 translate-y-0.5 items-center justify-center text-gray-700",
              !node.children && "invisible",
            )}
          >
            <Chevron open={open} />
          </span>
          <span className="min-w-0 wrap-anywhere">
            {node.label === null ? null : (
              <span className="text-json-key">
                <Highlight pattern={highlightPattern} text={node.label} />
                <span className="text-gray-1000">: </span>
              </span>
            )}
            {container ? (
              <span className="text-gray-1000">
                {openBracket}
                {node.children && !open ? <span className="text-gray-900">…</span> : null}
                {node.children && open ? null : closeBracket}
              </span>
            ) : (
              <span className={toneOf(node.value)}>
                <Highlight pattern={highlightPattern} text={printValue(node.value)} />
              </span>
            )}
          </span>
        </div>

        {node.children && open ? (
          <>
            {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a tree's child list is role="group" per the ARIA APG; the suggested tags all carry the wrong semantics here */}
            <div className="m-0 p-0" role="group">
              {node.children.map((child, index) =>
                renderNode(child, index + 1, node.children?.length ?? 0),
              )}
            </div>
            <div aria-hidden="true" className="min-h-6 py-0.5 ps-5 text-gray-1000">
              {closeBracket}
            </div>
          </>
        ) : null}
      </div>
    );
  };

  return (
    <div
      aria-label="JSON"
      className={cn(
        "w-full font-mono text-xs/5 text-gray-1000 [font-variant-ligatures:none]",
        className,
      )}
      role="tree"
      // The tree is reached through whichever treeitem currently holds the
      // roving 0, so the container itself stays out of the tab order.
      tabIndex={-1}
    >
      {renderNode(root, 1, 1)}
    </div>
  );
};

More from vernostudio

Browse all components in this library →