Sonar
Phosphor radar scope: a rotating sweep beam with a soft angular-gradient afterglow over concentric range rings and faint azimuth spokes; seeded contacts blip awake when the beam crosses them, bloom, then decay — drifters leave breadcrumb tracks. Optional hud knob for the full instrument look. Canvas 2D, seeded, offscreen-paused, DPR-clamped, reduced-motion-safe.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface SonarProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Two tones `[phosphor, background]`. `phosphor` colors the sweep beam,
* range rings, spokes and contact blips; `background` is the scope glass the
* whole instrument sits on. Defaults to classic phosphor green on
* near-black — pass amber (`["#fbbf24", "#140e04"]`) for a vintage CRT or
* ice blue for a naval console.
* @default ["#34d399", "#04140c"]
*/
colors?: [string, string];
/** Sweep-speed multiplier on top of `period`. `0` freezes the scope. 1 = default. @default 1 */
speed?: number;
/** Overall beam / blip / grid brightness, 0–2. @default 1 */
intensity?: number;
/** Seconds per full beam revolution (before `speed`). Clamped 2–60. @default 6 */
period?: number;
/** Number of labeled range rings inside the scope circle. Clamped 1–12. @default 4 */
rings?: number;
/** Number of seeded contacts on the scope. Clamped 0–24. @default 8 */
contacts?: number;
/**
* Phosphor persistence, 0–1 — how far around the dial the sweep's afterglow
* survives and how slowly contact blips decay between passes.
* @default 0.55
*/
persistence?: number;
/** Full instrument look: center crosshair + range-ring labels. @default false */
hud?: boolean;
/** Seed for contact placement and drift — same seed, same scope picture. @default 0x50a44e1d */
seed?: number;
/** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
dpr?: number;
/** Freeze at the designed static frame (beam mid-sweep, two contacts lit). @default false */
paused?: boolean;
}
const DEFAULT_COLORS: [string, string] = ["#34d399", "#04140c"];
const TAU = Math.PI * 2;
const DEFAULT_SEED = 0x50a44e1d;
/** Static-frame beam heading (up-right) — used when reduced-motion / paused. */
const STATIC_ANGLE = -Math.PI / 3;
/** Azimuth spokes around the dial (every 30°). */
const SPOKES = 12;
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const int = parseInt(h, 16);
if (Number.isNaN(int) || h.length !== 6) return [52, 211, 153];
return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}
function mix(
a: [number, number, number],
b: [number, number, number],
t: number
): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
function rgba(rgb: [number, number, number], a: number): string {
return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
}
/** Deterministic mulberry32 — seeded so the scope picture is identical SSR intent ↔ CSR. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface Contact {
/** Normalized position in the container (0–1) so resizes keep the picture. */
nx: number;
ny: number;
/** Normalized drift velocity per second — zero for stationary contacts. */
vx: number;
vy: number;
/** Blip size scalar, 0–1. */
size: number;
/** Excitement 0–1 — set to 1 when the beam crosses, decays until next pass. */
e: number;
/** Which beam lap last lit this contact (crossing detector). */
lastPass: number;
/** Seconds since last excitation, drives the wake-up ripple ring. */
rippleAge: number;
drifter: boolean;
/** Past blip positions (px) for drifting contact tracks. */
track: { x: number; y: number; t: number }[];
}
interface Internals {
gridCtx: CanvasRenderingContext2D;
fxCtx: CanvasRenderingContext2D;
width: number;
height: number;
cx: number;
cy: number;
/** Full-bleed radius — the sweep reaches every corner. */
rFull: number;
/** Inscribed scope radius — labeled rings live inside this. */
rIn: number;
/** Cached radial mask that shapes the sweep (center dead-zone, rim falloff). */
radialMask: CanvasGradient | null;
contactList: Contact[];
phosphor: [number, number, number];
bg: [number, number, number];
hot: [number, number, number];
/** Sweep angular velocity, rad/s (period × speed applied). */
omega: number;
/** Fraction of the dial the afterglow spans. */
trailFrac: number;
/** Contact-blip decay time constant, seconds. */
blipTau: number;
intensity: number;
rings: number;
hud: boolean;
/** Seeded per-instance start heading — multiple instances desynchronize. */
angle0: number;
lastElapsed: number;
primed: boolean;
}
/** Paint the static instrument: glass, grain, range rings, spokes, HUD. */
function drawGrid(it: Internals) {
const { gridCtx: ctx, width: w, height: h, cx, cy, rFull, rIn, phosphor, bg } = it;
const gi = it.intensity;
ctx.fillStyle = rgba(bg, 1);
ctx.fillRect(0, 0, w, h);
// Scope-glass glow — a whisper of phosphor lifting the center.
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, rIn * 1.15);
glow.addColorStop(0, rgba(phosphor, 0.055 * gi));
glow.addColorStop(0.55, rgba(phosphor, 0.02 * gi));
glow.addColorStop(1, rgba(phosphor, 0));
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
// Seeded grain tile — breaks banding on the glass gradient.
const tile = document.createElement("canvas");
tile.width = 96;
tile.height = 96;
const tctx = tile.getContext("2d");
if (tctx) {
const img = tctx.createImageData(96, 96);
const rand = mulberry32(0x9e3779b9);
for (let p = 0; p < img.data.length; p += 4) {
const v = rand();
img.data[p] = phosphor[0];
img.data[p + 1] = phosphor[1];
img.data[p + 2] = phosphor[2];
img.data[p + 3] = (v * 10) | 0; // ≤4% alpha speckle
}
tctx.putImageData(img, 0, 0);
const pattern = ctx.createPattern(tile, "repeat");
if (pattern) {
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, w, h);
}
}
// Azimuth spokes — every 30°, cardinal directions a touch brighter.
ctx.lineWidth = 1;
for (let s = 0; s < SPOKES; s++) {
const a = (s / SPOKES) * TAU;
const cardinal = s % 3 === 0;
ctx.strokeStyle = rgba(phosphor, (cardinal ? 0.075 : 0.045) * gi);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * rIn * 0.02, cy + Math.sin(a) * rIn * 0.02);
ctx.lineTo(cx + Math.cos(a) * rFull, cy + Math.sin(a) * rFull);
ctx.stroke();
}
// Range rings — labeled set inside the scope circle, fainter ones continue
// outward so the instrument bleeds through the corners of the frame.
const gap = rIn / it.rings;
for (let k = 1; k * gap <= rFull + gap; k++) {
const r = k * gap;
const inside = k <= it.rings;
const isBezel = k === it.rings;
ctx.strokeStyle = rgba(phosphor, (isBezel ? 0.3 : inside ? 0.15 : 0.06) * gi);
ctx.lineWidth = isBezel ? 1.5 : 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, TAU);
ctx.stroke();
}
if (it.hud) {
// Crosshair hairlines through the full frame.
ctx.strokeStyle = rgba(phosphor, 0.13 * gi);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, cy + 0.5);
ctx.lineTo(w, cy + 0.5);
ctx.moveTo(cx + 0.5, 0);
ctx.lineTo(cx + 0.5, h);
ctx.stroke();
// Range labels climbing the upper crosshair.
ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, monospace";
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
ctx.fillStyle = rgba(phosphor, 0.45 * gi);
for (let k = 1; k <= it.rings; k++) {
ctx.fillText(String(k * 100), cx + 5, cy - k * gap - 3);
}
}
}
/** The sweep: soft angular-gradient wedge trailing behind the beam heading. */
function drawSweep(it: Internals, beamAngle: number) {
const { fxCtx: ctx, width: w, height: h, cx, cy, phosphor, trailFrac } = it;
const peak = Math.min(0.5 * it.intensity, 0.85);
if (typeof ctx.createConicGradient === "function") {
// Conic runs clockwise from the beam heading; the sweep also rotates
// clockwise, so the afterglow sits just behind — near t = 1.
const conic = ctx.createConicGradient(beamAngle, cx, cy);
conic.addColorStop(0, rgba(phosphor, peak));
conic.addColorStop(0.004, rgba(phosphor, 0)); // razor falloff ahead of the beam
conic.addColorStop(Math.max(0.005, 1 - trailFrac), rgba(phosphor, 0));
// Exponential tail — tone-mapped decay, never a linear ramp.
for (let s = 1; s <= 8; s++) {
const u = 1 - s / 9; // 1 → oldest, 0 → freshest
conic.addColorStop(1 - trailFrac * u, rgba(phosphor, peak * Math.exp(-4.2 * u)));
}
conic.addColorStop(1, rgba(phosphor, peak));
ctx.fillStyle = conic;
ctx.fillRect(0, 0, w, h);
} else {
// Ancient-browser fallback: one soft uniform wedge.
ctx.fillStyle = rgba(phosphor, peak * 0.3);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, it.rFull, beamAngle - trailFrac * TAU * 0.5, beamAngle);
ctx.closePath();
ctx.fill();
}
// Radial shaping: dead-zone at the hub, glass falloff toward the rim.
if (it.radialMask) {
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = it.radialMask;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
}
}
/** Bright leading edge of the beam — the hot line the phosphor chases. */
function drawBeamEdge(it: Internals, beamAngle: number) {
const { fxCtx: ctx, cx, cy, rFull, hot } = it;
const ex = cx + Math.cos(beamAngle) * rFull;
const ey = cy + Math.sin(beamAngle) * rFull;
ctx.globalCompositeOperation = "lighter";
ctx.lineCap = "round";
ctx.strokeStyle = rgba(hot, Math.min(0.16 * it.intensity, 0.4));
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(beamAngle) * 6, cy + Math.sin(beamAngle) * 6);
ctx.lineTo(ex, ey);
ctx.stroke();
ctx.strokeStyle = rgba(hot, Math.min(0.7 * it.intensity, 1));
ctx.lineWidth = 1.25;
ctx.stroke();
ctx.globalCompositeOperation = "source-over";
}
/** Contact blips, wake-up ripples and drifting tracks. */
function drawContacts(it: Internals, elapsed: number) {
const { fxCtx: ctx, width: w, height: h, phosphor, hot } = it;
const trackTau = it.blipTau * 2.9;
ctx.globalCompositeOperation = "lighter";
for (const c of it.contactList) {
const px = c.nx * w;
const py = c.ny * h;
// Faded breadcrumb track behind drifting contacts.
for (const p of c.track) {
const a = 0.3 * Math.exp(-(elapsed - p.t) / trackTau) * it.intensity;
if (a < 0.015) continue;
ctx.fillStyle = rgba(phosphor, Math.min(a, 0.35));
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, TAU);
ctx.fill();
}
const a = Math.pow(Math.max(c.e, 0), 1.35) * it.intensity;
if (a < 0.012) continue;
const r = 7 + c.size * 7;
const bloom = ctx.createRadialGradient(px, py, 0, px, py, r);
bloom.addColorStop(0, rgba(hot, Math.min(a, 1)));
bloom.addColorStop(0.3, rgba(phosphor, Math.min(a * 0.55, 1)));
bloom.addColorStop(1, rgba(phosphor, 0));
ctx.fillStyle = bloom;
ctx.beginPath();
ctx.arc(px, py, r, 0, TAU);
ctx.fill();
// Wake-up ripple right after the beam finds it.
if (c.rippleAge < 0.55) {
const t = c.rippleAge / 0.55;
ctx.strokeStyle = rgba(phosphor, (1 - t) * 0.45 * it.intensity);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px, py, 4 + t * 26, 0, TAU);
ctx.stroke();
}
}
ctx.globalCompositeOperation = "source-over";
}
/** Advance drift + beam-crossing excitement for one frame. */
function updateContacts(it: Internals, beamAngle: number, dt: number, elapsed: number) {
const { width: w, height: h, cx, cy } = it;
const decay = Math.exp(-dt / it.blipTau);
for (const c of it.contactList) {
if (c.drifter) {
c.nx += c.vx * dt;
c.ny += c.vy * dt;
if (c.nx < 0.06 || c.nx > 0.94) c.vx *= -1;
if (c.ny < 0.08 || c.ny > 0.92) c.vy *= -1;
c.nx = Math.min(Math.max(c.nx, 0.06), 0.94);
c.ny = Math.min(Math.max(c.ny, 0.08), 0.92);
}
const theta = Math.atan2(c.ny * h - cy, c.nx * w - cx);
const pass = Math.floor((beamAngle - theta) / TAU);
if (pass > c.lastPass) {
c.lastPass = pass;
c.e = 1;
c.rippleAge = 0;
if (c.drifter) {
c.track.push({ x: c.nx * w, y: c.ny * h, t: elapsed });
if (c.track.length > 5) c.track.shift();
}
} else {
c.e *= decay;
c.rippleAge += dt;
}
}
}
/**
* The designed still: beam frozen mid-sweep with its full afterglow, the two
* contacts just behind the beam lit bright, the rest as faint embers.
*/
function poseStaticContacts(it: Internals, beamAngle: number) {
const { width: w, height: h, cx, cy } = it;
const behind = it.contactList.map(c => {
const theta = Math.atan2(c.ny * h - cy, c.nx * w - cx);
const d = (((beamAngle - theta) % TAU) + TAU) % TAU;
return { c, d };
});
behind.sort((a, b) => a.d - b.d);
behind.forEach(({ c, d }, i) => {
c.e = i === 0 ? 0.95 : i === 1 ? 0.7 : Math.exp(-d * 1.6) * 0.35;
c.rippleAge = 1; // no ripple in the still
});
}
/** One frame. `useStatic` renders the frozen reduced-motion / paused pose. */
function draw(it: Internals, elapsed: number, useStatic: boolean) {
const beamAngle = useStatic ? it.angle0 + STATIC_ANGLE : it.angle0 + it.omega * elapsed;
if (useStatic) {
poseStaticContacts(it, beamAngle);
} else {
if (!it.primed) {
// Pre-excite as if the scope had been running — never a dark first frame.
it.primed = true;
const { width: w, height: h, cx, cy } = it;
const safeOmega = Math.max(it.omega, 1e-3);
for (const c of it.contactList) {
const theta = Math.atan2(c.ny * h - cy, c.nx * w - cx);
const d = (((beamAngle - theta) % TAU) + TAU) % TAU;
c.e = Math.exp(-(d / safeOmega) / it.blipTau);
c.rippleAge = 1;
c.lastPass = Math.floor((beamAngle - theta) / TAU);
}
}
const dt = Math.min(Math.max(elapsed - it.lastElapsed, 0), 0.1);
updateContacts(it, beamAngle, dt, elapsed);
}
it.lastElapsed = elapsed;
const ctx = it.fxCtx;
ctx.clearRect(0, 0, it.width, it.height);
drawSweep(it, beamAngle);
drawBeamEdge(it, beamAngle);
drawContacts(it, elapsed);
}
/**
* Sonar — a phosphor radar scope. A rotating sweep beam drags a soft
* angular-gradient afterglow over concentric range rings and faint azimuth
* spokes; seeded contacts blip awake as the beam crosses them, bloom bright,
* then decay until the next pass — drifting contacts leave breadcrumb tracks.
* A `hud` knob adds the full instrument look (crosshair + range labels).
* Canvas 2D: seeded and SSR-safe, paused offscreen and on hidden tabs,
* DPR-clamped, reduced-motion-safe (beam frozen mid-sweep, two contacts lit).
* Drop it inside any `relative` container.
*/
export function Sonar({
colors = DEFAULT_COLORS,
speed = 1,
intensity = 1,
period = 6,
rings = 4,
contacts = 8,
persistence = 0.55,
hud = false,
seed = DEFAULT_SEED,
dpr = 2,
paused = false,
className,
style,
...props
}: SonarProps) {
const gridRef = React.useRef<HTMLCanvasElement>(null);
const fxRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const staticFrame = paused || reducedMotion || speed <= 0;
const staticFrameRef = React.useRef(staticFrame);
staticFrameRef.current = staticFrame;
const ringCount = Math.min(Math.max(Math.round(rings), 1), 12);
const contactCount = Math.min(Math.max(Math.round(contacts), 0), 24);
const clampedPeriod = Math.min(Math.max(period, 2), 60);
const clampedPersistence = Math.min(Math.max(persistence, 0), 1);
const clampedIntensity = Math.min(Math.max(intensity, 0), 2);
const colorsKey = colors.join(",");
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
draw(it, elapsed, staticFrameRef.current);
}, []);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });
React.useEffect(() => {
const gridCanvas = gridRef.current;
const fxCanvas = fxRef.current;
const container = containerRef.current;
if (!gridCanvas || !fxCanvas || !container) return;
const gridCtx = gridCanvas.getContext("2d", { alpha: false });
const fxCtx = fxCanvas.getContext("2d");
if (!gridCtx || !fxCtx) return;
const effectiveDpr = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
Math.min(Math.max(dpr, 1), 2)
);
const phosphor = hexToRgb(colors[0]);
const bg = hexToRgb(colors[1]);
const rand = mulberry32(seed >>> 0);
const contactList: Contact[] = [];
for (let i = 0; i < contactCount; i++) {
const drifter = i % 3 === 2; // every third contact is underway
const heading = rand() * TAU;
const knots = 0.008 + rand() * 0.014; // normalized units / s — slow
contactList.push({
nx: 0.08 + rand() * 0.84,
ny: 0.1 + rand() * 0.8,
vx: drifter ? Math.cos(heading) * knots : 0,
vy: drifter ? Math.sin(heading) * knots : 0,
size: rand(),
e: 0,
lastPass: Number.NEGATIVE_INFINITY,
rippleAge: 1,
drifter,
track: [],
});
}
const speedMul = Math.max(speed, 0);
const it: Internals = {
gridCtx,
fxCtx,
width: 0,
height: 0,
cx: 0,
cy: 0,
rFull: 0,
rIn: 0,
radialMask: null,
contactList,
phosphor,
bg,
hot: mix(phosphor, [255, 255, 255], 0.55),
omega: (TAU / clampedPeriod) * speedMul,
trailFrac: 0.2 + clampedPersistence * 0.72,
blipTau:
(clampedPeriod / Math.max(speedMul, 0.05)) * (0.25 + 0.55 * clampedPersistence),
intensity: clampedIntensity,
rings: ringCount,
hud,
angle0: rand() * TAU,
lastElapsed: 0,
primed: false,
};
internalsRef.current = it;
const resize = () => {
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
for (const canvas of [gridCanvas, fxCanvas]) {
canvas.width = Math.round(w * effectiveDpr);
canvas.height = Math.round(h * effectiveDpr);
canvas.style.width = "100%";
canvas.style.height = "100%";
}
gridCtx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
fxCtx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
it.width = w;
it.height = h;
it.cx = w / 2;
it.cy = h / 2;
it.rFull = Math.hypot(w, h) / 2;
it.rIn = (Math.min(w, h) / 2) * 0.92;
const mask = fxCtx.createRadialGradient(it.cx, it.cy, 0, it.cx, it.cy, it.rFull);
mask.addColorStop(0, "rgba(255,255,255,0)");
mask.addColorStop(0.05, "rgba(255,255,255,0.9)");
mask.addColorStop(0.35, "rgba(255,255,255,1)");
mask.addColorStop(1, "rgba(255,255,255,0.12)");
it.radialMask = mask;
drawGrid(it);
// Never flash empty — paint one sweep frame immediately.
draw(it, it.lastElapsed, staticFrameRef.current);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
return () => {
ro.disconnect();
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dpr,
colorsKey,
speed,
clampedIntensity,
clampedPeriod,
ringCount,
contactCount,
clampedPersistence,
hud,
seed,
]);
// Re-pose the frozen frame in place when paused / reduced-motion toggles.
React.useEffect(() => {
const it = internalsRef.current;
if (!it || !staticFrame) return;
draw(it, it.lastElapsed, true);
}, [staticFrame]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="sonar"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={{ backgroundColor: colors[1], ...style }}
{...props}
>
<canvas ref={gridRef} className="absolute inset-0 block h-full w-full" />
<canvas ref={fxRef} className="absolute inset-0 block h-full w-full" />
{/* Soft vignette so the scope sinks into the frame edges. */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(120% 120% at 50% 45%, transparent 60%, rgba(0,0,0,0.45) 100%)",
}}
/>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/sonarProps
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | [string, string] | ["#34d399", "#04140c"] | Two tones [phosphor, background]. phosphor colors the sweep beam, range rings, spokes and contact blips; background is the scope glass the whole instrument sits on. Defaults to classic phosphor green on near-black — pass amber (["#fbbf24", "#140e04"]) for a vintage CRT or ice blue for a naval console. |
| speed | number | 1 | Sweep-speed multiplier on top of period. 0 freezes the scope. 1 = default. |
| intensity | number | 1 | Overall beam / blip / grid brightness, 0–2. |
| period | number | 6 | Seconds per full beam revolution (before speed). Clamped 2–60. |
| rings | number | 4 | Number of labeled range rings inside the scope circle. Clamped 1–12. |
| contacts | number | 8 | Number of seeded contacts on the scope. Clamped 0–24. |
| persistence | number | 0.55 | Phosphor persistence, 0–1 — how far around the dial the sweep's afterglow survives and how slowly contact blips decay between passes. |
| hud | boolean | false | Full instrument look: center crosshair + range-ring labels. |
| seed | number | 0x50a44e1d | Seed for contact placement and drift — same seed, same scope picture. |
| dpr | number | 2 | devicePixelRatio ceiling. Clamped 1–2. |
| paused | boolean | false | Freeze at the designed static frame (beam mid-sweep, two contacts lit). |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.