{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "empty-state-sonar",
  "title": "Empty State Sonar",
  "description": "An empty state that pings like sonar every 6-8s to actively demonstrate emptiness, then freezes and grows skeleton rows outward the instant real results interrupt it.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/empty-state-sonar/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// EchoSound — an empty state that behaves like sonar, not an illustration\n// waiting to be swapped for a skeleton. While nothing has resolved, a thin\n// SVG ring (stroked in --border) expands calmly from the stage's center on a\n// slow, lightly-randomized 6-8s cadence, finds nothing, and fades — the same\n// object every cycle, so emptiness is demonstrated rather than captioned.\n//\n// The instant data arrives, that same ring is interrupted mid-flight: its\n// current radius/opacity is read once (getComputedStyle) and re-applied\n// inline so nothing jumps, the CSS keyframe is swapped for a single CSS\n// transition to the radius that reaches row one, and it holds there. Rows\n// then scale in from that shared center point at a 60ms stagger, ease-out,\n// before crossfading into the real content in place. Two timeline handoffs\n// (ping -> contact, contact -> resolved) — no rAF loop, no canvas.\n//\n// Distinct from status-glyph-cadence: that is a small inline status lamp whose\n// cadence itself IS the message, blinking in place forever. This is a full\n// content region — the ring is a spatial probe INTO that region, and it is\n// the arriving content, not a timer, that interrupts and answers it.\n// ---------------------------------------------------------------------------\n\nexport interface EchoSoundItem {\n  id: string | number;\n  content: ReactNode;\n}\n\nexport interface EchoSoundProps {\n  /** null = nothing has resolved yet (the ring probes). An array — including\n   * an empty one — means the search settled; non-empty triggers the reveal. */\n  items: EchoSoundItem[] | null;\n  /** Named in the empty-state copy: `No results for \"${query}\"`. */\n  query?: string;\n  /** Row height in px — also what the ring's contact radius targets. */\n  rowHeight?: number;\n  /** Gap between rows, px. */\n  rowGap?: number;\n  /** Rows the stage reserves height for while probing (usually your expected count). */\n  stageRows?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ntype Phase = \"empty\" | \"arriving\" | \"loaded\";\n\nconst STAGGER_MS = 60;\nconst ROW_REVEAL_MS = 420;\nconst CONTACT_HOLD_MS = 380;\nconst CROSSFADE_MS = 240;\nconst SETTLE_HOLD_MS = 220;\nconst MAX_STAGGERED_ROWS = 10;\n\nconst CSS = `\n.ns-echo-ring{transform-box:fill-box}\n.ns-echo-pinging{animation:ns-echo-ping var(--ns-echo-period,7000ms) cubic-bezier(.32,.72,.35,1) infinite}\n@keyframes ns-echo-ping{\n  0%{r:3;stroke-opacity:.85}\n  70%{stroke-opacity:.18}\n  100%{r:47;stroke-opacity:0}\n}\n.ns-echo-row{position:relative}\n.ns-echo-skel{\n  position:absolute;inset:0;border-radius:8px;background:var(--border);\n  transform:scaleX(0);transform-origin:50% 50%;opacity:0;\n}\n.ns-echo-row-in .ns-echo-skel{\n  animation:ns-echo-row-in ${ROW_REVEAL_MS}ms cubic-bezier(.16,1,.3,1) forwards;\n}\n@keyframes ns-echo-row-in{\n  from{transform:scaleX(0);opacity:0}\n  to{transform:scaleX(1);opacity:1}\n}\n.ns-echo-loaded .ns-echo-skel{opacity:0;transition:opacity ${CROSSFADE_MS}ms ease-out}\n.ns-echo-content{\n  position:absolute;inset:0;opacity:0;transition:opacity ${CROSSFADE_MS}ms ease-out;\n}\n.ns-echo-loaded .ns-echo-content{opacity:1}\n@media (prefers-reduced-motion: reduce){\n  .ns-echo-pinging{animation:none}\n  .ns-echo-row-in .ns-echo-skel{animation:none}\n  .ns-echo-content,.ns-echo-loaded .ns-echo-skel{transition:none}\n}\n`;\n\n// Radius (in the ring's 0-100 viewBox space) whose upward reach lands exactly\n// on row one's vertical center — pure arithmetic from known row geometry, no\n// DOM measurement needed. The viewBox scales non-uniformly onto the stage\n// (preserveAspectRatio=\"none\"), so this fraction maps correctly regardless of\n// the stage's actual pixel width/height.\nfunction contactRadius(rowHeight: number, rowGap: number, stageRows: number): number {\n  const rows = Math.max(1, stageRows);\n  const stageHeight = rows * rowHeight + Math.max(0, rows - 1) * rowGap;\n  const row1CenterPct = (rowHeight / 2 / stageHeight) * 100;\n  return Math.max(6, 50 - row1CenterPct);\n}\n\nexport function EchoSound({\n  items,\n  query = \"\",\n  rowHeight = 52,\n  rowGap = 10,\n  stageRows = 4,\n  className = \"\",\n}: EchoSoundProps) {\n  const labelId = useId();\n  const ringRef = useRef<SVGCircleElement | null>(null);\n  const timers = useRef<number[]>([]);\n  const [reduced, setReduced] = useState(false);\n  const [phase, setPhase] = useState<Phase>(items && items.length > 0 ? \"loaded\" : \"empty\");\n  const [revealCount, setRevealCount] = useState(0);\n  const [announce, setAnnounce] = useState(\"\");\n  const [displayItems, setDisplayItems] = useState<EchoSoundItem[]>(items ?? []);\n  const wasEmpty = useRef(phase === \"empty\");\n  // 7000ms until the client settles on its own randomized 6-8s cadence post-\n  // mount — picking the random value during the initial render would differ\n  // between server and client and trip a hydration mismatch.\n  const [period, setPeriod] = useState(7000);\n\n  useEffect(() => {\n    setPeriod(Math.round(6000 + Math.random() * 2000));\n  }, []);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onChange = () => setReduced(mq.matches);\n    onChange();\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    const clearTimers = () => {\n      timers.current.forEach((t) => window.clearTimeout(t));\n      timers.current = [];\n    };\n\n    const hasItems = !!items && items.length > 0;\n\n    if (!hasItems) {\n      clearTimers();\n      // release any inline overrides left by a previous freeze so the CSS\n      // keyframe class governs the ring again on the next empty stretch\n      const ring = ringRef.current;\n      if (ring) {\n        ring.style.removeProperty(\"animation\");\n        ring.style.removeProperty(\"transition\");\n        ring.style.removeProperty(\"r\");\n        ring.style.removeProperty(\"stroke-opacity\");\n      }\n      setPhase(\"empty\");\n      setRevealCount(0);\n      wasEmpty.current = true;\n      return clearTimers;\n    }\n\n    if (!wasEmpty.current) {\n      // already showing content — a fresh batch just swaps in, no replay\n      setDisplayItems(items!);\n      setPhase(\"loaded\");\n      return clearTimers;\n    }\n\n    wasEmpty.current = false;\n    setAnnounce(`${items!.length} result${items!.length === 1 ? \"\" : \"s\"} loaded`);\n    setDisplayItems(items!);\n\n    if (reduced) {\n      setPhase(\"loaded\");\n      return clearTimers;\n    }\n\n    setPhase(\"arriving\");\n\n    // handoff 1 — ping to contact: snapshot the ring's mid-flight radius and\n    // opacity, freeze them inline (so nothing jumps), then hand off to a\n    // single CSS transition to the row-one contact radius. One read, one\n    // write; no per-frame loop.\n    const ring = ringRef.current;\n    if (ring) {\n      const cs = getComputedStyle(ring);\n      const snapR = cs.getPropertyValue(\"r\") || \"3px\";\n      const snapOpacity = cs.getPropertyValue(\"stroke-opacity\") || \"1\";\n      ring.style.animation = \"none\";\n      ring.style.setProperty(\"r\", snapR);\n      ring.style.setProperty(\"stroke-opacity\", snapOpacity);\n      void ring.getBoundingClientRect(); // commit the frozen values before transitioning\n      ring.style.transition = `r ${CONTACT_HOLD_MS}ms cubic-bezier(.16,1,.3,1), stroke-opacity ${CONTACT_HOLD_MS}ms ease-out`;\n      const targetR = contactRadius(rowHeight, rowGap, stageRows);\n      requestAnimationFrame(() => {\n        ring.style.setProperty(\"r\", `${targetR}px`);\n        ring.style.setProperty(\"stroke-opacity\", \"0.55\");\n      });\n    }\n\n    // handoff 2 — contact to resolved: rows scale in one at a time, then the\n    // whole row crossfades from skeleton to real content.\n    const rowCount = Math.min(items!.length, MAX_STAGGERED_ROWS);\n    let revealed = 0;\n    const stepRow = () => {\n      revealed += 1;\n      setRevealCount(revealed);\n      if (revealed < rowCount) {\n        timers.current.push(window.setTimeout(stepRow, STAGGER_MS));\n      }\n    };\n    timers.current.push(\n      window.setTimeout(stepRow, CONTACT_HOLD_MS),\n      window.setTimeout(\n        () => setPhase(\"loaded\"),\n        CONTACT_HOLD_MS + rowCount * STAGGER_MS + ROW_REVEAL_MS + SETTLE_HOLD_MS\n      )\n    );\n\n    return clearTimers;\n    // items is an external reference the caller owns; reduced/rowHeight/rowGap/\n    // stageRows rarely change mid-flight and re-running this is idempotent\n    // once wasEmpty guards a replay.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [items, reduced, rowHeight, rowGap, stageRows]);\n\n  useEffect(\n    () => () => {\n      timers.current.forEach((t) => window.clearTimeout(t));\n    },\n    []\n  );\n\n  const emptyText = query ? `No results for \"${query}\"` : \"Nothing here yet\";\n  const loadedText = `${displayItems.length} result${displayItems.length === 1 ? \"\" : \"s\"}${\n    query ? ` for \"${query}\"` : \"\"\n  }`;\n  const stageHeight = stageRows * rowHeight + Math.max(0, stageRows - 1) * rowGap;\n\n  return (\n    <div className={`ns-echo relative w-full ${className}`}>\n      <style>{CSS}</style>\n\n      <div role=\"region\" aria-labelledby={labelId} className=\"flex w-full flex-col items-center gap-4\">\n        <div className=\"relative w-full\" style={{ height: reduced ? undefined : stageHeight }}>\n          {phase !== \"loaded\" && !reduced && (\n            <svg\n              aria-hidden=\"true\"\n              focusable=\"false\"\n              className=\"absolute inset-0 h-full w-full\"\n              viewBox=\"0 0 100 100\"\n              preserveAspectRatio=\"none\"\n            >\n              <circle\n                ref={ringRef}\n                r={3}\n                cx={50}\n                cy={50}\n                vectorEffect=\"non-scaling-stroke\"\n                className={phase === \"empty\" ? \"ns-echo-ring ns-echo-pinging\" : \"ns-echo-ring\"}\n                style={\n                  {\n                    fill: \"none\",\n                    stroke: \"var(--border)\",\n                    strokeWidth: 1,\n                    \"--ns-echo-period\": `${period}ms`,\n                  } as CSSProperties\n                }\n              />\n            </svg>\n          )}\n\n          {(phase === \"arriving\" || phase === \"loaded\") && (\n            <ul\n              className={`relative flex w-full list-none flex-col ${phase === \"loaded\" ? \"ns-echo-loaded\" : \"\"}`}\n              style={{ gap: rowGap }}\n            >\n              {displayItems.map((item, i) => (\n                <li\n                  key={item.id}\n                  className={`ns-echo-row ${phase === \"loaded\" || i < revealCount ? \"ns-echo-row-in\" : \"\"}`}\n                  style={{ height: rowHeight }}\n                >\n                  <div className=\"ns-echo-skel\" aria-hidden=\"true\" />\n                  <div className=\"ns-echo-content\" aria-hidden={phase !== \"loaded\"}>\n                    {item.content}\n                  </div>\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n\n        <p id={labelId} className={phase === \"empty\" ? \"text-center text-sm text-ns-muted\" : \"sr-only\"}>\n          {phase === \"empty\" ? emptyText : loadedText}\n        </p>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/empty-state-sonar.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "empty-state",
      "skeleton",
      "loading",
      "list",
      "search",
      "svg",
      "aria-live",
      "sonar"
    ],
    "instruction": "A list/search result region, `<EchoSound items={items} query={query} .../>`, that unifies empty, loading and loaded into one continuous vocabulary instead of an illustration empty state handing off jarringly to an unrelated skeleton loader. `items` is the whole state machine: `null` means nothing has resolved yet and the region probes; a non-empty array means results landed. While probing, a single SVG circle (stroked in --border, fill none, vector-effect non-scaling-stroke) sits centered in a stage sized to the expected row count, and CSS keyframes alone (no rAF) grow its `r` from 3 to 47 and fade its stroke-opacity from .85 to 0 over a per-mount randomized 6-8s cycle, restarting forever — calm and continuous, never a quick blip. The stage's SVG uses preserveAspectRatio=none against a 0-100 viewBox, so the ring reads as a probe reaching toward the stage's real proportions rather than a decorative fixed icon. The instant `items` flips from empty to populated, the SAME ring is interrupted: its current mid-flight radius and stroke-opacity are read once via getComputedStyle and re-applied inline (so nothing jumps), the keyframe class is dropped, and a single CSS transition carries it to the exact radius that reaches row one's vertical center — a radius computed analytically from rowHeight/rowGap/stageRows, no DOM measurement needed. The ring holds there while skeleton rows (flat --border bars) scale in from their own center point one at a time at a 60ms stagger with ease-out-expo, each row then crossfading its skeleton bar to the real rendered item over 240ms once every row has appeared and settled briefly. That is the entire mechanism: two timeline handoffs (ping-to-contact, contact-to-resolved) driven by a handful of setTimeouts, zero per-frame JS, zero canvas. If items empties again (a new, zero-result search) the region resets straight back to probing with no reverse animation; if items changes while already loaded, the new batch swaps in place without replaying the reveal. Accessibility: the region carries aria-labelledby pointing at a single persistent paragraph that visibly reads 'No results for \"{query}\"' while probing and becomes the sr-only accessible name once loaded (same node throughout, so the region's name is always literally the empty-state text or its resolved successor); the ring is aria-hidden; skeleton bars are permanently aria-hidden and the row's real content is aria-hidden until the crossfade completes so nothing is announced early; arrival fires exactly one polite aria-live announcement, 'N results loaded', at the moment data lands, not per row; focus is never moved automatically. Under prefers-reduced-motion the ring never renders and the reveal never plays — the empty paragraph is simply replaced by the resolved list the instant items arrive, no ping, no stagger. Differs from status-glyph-cadence: that is a 20-64px inline status lamp whose cadence blinks forever to encode a state; this is a full content region where the ring is a spatial probe that the arriving data itself interrupts and answers, and the reveal choreography is the same SVG object as the empty state, not a swapped-in skeleton loader."
  },
  "type": "registry:ui"
}