# Sonar — Animated Background for React

> 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.

- Page: https://crucibleui.com/components/backgrounds/sonar
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/sonar
```

Registry JSON: https://crucibleui.com/r/sonar.json

Import from `@/components/crucible/sonar`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Sonar } from "@/components/crucible/sonar";
import { DemoScene } from "@/components/registry/demo-scene";

export default function SonarDemo() {
  return (
    <DemoScene
      background={<Sonar />}
      headline="Nothing slips past the sweep"
      subhead="Contacts blip awake as the beam finds them, then fade until the next pass."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`sonar.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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>
  );
}
```
