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.
npx shadcn add https://design.helpmarq.com /r/empty-state-sonar.jsonregistry/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>
);
}
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.