본문으로 건너뛰기
컴포넌트

JSON 뷰어

JSON 데이터를 접고 펼치며 검색하고 복사할 수 있는 트리 뷰어예요.

  • #arc
  • #component
  • #json
  • #tree
  • #code
  • #search
  • #developer

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/json-viewer.json

json-viewer.module.csscss
.root { --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; --indent: 16px; display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }

/* Toolbar: search with a match counter, then expand and collapse all. */
.toolbar { display: flex; align-items: center; gap: 2px; padding: 6px; border-bottom: 1px solid var(--border-subtle); }
/* Focus fills the search field with the accent, like Input; the glass, text, counter and its buttons switch to the accent foreground. */
.search { position: relative; display: flex; box-sizing: border-box; min-width: 0; flex: 1; align-items: center; gap: 4px; min-height: 34px; padding: 0 3px 0 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); cursor: text; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .search:hover:not(:has(.input:focus-visible)) { border-color: var(--foreground); } }
.search:has(.input:focus-visible) { border-color: var(--accent); background: var(--accent); }
.search:has(.input:focus-visible) .searchIcon, .search:has(.input:focus-visible) .tool { color: var(--accent-foreground); }
.search:has(.input:focus-visible) .count { color: var(--accent-foreground-muted); }
.search:has(.input:focus-visible) .input { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.search:has(.input:focus-visible) .input::placeholder { color: var(--accent-foreground-muted); }
.search:has(.input:focus-visible) .tool:active:not(:disabled) { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); }
.searchIcon { flex: none; color: var(--text-muted); }
.input { min-width: 0; flex: 1; height: 32px; padding: 0 4px; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); outline: none; }
.input::placeholder { color: var(--text-muted); }
.input::-webkit-search-cancel-button { display: none; }
.matches { display: inline-flex; flex: none; align-items: center; gap: 0; }
.count { min-width: 3.5ch; padding-inline: 4px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; text-align: right; }
.grow { flex: 1; }
.divider { width: 1px; height: 18px; margin-inline: 4px; background: var(--border-subtle); }
.tool { display: inline-grid; width: 34px; height: 34px; flex: none; place-items: center; padding: 0; border: 0; border-radius: 14px; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.tool:disabled { cursor: default; opacity: .4; }
.tool:active:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }
.search .tool { width: 26px; height: 26px; border-radius: 8px; }
.search .tool:active:not(:disabled) { background: var(--surface-muted); }

/* Tree. Rows are one fixed height, so scrolling can aim at where a row will settle. */
.tree { position: relative; max-height: var(--json-max-height, 420px); padding: 6px; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; font-family: var(--mono); font-size: 13px; font-variant-ligatures: none; letter-spacing: 0; scrollbar-width: thin; }
.row { position: relative; border-radius: 10px; cursor: default; outline: none; -webkit-tap-highlight-color: transparent; }
.highlight { position: absolute; inset: 1px 0; border-radius: 9px; background: var(--surface-muted); }
.line { position: relative; display: flex; height: 30px; min-width: 0; align-items: center; gap: 6px; padding-right: 6px; padding-left: calc(4px + var(--level, 0) * var(--indent)); white-space: nowrap; }
.row[data-kind="node"] .line { cursor: pointer; }

.chevron { display: inline-grid; width: 14px; height: 14px; flex: none; place-items: center; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.chevron[data-open] { transform: rotate(90deg); }
.row[data-active] .chevron[data-branch] { color: var(--foreground); }

.key { flex: none; max-width: 45%; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; }
.index { flex: none; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.colon { flex: none; margin-left: -5px; color: var(--text-muted); }
.summary { display: inline-flex; min-width: 0; align-items: baseline; gap: 8px; overflow: hidden; }
.preview { min-width: 0; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; }
.countLabel { flex: none; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }

/* Type colors lean on the semantic roles, so both themes stay legible. */
.value { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.value[data-type="string"] { color: var(--success); }
.value[data-type="number"] { color: var(--accent); font-variant-numeric: tabular-nums; }
.value[data-type="boolean"] { color: var(--warning); }
.value[data-type="null"], .value[data-type="other"] { color: var(--text-muted); }

.mark { border-radius: 3px; background: color-mix(in oklab, var(--warning) 22%, transparent); color: inherit; box-shadow: 0 0 0 1px color-mix(in oklab, var(--warning) 22%, transparent); transition: background-color var(--duration-fast) var(--ease-standard); }
.mark[data-current] { background: color-mix(in oklab, var(--warning) 48%, transparent); }

.more { display: inline-flex; align-items: baseline; gap: 10px; padding-left: 20px; font-family: var(--font-body); font-size: var(--text-sm); }
.moreLabel { color: var(--foreground); font-weight: 500; }
.row[data-kind="more"] { cursor: pointer; }
.muted { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }

/* Copy actions appear at the row's end over a soft fade, on hover or on the current row. */
/* Inset like the highlight, with its right corners, so the fade never shows a square edge past the row's rounded end. */
.actions { position: absolute; top: 1px; right: 0; bottom: 1px; display: flex; align-items: center; gap: 0; padding-right: 1px; padding-left: 24px; border-radius: 0 9px 9px 0; background: linear-gradient(to right, transparent, var(--surface) 22px); opacity: 0; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }
.row[data-active] .actions { background: linear-gradient(to right, transparent, var(--surface-muted) 22px); }
.action { position: relative; display: inline-grid; width: 26px; height: 26px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.action[data-state="done"] { color: var(--success); }
.action[data-state="error"] { color: var(--danger); }
.action:active { background: color-mix(in oklab, var(--foreground) 8%, transparent); }
.actionGlyph { display: grid; place-items: center; }
.row[data-active] .actions, .row:focus-within .actions { opacity: 1; pointer-events: auto; }

/* The current path, always readable in full through its copy action. */
.pathBar { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-top: 1px solid var(--border-subtle); }
.path { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); font-family: var(--mono); font-size: var(--text-xs); letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.pathType { flex: none; color: var(--text-muted); font-size: var(--text-xs); }
.pathCopy { width: 30px; height: 30px; border-radius: 10px; }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (hover: hover) and (pointer: fine) {
  .row:hover .actions { opacity: 1; pointer-events: auto; }
  /* Same inset and corner as the current-row highlight, so hover and selection share one shape. */
  .row:not([data-active]):hover { --row-hover: color-mix(in oklab, var(--foreground) 3.5%, var(--surface)); }
  .row:not([data-active]):hover::before { content: ""; position: absolute; inset: 1px 0; border-radius: 9px; background: var(--row-hover); pointer-events: none; }
  .row:not([data-active]):hover .actions { background: linear-gradient(to right, transparent, var(--row-hover) 22px); }
  .row:hover .chevron[data-branch] { color: var(--foreground); }
  .tool:hover:not(:disabled), .action:hover { background: var(--surface-muted); color: var(--foreground); }
  .search .tool:hover:not(:disabled) { background: var(--surface-muted); }
  .search:has(.input:focus-visible) .tool:hover:not(:disabled) { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); }
  .action[data-state="done"]:hover { color: var(--success); }
}

@media (max-width: 420px) {
  .tree { --indent: 12px; }
  .key { max-width: 40%; }
  .pathType { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .chevron, .actions, .tool, .action, .mark { transition: none; }
}
json-viewer.tsxtsx
"use client";

import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, LayoutGroup, animate, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, Transition } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { UnfoldLessIcon, UnfoldMoreIcon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./json-viewer.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";

export type JsonValueType = "object" | "array" | "string" | "number" | "boolean" | "null" | "other";

export interface JsonViewerCopyDetail {
  kind: "value" | "path";
  path: string;
  text: string;
}

/**
 * A collapsible tree for JSON. Values are colored by type, branches unfold row by row, search highlights every match and
 * opens the branches that hold them, long arrays and objects load in pages, and each row can copy its value or path.
 * The tree follows the WAI-ARIA tree pattern: arrows move and open, Home and End jump, Enter toggles, and the modifier
 * key with C copies the focused value.
 */
export interface JsonViewerProps {
  data: unknown;
  /** Name of the root in paths, as in `root.users[0].name`. Defaults to "root". */
  rootName?: string;
  /** Levels open on first render when `defaultExpanded` is not set. Defaults to 1, which opens the root. */
  defaultExpandDepth?: number;
  /** Paths of open branches. */
  expanded?: string[];
  defaultExpanded?: string[];
  onExpandedChange?: (paths: string[]) => void;
  /** Show the search field. Defaults to true. */
  searchable?: boolean;
  query?: string;
  defaultQuery?: string;
  onQueryChange?: (query: string) => void;
  /** Children shown per page in a long array or object. Defaults to 50. */
  pageSize?: number;
  /** Show copy value and copy path actions. Defaults to true. */
  copyable?: boolean;
  onCopy?: (detail: JsonViewerCopyDetail) => void;
  /** Called when a row becomes the current one. */
  onSelect?: (detail: { path: string; value: unknown; type: JsonValueType }) => void;
  /** Show the path of the current row under the tree. Defaults to true. */
  showPath?: boolean;
  /** Height of the scrolling tree area. Defaults to 420px. */
  maxHeight?: number | string;
  /** Accessible name of the tree. */
  label?: string;
  className?: string;
}

type Row = {
  id: string;
  kind: "node" | "more";
  level: number;
  name: string | number | null;
  value: unknown;
  type: JsonValueType;
  count: number;
  open: boolean;
  parent: string | null;
  posinset: number;
  setsize: number;
  /** For "more" rows: how many children are still hidden. */
  hidden?: number;
};

const ROW = 30;
const IDENT = /^[A-Za-z_$][\w$]*$/;

function typeOf(value: unknown): JsonValueType {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  switch (typeof value) {
    case "object": return "object";
    case "string": return "string";
    case "number": case "bigint": return "number";
    case "boolean": return "boolean";
    default: return "other";
  }
}
const isBranch = (type: JsonValueType) => type === "object" || type === "array";
function entries(value: unknown): [string | number, unknown][] {
  if (Array.isArray(value)) return value.map((item, index) => [index, item]);
  if (value && typeof value === "object") return Object.entries(value as Record<string, unknown>);
  return [];
}
const childPath = (parent: string, key: string | number) => typeof key === "number" ? `${parent}[${key}]` : IDENT.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
const within = (ancestor: string, id: string) => id === ancestor || id.startsWith(`${ancestor}.`) || id.startsWith(`${ancestor}[`);

function primitiveText(value: unknown, type: JsonValueType) {
  if (type === "string") return value as string;
  if (type === "null") return "null";
  return String(value);
}
function copyText(value: unknown, type: JsonValueType) {
  if (type === "string") return value as string;
  if (isBranch(type)) return JSON.stringify(value, null, 2);
  return primitiveText(value, type);
}
const countLabel = (row: { type: JsonValueType; count: number }) => `${row.count}개 ${row.type === "array" ? "항목" : "키"}`;
const typeLabel: Record<JsonValueType, string> = { object: "객체", array: "배열", string: "문자열", number: "숫자", boolean: "참거짓", null: "비어 있음", other: "기타" };

/** Every container path, for expand all. */
function allBranches(data: unknown, rootName: string) {
  const out: string[] = [];
  const visit = (value: unknown, id: string) => {
    if (!isBranch(typeOf(value))) return;
    out.push(id);
    for (const [key, child] of entries(value)) visit(child, childPath(id, key));
  };
  visit(data, rootName);
  return out;
}
function branchesToDepth(data: unknown, rootName: string, depth: number) {
  const out: string[] = [];
  const visit = (value: unknown, id: string, level: number) => {
    if (level >= depth || !isBranch(typeOf(value))) return;
    out.push(id);
    for (const [key, child] of entries(value)) visit(child, childPath(id, key), level + 1);
  };
  visit(data, rootName, 0);
  return out;
}

type SearchResult = { matches: string[]; ancestors: Set<string>; needed: Map<string, number> };
/** Finds keys and primitive values that contain the query, the branches that hold them, and how far each page must reach. */
function searchJson(data: unknown, rootName: string, needle: string): SearchResult {
  const matches: string[] = [];
  const ancestors = new Set<string>();
  const needed = new Map<string, number>();
  const frames: [string, number][] = [];
  const visit = (value: unknown, id: string, name: string | number | null) => {
    if (matches.length >= 2000) return;
    const type = typeOf(value);
    const keyHit = typeof name === "string" && name.toLowerCase().includes(needle);
    const valueHit = !isBranch(type) && primitiveText(value, type).toLowerCase().includes(needle);
    if (keyHit || valueHit) {
      matches.push(id);
      for (const [frame, index] of frames) {
        ancestors.add(frame);
        needed.set(frame, Math.max(needed.get(frame) ?? 0, index + 1));
      }
    }
    if (!isBranch(type)) return;
    entries(value).forEach(([key, child], index) => {
      frames.push([id, index]);
      visit(child, childPath(id, key), key);
      frames.pop();
    });
  };
  visit(data, rootName, null);
  return { matches, ancestors, needed };
}

function Highlight({ text, needle, current }: { text: string; needle: string; current: boolean }) {
  if (!needle) return <>{text}</>;
  const lower = text.toLowerCase();
  const parts: ReactNode[] = [];
  let from = 0, at = lower.indexOf(needle);
  while (at !== -1) {
    if (at > from) parts.push(text.slice(from, at));
    parts.push(<mark key={at} className={styles.mark} data-current={current || undefined}>{text.slice(at, at + needle.length)}</mark>);
    from = at + needle.length;
    at = lower.indexOf(needle, from);
  }
  if (from < text.length) parts.push(text.slice(from));
  return <>{parts}</>;
}

type CopyState = "idle" | "done" | "error";
/** Icon-only copy action. The glyph swaps in place, so the button never changes width. */
function CopyButton({ label, icon, onCopy, reduced, className, focusable = false }: { label: string; icon: ReactNode; onCopy: () => Promise<void>; reduced: boolean; className?: string; focusable?: boolean }) {
  const [state, setState] = useState<CopyState>("idle");
  const timer = useRef(0);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  const run = async () => {
    window.clearTimeout(timer.current);
    try { await onCopy(); setState("done"); } catch { setState("error"); }
    timer.current = window.setTimeout(() => setState("idle"), 1400);
  };
  const glyph = state === "done" ? <UiIcon iconRole="check" size={14} strokeWidth={2} aria-hidden="true" /> : state === "error" ? <UiIcon iconRole="close" size={14} strokeWidth={2} aria-hidden="true" /> : icon;
  return <button type="button" tabIndex={focusable ? undefined : -1} className={[styles.action, className].filter(Boolean).join(" ")} data-state={state} aria-label={state === "done" ? "복사했어요" : state === "error" ? "복사하지 못했어요" : label} title={label}
    onClick={event => { event.stopPropagation(); void run(); }}>
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span key={state} className={styles.actionGlyph} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }}
        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }}
        transition={reduced ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant }, filter: { duration: motionTokens.duration.instant } }}>{glyph}</motion.span>
    </AnimatePresence>
  </button>;
}

async function writeClipboard(text: string) {
  if (!navigator.clipboard?.writeText) throw new Error("클립보드를 쓸 수 없어요");
  await navigator.clipboard.writeText(text);
}

/* Scroll, row heights and the current-row highlight share one critically damped spring, so a highlight that moves while
 * the tree scrolls travels straight to its row instead of overshooting the viewport. */
const scrollSpring: Transition = motionTokens.spring.smooth;

export const JsonViewer = forwardRef<HTMLDivElement, JsonViewerProps>(function JsonViewer({
  data, rootName = "root", defaultExpandDepth = 1, expanded: expandedProp, defaultExpanded, onExpandedChange,
  searchable = true, query: queryProp, defaultQuery = "", onQueryChange, pageSize = 50, copyable = true, onCopy, onSelect,
  showPath = true, maxHeight = 420, label = "JSON 데이터", className,
}, ref) {
  const reduced = !!useReducedMotion();
  const uid = useId();
  const treeRef = useRef<HTMLDivElement>(null);
  const searchRef = useRef<HTMLInputElement>(null);
  const rowRefs = useRef(new Map<string, HTMLDivElement>());

  /* Open branches. */
  const [expandedInternal, setExpandedInternal] = useState<string[]>(() => defaultExpanded ?? branchesToDepth(data, rootName, defaultExpandDepth));
  const expandedList = expandedProp ?? expandedInternal;
  const base = useMemo(() => new Set(expandedList), [expandedList]);
  const setExpanded = useCallback((next: Set<string>) => {
    const list = [...next];
    if (expandedProp === undefined) setExpandedInternal(list);
    onExpandedChange?.(list);
  }, [expandedProp, onExpandedChange]);

  /* Search. Branches holding a match open on their own; closing one while searching is remembered until the query changes. */
  const [queryInternal, setQueryInternal] = useState(defaultQuery);
  const query = queryProp ?? queryInternal;
  const needle = query.trim().toLowerCase();
  const setQuery = (next: string) => {
    if (queryProp === undefined) setQueryInternal(next);
    onQueryChange?.(next);
  };
  const search = useMemo<SearchResult | null>(() => needle ? searchJson(data, rootName, needle) : null, [data, needle, rootName]);
  const [closedWhileSearching, setClosedWhileSearching] = useState<{ needle: string; ids: Set<string> }>({ needle: "", ids: new Set() });
  const closed = closedWhileSearching.needle === needle ? closedWhileSearching.ids : null;
  const [matchIndex, setMatchIndex] = useState(0);
  const matchCount = search?.matches.length ?? 0;
  const matchSet = useMemo(() => new Set(search?.matches ?? []), [search]);
  const currentMatch = search && matchCount ? search.matches[Math.min(matchIndex, matchCount - 1)] : null;

  const isOpen = useCallback((id: string) => {
    if (search && search.ancestors.has(id)) return !closed?.has(id);
    return base.has(id);
  }, [base, closed, search]);

  /* Pages for long branches. */
  const [pages, setPages] = useState<Record<string, number>>({});
  const limitOf = useCallback((id: string) => Math.max((pages[id] ?? 1) * pageSize, search?.needed.get(id) ?? 0), [pageSize, pages, search]);

  /* The visible rows, flat. Each row unfolds its own height, so nested branches, pages, and expand all share one motion. */
  const rows = useMemo(() => {
    const out: Row[] = [];
    const visit = (value: unknown, id: string, name: string | number | null, level: number, parent: string | null, posinset: number, setsize: number) => {
      const type = typeOf(value);
      const list = isBranch(type) ? entries(value) : [];
      const open = isBranch(type) && isOpen(id);
      out.push({ id, kind: "node", level, name, value, type, count: list.length, open, parent, posinset, setsize });
      if (!open) return;
      const limit = limitOf(id);
      const shown = list.slice(0, limit);
      const more = list.length - shown.length;
      const size = shown.length + (more > 0 ? 1 : 0);
      shown.forEach(([key, child], index) => visit(child, childPath(id, key), key, level + 1, id, index + 1, size));
      if (more > 0) out.push({ id: `${id}::more`, kind: "more", level: level + 1, name: null, value: null, type: "other", count: 0, open: false, parent: id, posinset: size, setsize: size, hidden: more });
    };
    visit(data, rootName, null, 1, null, 1, 1);
    return out;
  }, [data, isOpen, limitOf, rootName]);

  /* The current row. When it is folded away, the nearest visible branch that holds it takes over. */
  const [activeRaw, setActiveRaw] = useState(rootName);
  const active = useMemo(() => {
    if (rows.some(row => row.id === activeRaw)) return activeRaw;
    let best = rootName;
    for (const row of rows) if (row.kind === "node" && within(row.id, activeRaw) && row.id.length > best.length) best = row.id;
    return best;
  }, [activeRaw, rootName, rows]);
  const activeIndex = Math.max(0, rows.findIndex(row => row.id === active));
  /* The current row folded away and a branch above took over. The highlight lands there directly instead of flying from a row that is collapsing out of view. */
  const folded = active !== activeRaw;
  const activeRow = rows[activeIndex];

  const onSelectRef = useRef(onSelect);
  useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);
  const lastReported = useRef<string | null>(null);
  useEffect(() => {
    if (!activeRow || activeRow.kind !== "node" || lastReported.current === activeRow.id) return;
    lastReported.current = activeRow.id;
    onSelectRef.current?.({ path: activeRow.id, value: activeRow.value, type: activeRow.type });
  }, [activeRow]);

  /* Scrolling keeps the current row in view, springing to where it will sit once rows finish unfolding. */
  const scrollFlight = useRef<AnimationPlaybackControls | null>(null);
  const reveal = useCallback((index: number, center: boolean) => {
    const tree = treeRef.current;
    if (!tree) return;
    const top = index * ROW, bottom = top + ROW, view = tree.clientHeight, now = tree.scrollTop;
    let target = now;
    if (center) target = top - view / 2 + ROW / 2;
    else if (top < now + 4) target = top - 4;
    else if (bottom > now + view - 4) target = bottom - view + 4;
    target = Math.max(0, target);
    if (Math.abs(target - now) < 1) return;
    scrollFlight.current?.stop();
    if (reduced) { tree.scrollTop = target; return; }
    scrollFlight.current = animate(now, target, { ...scrollSpring, onUpdate: value => { tree.scrollTop = value; } });
  }, [reduced]);
  useEffect(() => () => scrollFlight.current?.stop(), []);

  const pendingFocus = useRef(false);
  const pendingReveal = useRef<"nearest" | "center" | null>(null);
  /* Bumped on every navigation, so moving to the row that is already current still focuses and reveals it. */
  const [navigation, setNavigation] = useState(0);
  useEffect(() => {
    if (pendingFocus.current) {
      pendingFocus.current = false;
      rowRefs.current.get(active)?.focus({ preventScroll: true });
    }
    if (pendingReveal.current) {
      reveal(activeIndex, pendingReveal.current === "center");
      pendingReveal.current = null;
    }
  }, [active, activeIndex, reveal, navigation]);

  const moveTo = (id: string, focus = true, how: "nearest" | "center" = "nearest") => {
    pendingFocus.current = focus;
    pendingReveal.current = how;
    setActiveRaw(id);
    setNavigation(count => count + 1);
  };

  const toggle = (id: string, next?: boolean) => {
    const open = isOpen(id);
    const want = next ?? !open;
    if (want === open) return;
    playUiSound("tick");
    if (search?.ancestors.has(id)) {
      const ids = new Set(closed ?? []);
      if (want) ids.delete(id); else ids.add(id);
      setClosedWhileSearching({ needle, ids });
      if (want && !base.has(id)) setExpanded(new Set(base).add(id));
      return;
    }
    const nextSet = new Set(base);
    if (want) nextSet.add(id); else nextSet.delete(id);
    setExpanded(nextSet);
  };

  const expandAll = () => {
    playUiSound("tick");
    setExpanded(new Set(allBranches(data, rootName)));
    setClosedWhileSearching({ needle, ids: new Set() });
    // Branches above the current row unfold and push it down; follow it to where it settles.
    moveTo(active, false, "nearest");
  };
  const collapseAll = () => {
    playUiSound("tick");
    const rootOpen = isBranch(typeOf(data)) ? [rootName] : [];
    setExpanded(new Set(rootOpen));
    setClosedWhileSearching({ needle, ids: new Set([...(search?.ancestors ?? [])].filter(id => id !== rootName)) });
    setPages({});
    moveTo(rootName, false, "nearest");
  };
  const showMore = (parent: string) => setPages(current => ({ ...current, [parent]: Math.ceil(limitOf(parent) / pageSize) + 1 }));

  /* Copy. */
  const [announcement, setAnnouncement] = useState("");
  const copy = async (row: Row, kind: "value" | "path") => {
    const text = kind === "path" ? row.id : copyText(row.value, row.type);
    try {
      await writeClipboard(text);
      setAnnouncement(`${row.id} ${kind === "path" ? "경로를" : "값을"} 복사했어요`);
      onCopy?.({ kind, path: row.id, text });
    } catch (error) {
      setAnnouncement("복사하지 못했어요");
      throw error;
    }
  };

  /* Search navigation. */
  const stepMatch = (step: number) => {
    if (!search || !matchCount) return;
    const next = ((Math.min(matchIndex, matchCount - 1) + step) % matchCount + matchCount) % matchCount;
    setMatchIndex(next);
    moveTo(search.matches[next]!, false, "center");
  };
  const onQuery = (next: string) => {
    setQuery(next);
    setMatchIndex(0);
    const trimmed = next.trim().toLowerCase();
    if (trimmed) {
      const first = searchJson(data, rootName, trimmed).matches[0];
      if (first) moveTo(first, false, "center");
    } else if (needle) {
      // Clearing folds the branches search opened. Aim the scroll at where the current row settles, so it stays in view.
      moveTo(activeRaw, false, "nearest");
    }
  };

  const onSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    if (event.nativeEvent.isComposing || event.keyCode === 229) return;
    if (event.key === "Enter") { event.preventDefault(); stepMatch(event.shiftKey ? -1 : 1); }
    else if (event.key === "Escape" && query) { event.preventDefault(); event.stopPropagation(); onQuery(""); }
    else if (event.key === "ArrowDown" && !event.altKey) { event.preventDefault(); moveTo(active, true); }
  };

  const onTreeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const row = rows[activeIndex];
    if (!row || event.altKey) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "c") {
      if (row.kind === "node" && !window.getSelection()?.toString()) { event.preventDefault(); void copy(row, event.shiftKey ? "path" : "value").catch(() => {}); }
      return;
    }
    if (mod) return;
    const go = (index: number) => { event.preventDefault(); const target = rows[Math.max(0, Math.min(rows.length - 1, index))]; if (target) moveTo(target.id); };
    switch (event.key) {
      case "ArrowDown": go(activeIndex + 1); break;
      case "ArrowUp": go(activeIndex - 1); break;
      case "Home": go(0); break;
      case "End": go(rows.length - 1); break;
      case "ArrowRight":
        event.preventDefault();
        if (row.kind === "node" && isBranch(row.type)) { if (!row.open) toggle(row.id, true); else if (row.count) go(activeIndex + 1); }
        break;
      case "ArrowLeft":
        event.preventDefault();
        if (row.kind === "node" && row.open) toggle(row.id, false);
        else if (row.parent) moveTo(row.parent);
        break;
      case "Enter": case " ":
        event.preventDefault();
        if (row.kind === "more") showMore(row.parent!);
        else if (isBranch(row.type)) toggle(row.id);
        break;
      case "/":
        if (searchable) { event.preventDefault(); searchRef.current?.focus(); searchRef.current?.select(); }
        break;
      default:
    }
  };

  const heightStyle = { "--json-max-height": typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight } as CSSProperties;
  const bulk = rows.length > 400;
  const rowTransition: Transition = reduced || bulk ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };

  const describe = (row: Row) => {
    const name = row.name === null ? rootName : String(row.name);
    if (row.kind === "more") return `${Math.min(pageSize, row.hidden ?? 0)}개 더 보기, ${row.hidden}개 숨김`;
    return isBranch(row.type) ? `${name}, ${typeLabel[row.type]}, ${countLabel(row)}` : `${name}: ${primitiveText(row.value, row.type)}`;
  };

  return <div ref={ref} className={[styles.root, className].filter(Boolean).join(" ")} style={heightStyle}>
    {searchable || rows.length > 1 ? <div className={styles.toolbar}>
      {searchable ? <label className={styles.search}>
        <UiIcon iconRole="search" size={15} strokeWidth={1.75} aria-hidden="true" className={styles.searchIcon} />
        <input ref={searchRef} type="search" className={styles.input} value={query} placeholder="키와 값 검색하기" aria-label="JSON 검색하기" aria-controls={`${uid}-tree`}
          onChange={event => onQuery(event.target.value)} onKeyDown={onSearchKeyDown} spellCheck={false} autoComplete="off" />
        <AnimatePresence initial={false}>
          {needle ? <motion.span key="matches" className={styles.matches} initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} transition={{ duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }}>
            <span className={styles.count} aria-live="polite">{matchCount ? `${Math.min(matchIndex, matchCount - 1) + 1}/${matchCount}` : "0/0"}</span>
            <button type="button" className={styles.tool} aria-label="이전 결과" disabled={!matchCount} onClick={() => stepMatch(-1)}><UiIcon iconRole="chevron-up" size={15} strokeWidth={1.75} aria-hidden="true" /></button>
            <button type="button" className={styles.tool} aria-label="다음 결과" disabled={!matchCount} onClick={() => stepMatch(1)}><UiIcon iconRole="chevron-down" size={15} strokeWidth={1.75} aria-hidden="true" /></button>
          </motion.span> : null}
        </AnimatePresence>
      </label> : <span className={styles.grow} />}
      <span className={styles.divider} aria-hidden="true" />
      <button type="button" className={styles.tool} aria-label="모두 펼치기" title="모두 펼치기" onClick={expandAll}><HugeiconsIcon icon={UnfoldMoreIcon} size={15} strokeWidth={1.75} aria-hidden="true" /></button>
      <button type="button" className={styles.tool} aria-label="모두 접기" title="모두 접기" onClick={collapseAll}><HugeiconsIcon icon={UnfoldLessIcon} size={15} strokeWidth={1.75} aria-hidden="true" /></button>
    </div> : null}

    <LayoutGroup id={uid}>
      <motion.div ref={treeRef} layoutScroll id={`${uid}-tree`} role="tree" aria-label={label} className={styles.tree} onKeyDown={onTreeKeyDown}>
        <AnimatePresence initial={false}>
          {rows.map(row => {
            const isActive = row.id === active;
            const branch = isBranch(row.type);
            const matched = !!search && row.kind === "node" && matchSet.has(row.id);
            const isCurrentMatch = row.id === currentMatch;
            const mark = matched ? needle : "";
            return <motion.div key={row.id} ref={(node: HTMLDivElement | null) => { if (node) rowRefs.current.set(row.id, node); else rowRefs.current.delete(row.id); }}
              role="treeitem" tabIndex={isActive ? 0 : -1} aria-level={row.level} aria-posinset={row.posinset} aria-setsize={row.setsize}
              aria-expanded={row.kind === "node" && branch ? row.open : undefined} aria-selected={isActive} aria-label={describe(row)}
              className={styles.row} data-kind={row.kind} data-active={isActive || undefined} style={{ "--level": row.level - 1 } as CSSProperties}
              initial={{ height: 0, opacity: 0, overflow: "hidden" }} animate={{ height: ROW, opacity: 1, transitionEnd: { overflow: "visible" } }} exit={{ height: 0, opacity: 0, overflow: "hidden" }} transition={rowTransition}
              onFocus={event => { if (event.target === event.currentTarget && row.id !== active) setActiveRaw(row.id); }}
              onClick={() => {
                if (row.kind === "more") { showMore(row.parent!); moveTo(row.id); return; }
                if (branch) toggle(row.id);
                moveTo(row.id);
              }}>
              {isActive ? <motion.span layoutId="json-active" className={styles.highlight} transition={reduced || folded ? { duration: 0 } : scrollSpring} aria-hidden="true" /> : null}
              <span className={styles.line}>
                {row.kind === "more" ? <span className={styles.more}>
                  <span className={styles.moreLabel}>{Math.min(pageSize, row.hidden ?? 0)}개 더 보기</span>
                  <span className={styles.muted}>{row.hidden}개 숨김</span>
                </span> : <>
                  <span className={styles.chevron} data-open={row.open || undefined} data-branch={branch || undefined} aria-hidden="true">{branch ? <UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} /> : null}</span>
                  <span className={typeof row.name === "number" ? styles.index : styles.key}>
                    {row.name === null ? rootName : typeof row.name === "number" ? row.name : <Highlight text={row.name} needle={mark} current={isCurrentMatch} />}
                  </span>
                  <span className={styles.colon} aria-hidden="true">{row.name === null && branch ? "" : ":"}</span>
                  {branch
                    ? <span className={styles.summary}>
                        {!row.open ? <span className={styles.preview}>{row.type === "array" ? "[…]" : `{ ${entries(row.value).slice(0, 3).map(([key]) => key).join(", ")}${row.count > 3 ? ", …" : ""} }`}</span> : null}
                        <span className={styles.countLabel}>{countLabel(row)}</span>
                      </span>
                    : <span className={styles.value} data-type={row.type} title={row.type === "string" && (row.value as string).length > 40 ? row.value as string : undefined}>
                        {row.type === "string" ? <>&quot;<Highlight text={row.value as string} needle={mark} current={isCurrentMatch} />&quot;</> : <Highlight text={primitiveText(row.value, row.type)} needle={mark} current={isCurrentMatch} />}
                      </span>}
                  {copyable ? <span className={styles.actions}>
                    <CopyButton label="값 복사하기" icon={<UiIcon iconRole="copy" size={14} strokeWidth={1.75} aria-hidden="true" />} reduced={reduced} onCopy={() => copy(row, "value")} />
                    <CopyButton label="경로 복사하기" icon={<UiIcon iconRole="link" size={14} strokeWidth={1.75} aria-hidden="true" />} reduced={reduced} onCopy={() => copy(row, "path")} />
                  </span> : null}
                </>}
              </span>
            </motion.div>;
          })}
        </AnimatePresence>
      </motion.div>
    </LayoutGroup>

    {showPath && activeRow ? <div className={styles.pathBar}>
      <code className={styles.path} title={activeRow.kind === "node" ? activeRow.id : activeRow.parent ?? ""}>{activeRow.kind === "node" ? activeRow.id : activeRow.parent}</code>
      <span className={styles.pathType}>{activeRow.kind === "node" ? isBranch(activeRow.type) ? `${typeLabel[activeRow.type]}, ${countLabel(activeRow)}` : typeLabel[activeRow.type] : "더 보기"}</span>
      {copyable && activeRow.kind === "node" ? <CopyButton key={activeRow.id} focusable className={styles.pathCopy} label="경로 복사하기" icon={<UiIcon iconRole="copy" size={14} strokeWidth={1.75} aria-hidden="true" />} reduced={reduced} onCopy={() => copy(activeRow, "path")} /> : null}
    </div> : null}
    <span className={styles.srOnly} role="status">{announcement}</span>
  </div>;
});

export default JsonViewer;