Empty State Sonar

Empty state

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.

Install
npx shadcn add https://design.helpmarq.com/r/empty-state-sonar.json
Source
registry/core/empty-state-sonar/component.tsx
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";

// ---------------------------------------------------------------------------
// EchoSound — an empty state that behaves like sonar, not an illustration
// waiting to be swapped for a skeleton. While nothing has resolved, a thin
// SVG ring (stroked in --border) expands calmly from the stage's center on a
// slow, lightly-randomized 6-8s cadence, finds nothing, and fades — the same
// object every cycle, so emptiness is demonstrated rather than captioned.
//
// The instant data arrives, that same ring is interrupted mid-flight: its
// current radius/opacity is read once (getComputedStyle) and re-applied
// inline so nothing jumps, the CSS keyframe is swapped for a single CSS
// transition to the radius that reaches row one, and it holds there. Rows
// then scale in from that shared center point at a 60ms stagger, ease-out,
// before crossfading into the real content in place. Two timeline handoffs
// (ping -> contact, contact -> resolved) — no rAF loop, no canvas.
//
// Distinct from status-glyph-cadence: that is a small inline status lamp whose
// cadence itself IS the message, blinking in place forever. This is a full
// content region — the ring is a spatial probe INTO that region, and it is
// the arriving content, not a timer, that interrupts and answers it.
// ---------------------------------------------------------------------------

export interface EchoSoundItem {
  id: string | number;
  content: ReactNode;
}

export interface EchoSoundProps {
  /** null = nothing has resolved yet (the ring probes). An array — including
   * an empty one — means the search settled; non-empty triggers the reveal. */
  items: EchoSoundItem[] | null;
  /** Named in the empty-state copy: `No results for "${query}"`. */
  query?: string;
  /** Row height in px — also what the ring's contact radius targets. */
  rowHeight?: number;
  /** Gap between rows, px. */
  rowGap?: number;
  /** Rows the stage reserves height for while probing (usually your expected count). */
  stageRows?: number;
  className?: string;
}

type Phase = "empty" | "arriving" | "loaded";

const STAGGER_MS = 60;
const ROW_REVEAL_MS = 420;
const CONTACT_HOLD_MS = 380;
const CROSSFADE_MS = 240;
const SETTLE_HOLD_MS = 220;
const MAX_STAGGERED_ROWS = 10;

const CSS = `
.ns-echo-ring{transform-box:fill-box}
.ns-echo-pinging{animation:ns-echo-ping var(--ns-echo-period,7000ms) cubic-bezier(.32,.72,.35,1) infinite}
@keyframes ns-echo-ping{
  0%{r:3;stroke-opacity:.85}
  70%{stroke-opacity:.18}
  100%{r:47;stroke-opacity:0}
}
.ns-echo-row{position:relative}
.ns-echo-skel{
  position:absolute;inset:0;border-radius:8px;background:var(--border);
  transform:scaleX(0);transform-origin:50% 50%;opacity:0;
}
.ns-echo-row-in .ns-echo-skel{
  animation:ns-echo-row-in ${ROW_REVEAL_MS}ms cubic-bezier(.16,1,.3,1) forwards;
}
@keyframes ns-echo-row-in{
  from{transform:scaleX(0);opacity:0}
  to{transform:scaleX(1);opacity:1}
}
.ns-echo-loaded .ns-echo-skel{opacity:0;transition:opacity ${CROSSFADE_MS}ms ease-out}
.ns-echo-content{
  position:absolute;inset:0;opacity:0;transition:opacity ${CROSSFADE_MS}ms ease-out;
}
.ns-echo-loaded .ns-echo-content{opacity:1}
@media (prefers-reduced-motion: reduce){
  .ns-echo-pinging{animation:none}
  .ns-echo-row-in .ns-echo-skel{animation:none}
  .ns-echo-content,.ns-echo-loaded .ns-echo-skel{transition:none}
}
`;

// Radius (in the ring's 0-100 viewBox space) whose upward reach lands exactly
// on row one's vertical center — pure arithmetic from known row geometry, no
// DOM measurement needed. The viewBox scales non-uniformly onto the stage
// (preserveAspectRatio="none"), so this fraction maps correctly regardless of
// the stage's actual pixel width/height.
function contactRadius(rowHeight: number, rowGap: number, stageRows: number): number {
  const rows = Math.max(1, stageRows);
  const stageHeight = rows * rowHeight + Math.max(0, rows - 1) * rowGap;
  const row1CenterPct = (rowHeight / 2 / stageHeight) * 100;
  return Math.max(6, 50 - row1CenterPct);
}

export function EchoSound({
  items,
  query = "",
  rowHeight = 52,
  rowGap = 10,
  stageRows = 4,
  className = "",
}: EchoSoundProps) {
  const labelId = useId();
  const ringRef = useRef<SVGCircleElement | null>(null);
  const timers = useRef<number[]>([]);
  const [reduced, setReduced] = useState(false);
  const [phase, setPhase] = useState<Phase>(items && items.length > 0 ? "loaded" : "empty");
  const [revealCount, setRevealCount] = useState(0);
  const [announce, setAnnounce] = useState("");
  const [displayItems, setDisplayItems] = useState<EchoSoundItem[]>(items ?? []);
  const wasEmpty = useRef(phase === "empty");
  // 7000ms until the client settles on its own randomized 6-8s cadence post-
  // mount — picking the random value during the initial render would differ
  // between server and client and trip a hydration mismatch.
  const [period, setPeriod] = useState(7000);

  useEffect(() => {
    setPeriod(Math.round(6000 + Math.random() * 2000));
  }, []);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onChange = () => setReduced(mq.matches);
    onChange();
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    const clearTimers = () => {
      timers.current.forEach((t) => window.clearTimeout(t));
      timers.current = [];
    };

    const hasItems = !!items && items.length > 0;

    if (!hasItems) {
      clearTimers();
      // release any inline overrides left by a previous freeze so the CSS
      // keyframe class governs the ring again on the next empty stretch
      const ring = ringRef.current;
      if (ring) {
        ring.style.removeProperty("animation");
        ring.style.removeProperty("transition");
        ring.style.removeProperty("r");
        ring.style.removeProperty("stroke-opacity");
      }
      setPhase("empty");
      setRevealCount(0);
      wasEmpty.current = true;
      return clearTimers;
    }

    if (!wasEmpty.current) {
      // already showing content — a fresh batch just swaps in, no replay
      setDisplayItems(items!);
      setPhase("loaded");
      return clearTimers;
    }

    wasEmpty.current = false;
    setAnnounce(`${items!.length} result${items!.length === 1 ? "" : "s"} loaded`);
    setDisplayItems(items!);

    if (reduced) {
      setPhase("loaded");
      return clearTimers;
    }

    setPhase("arriving");

    // handoff 1 — ping to contact: snapshot the ring's mid-flight radius and
    // opacity, freeze them inline (so nothing jumps), then hand off to a
    // single CSS transition to the row-one contact radius. One read, one
    // write; no per-frame loop.
    const ring = ringRef.current;
    if (ring) {
      const cs = getComputedStyle(ring);
      const snapR = cs.getPropertyValue("r") || "3px";
      const snapOpacity = cs.getPropertyValue("stroke-opacity") || "1";
      ring.style.animation = "none";
      ring.style.setProperty("r", snapR);
      ring.style.setProperty("stroke-opacity", snapOpacity);
      void ring.getBoundingClientRect(); // commit the frozen values before transitioning
      ring.style.transition = `r ${CONTACT_HOLD_MS}ms cubic-bezier(.16,1,.3,1), stroke-opacity ${CONTACT_HOLD_MS}ms ease-out`;
      const targetR = contactRadius(rowHeight, rowGap, stageRows);
      requestAnimationFrame(() => {
        ring.style.setProperty("r", `${targetR}px`);
        ring.style.setProperty("stroke-opacity", "0.55");
      });
    }

    // handoff 2 — contact to resolved: rows scale in one at a time, then the
    // whole row crossfades from skeleton to real content.
    const rowCount = Math.min(items!.length, MAX_STAGGERED_ROWS);
    let revealed = 0;
    const stepRow = () => {
      revealed += 1;
      setRevealCount(revealed);
      if (revealed < rowCount) {
        timers.current.push(window.setTimeout(stepRow, STAGGER_MS));
      }
    };
    timers.current.push(
      window.setTimeout(stepRow, CONTACT_HOLD_MS),
      window.setTimeout(
        () => setPhase("loaded"),
        CONTACT_HOLD_MS + rowCount * STAGGER_MS + ROW_REVEAL_MS + SETTLE_HOLD_MS
      )
    );

    return clearTimers;
    // items is an external reference the caller owns; reduced/rowHeight/rowGap/
    // stageRows rarely change mid-flight and re-running this is idempotent
    // once wasEmpty guards a replay.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [items, reduced, rowHeight, rowGap, stageRows]);

  useEffect(
    () => () => {
      timers.current.forEach((t) => window.clearTimeout(t));
    },
    []
  );

  const emptyText = query ? `No results for "${query}"` : "Nothing here yet";
  const loadedText = `${displayItems.length} result${displayItems.length === 1 ? "" : "s"}${
    query ? ` for "${query}"` : ""
  }`;
  const stageHeight = stageRows * rowHeight + Math.max(0, stageRows - 1) * rowGap;

  return (
    <div className={`ns-echo relative w-full ${className}`}>
      <style>{CSS}</style>

      <div role="region" aria-labelledby={labelId} className="flex w-full flex-col items-center gap-4">
        <div className="relative w-full" style={{ height: reduced ? undefined : stageHeight }}>
          {phase !== "loaded" && !reduced && (
            <svg
              aria-hidden="true"
              focusable="false"
              className="absolute inset-0 h-full w-full"
              viewBox="0 0 100 100"
              preserveAspectRatio="none"
            >
              <circle
                ref={ringRef}
                r={3}
                cx={50}
                cy={50}
                vectorEffect="non-scaling-stroke"
                className={phase === "empty" ? "ns-echo-ring ns-echo-pinging" : "ns-echo-ring"}
                style={
                  {
                    fill: "none",
                    stroke: "var(--border)",
                    strokeWidth: 1,
                    "--ns-echo-period": `${period}ms`,
                  } as CSSProperties
                }
              />
            </svg>
          )}

          {(phase === "arriving" || phase === "loaded") && (
            <ul
              className={`relative flex w-full list-none flex-col ${phase === "loaded" ? "ns-echo-loaded" : ""}`}
              style={{ gap: rowGap }}
            >
              {displayItems.map((item, i) => (
                <li
                  key={item.id}
                  className={`ns-echo-row ${phase === "loaded" || i < revealCount ? "ns-echo-row-in" : ""}`}
                  style={{ height: rowHeight }}
                >
                  <div className="ns-echo-skel" aria-hidden="true" />
                  <div className="ns-echo-content" aria-hidden={phase !== "loaded"}>
                    {item.content}
                  </div>
                </li>
              ))}
            </ul>
          )}
        </div>

        <p id={labelId} className={phase === "empty" ? "text-center text-sm text-muted" : "sr-only"}>
          {phase === "empty" ? emptyText : loadedText}
        </p>
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}
Build spec

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.

Tags
empty-stateskeletonloadinglistsearchsvgaria-livesonar