Sonar
A radar sweep on one canvas — a rotating wedge with a decaying tail over concentric range rings, and contacts that flare when the beam passes and fade until the next one.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sonar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Sonar" component — a radar sweep painted
on one canvas: a rotating wedge with a decaying tail over concentric range
rings, plus contacts ("blips") that flare when the beam crosses them and fade
until the next pass. Its only dependency is a cn() class merger (clsx +
tailwind-merge).
Contract
- export interface SonarBlip { angle: number; distance: number; intensity?: number }
- angle: bearing in DEGREES, 0 = straight up, increasing clockwise, any value
wraps. distance: 0 at the origin, 1 on the outermost ring, clamped.
intensity: 0..1 (default 0.6), drives dot radius and peak brightness.
- export function Sonar(props): props extend React.ComponentProps<"div">
(rest props spread onto the root, so ref/id/data-* pass through) plus:
- blips?: SonarBlip[] — omit it and the component synthesises a seeded field.
- blipCount?: number (default 14, clamped 0..96) — only used when blips is
omitted.
- seed?: number (default 3) — integer seed for the synthesised field.
- rings?: number (default 4, clamped 0..12), spokes?: number (default 12,
clamped 0..48) — 0 drops either layer entirely.
- period?: number (default 5, clamped 0.4..600) — SECONDS PER REVOLUTION, so
higher is slower. Take Math.abs: direction is the direction prop's job.
- direction?: "cw" | "ccw" (default "cw").
- tail?: number (default 110, clamped 0..355) — angular length of the tail in
degrees.
- extent?: "contain" | "cover" (default "contain") — where the outermost ring
lands.
- origin?: "center" | "top" | "bottom" | { x: number; y: number } (default
"center") — beam anchor as PERCENTAGES of the box; a non-finite number in
the object form falls back to 50.
- tone?: "primary" | "foreground" | "muted" | "chart-1".."chart-5"
(default "primary").
- fade?: boolean (default true) — radial mask dissolving the scope toward the
container edges.
- children render above the canvas; className merges onto the root via cn().
- "use client": canvas, rAF, observers, matchMedia.
- Clamp every numeric prop up front and treat a non-finite value as the
default. period 0 divides by zero and spins the beam to NaN; a NaN bearing on
a blip poisons the context (every later path op on that path silently
no-ops), so malformed blips are DROPPED, not drawn.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
children. The component paints NO background of its own and has no focusable
element, no pointer handler and no keyboard surface: it is decoration, and the
only accessibility contract is that it stays out of the tree and out of the
way.
- Geometry, recomputed on every resize:
- origin (cx, cy) = box size * origin percentages.
- maxR = the distance to the FARTHEST corner. The wedge is drawn to maxR so a
non-square box never keeps a permanently unlit quadrant.
- ringR ("contain") = min(max(cx, w - cx), max(cy, h - cy)) — the largest
radius that still fits the shorter axis of the region the scope points
into: half the short side for a centred origin, the full height for one
parked on an edge (where "distance to the nearest edge" would be 0).
ringR ("cover") = maxR, i.e. the rings run past the box.
- A blip sits at (cx + cos(theta) * ringR * distance, cy + sin(...)), where
theta = degrees * PI / 180 - PI / 2 converts "0 = up, clockwise" into canvas
angles (0 = +x, y downward).
- Sweep: sweep += dirSign * TAU * dt / period, then normalised into [0, TAU)
every frame so the accumulator never drifts into the range where float
precision eats small increments. Keep the phase in a REF that outlives the
effect, so changing tone/period/direction re-runs the effect without snapping
the beam back to noon.
- THE TAIL IS A SPRITE. Bake it once into an offscreen canvas and draw it with
a single rotated drawImage per frame: translate(cx, cy), rotate(sweep),
drawImage(sprite, -maxR, -maxR, 2maxR, 2maxR). Re-filling the wedge every
frame at full-page size is the version of this component that burns a core.
- The sprite is a stack of pie sectors, each filled with the resolved ink at
alpha = 0.4 * exp(-u * 3.2), u being 0 at the leading edge and 1 at the end
of the tail. Sector count is ~1.6 degrees each (capped at 128). Alpha rides
on globalAlpha and the only colour string is the token: no colour literal,
and no dependency on canvas support for conic gradients or color-mix().
- Sectors overlap by 0.6 of a step so their antialiased edges cannot leave
dark radial seams, but the leading side is clamped to angle 0 — the tail may
bleed backwards into itself, never ahead of the beam.
- Range attenuation is a second pass: destination-out with a radial gradient
from transparent (centre) to black (rim) and globalAlpha 0.5. transparent
and black are ERASE keywords here — the composite reads alpha, not colour —
and the strength lives in globalAlpha, which is what keeps the pass free of
colour literals.
- The sprite backing store is capped at 768 device px. The wedge is a soft
gradient, so upscaling costs nothing visible, while a sprite sized to a
full-page container would reach tens of MB of texture. The one crisp part of
the beam — its leading edge — is stroked live at full resolution instead.
- ccw needs no second sprite: ctx.scale(1, -1) after the rotate mirrors the
local y axis, which maps angle -> -angle and flips the tail to the other
side of the leading edge.
- Rebuild the sprite only when the geometry or the resolved ink changes.
- Contacts: age = (((sweep - theta) * dirSign) mod TAU + TAU) mod TAU / TAU is
the fraction of a revolution since the beam last crossed that bearing — 0 the
instant it is hit, approaching 1 just before the next pass. Multiplying by
dirSign makes the single expression correct in both directions. Then
glow = exp(-age * 5.4) drives everything: alpha = intensity * (0.14 + 0.86 *
glow) (the floor keeps a contact faintly readable between passes), radius
grows 40% at the flare, a halo disc is drawn while glow is above 0.18, and for
the first 22% of a revolution an expanding ring is stroked outward with a
(1 - u)^1.6 fade. That is the whole "sonar" read: it is angular distance from
the beam, not a timer per blip, so nothing needs per-contact state.
- Rings and spokes: rings are stroked at alpha 0.18 (the outermost at 0.3);
spokes go into ONE path and get ONE stroke call — 48 separate strokes would be
48 rasterisation passes per frame — and start at 14% of ringR so they do not
crowd the origin.
- The contact list is read by the loop through a REF, updated in its own small
effect. An inline array literal from the consumer would otherwise tear down
the canvas, both observers and the sprite on every render. That effect also
triggers a repaint, because when the loop is paused (reduced motion, off
screen) no frame would otherwise pick the new contacts up.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). devicePixelRatio
(capped at 2) is the authority on scale; the device box is used verbatim only
when it agrees to within 0.01, purely to absorb sub-pixel rounding at
1.25x/1.5x. Re-apply ctx.setTransform after every resize (writing canvas.width
resets the context) and derive the scale from the actual backing size.
- Power: the rAF loop runs only while an IntersectionObserver says the canvas is
on screen AND document.visibilityState is "visible" AND motion is allowed. dt
is clamped to 1/30s so a backgrounded tab cannot teleport the beam on resume,
and the time base resets when the loop restarts.
- Theme flips land as a class/style change on the html element: a
MutationObserver re-reads the ink, rebuilds the sprite and repaints the still
frame, then schedules ONE more read ~400ms later — a surface animated with
transition-colors reports an intermediate colour for a few hundred ms, which
is long enough to bake the wrong ink into the sprite.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and kept in the effect deps. Under
reduce the loop never starts and exactly one frame is painted with the beam
parked 45 degrees past noon: rings, spokes, the full tail, and contacts at
whatever brightness their bearing earns relative to that parked beam. A
spread of bright and dim contacts, not a blank box and not a frozen flat field.
- Cleanup on unmount: cancelAnimationFrame, the ResizeObserver, the
IntersectionObserver, the MutationObserver, the settle timeout, the
visibilitychange listener, and the repaint hook.
- COST, HONESTLY: per frame the effect is one rotated drawImage covering the
container (a full-bleed alpha composite, so it scales with container AREA,
like any full-screen canvas backdrop), plus `rings` arcs, one spoke path, and
2-3 cheap ops per contact. The sprite is rebuilt only on resize / theme / tail
change. Prefer a section-sized container over a full-page one, keep blipCount
in the tens rather than the hundreds, and remember the loop costs exactly zero
while the section is scrolled away.
Rendering & styling
- Semantic tokens only, zero colour literals: the canvas carries the tone token
as an inline `color` (var(--primary) / var(--foreground) /
var(--muted-foreground) / var(--chart-1..5)), and the loop reads
getComputedStyle(canvas).color back and assigns that string straight to
fillStyle/strokeStyle. Every opacity lives in globalAlpha, never in the colour
string, so any syntax the browser resolves — oklch(), color-mix(), a brand
colour behind the token — works with no parsing.
- fade uses an alpha-only mask on the canvas: maskImage + WebkitMaskImage set to
radial-gradient(ellipse at <originX>% <originY>%, black 42%, transparent 92%).
The mask FOLLOWS THE ORIGIN, otherwise an edge-anchored scope gets cut in half
by a fade centred on the box. black/transparent are mask keywords, not paint.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes. The root sets no size — dimensions, padding, rounding, border and the
surface colour all come from the call site.
- Accessibility: the canvas is aria-hidden and pointer-events-none, and the
contacts carry no labels and no identity. If a blip must MEAN something to a
screen reader, render your own absolutely-positioned marker in the children
layer at the same polar coordinates and leave the canvas decorative.
Customization levers
- Pace and drama: period (seconds per revolution) and tail are the two dials
that change character most — 110 degrees at 5s reads as an idle scope, 210
degrees at 2.2s reads as an alert sweep. direction flips the rotation.
- Density: rings, spokes and blipCount. rings=0 spokes=0 leaves a bare beam over
the contacts; rings=6 spokes=16 reads as instrumentation.
- Meaning: pass blips yourself and the field stops being decoration — bearing
can encode region, distance can encode latency or depth, intensity can encode
severity. Hoist or memoize the array so its identity is stable.
- Framing: origin moves the beam ("bottom" turns the effect into a horizon
band), extent decides whether the ring set is fully inside the box
("contain") or bleeds past it ("cover"), and fade dissolves the edges.
- Palette: tone maps to a token. Add an entry pointing at var(--destructive)
for an incident banner, or var(--primary-foreground) for an inverted panel;
never hardcode a colour.
- Feel of the decay: SWEEP_PEAK_ALPHA / SWEEP_DECAY shape the tail, BLIP_DECAY
and BLIP_FLOOR shape how long a contact lingers (raise the floor for a field
that stays legible, drop it for a scope that is dark between passes), and
PING_AGE / PING_TRAVEL size the expanding ring. RING_ALPHA / SPOKE_ALPHA are
the instrumentation weight.
- Cost: SPRITE_MAX_PX trades tail sharpness for texture memory, MAX_DPR can drop
to 1 to halve fill cost on retina if you ship a very large scope, and
SWEEP_STEPS_PER_TURN trades sprite build time for tail smoothness.Concepts
- Angular age, not a timer — a contact's brightness is a pure function of how far the beam has travelled since it crossed that bearing:
age = ((sweep − theta) · dir mod 2π) / 2π, thenexp(−age · 5.4). Multiplying by the direction sign makes one expression correct clockwise and counter-clockwise, and because nothing is stored per contact, the field can change size, order or content between frames without any state to migrate. - Sprite-baked tail — the decaying wedge is filled once into an offscreen canvas as a stack of overlapping sectors, then drawn with a single rotated
drawImageper frame. Sectors overlap by 0.6 of a step so their antialiased edges cannot leave dark radial seams, and the leading side is clamped so the tail never spills ahead of the beam. - Erase keywords, not paint — range attenuation is a
destination-outpass with atransparent → blackradial gradient, its strength carried byglobalAlpha. The composite reads alpha only, which is how the wedge gets a soft range falloff without a single colour literal escaping the token system. - Contain versus cover — the ring radius is
min(max(cx, w−cx), max(cy, h−cy))forcontain, the far-corner distance forcover. The first keeps the whole scope inside the box (and still works when the origin sits on an edge, where "nearest edge" would be zero); the second pushes the rings past the border so a card is entirely inside the scope. The wedge always reaches the far corner either way, so no quadrant stays unlit. - Ref-fed contact list — the loop reads contacts from a ref that a tiny effect keeps in sync, so an inline
blipsarray cannot rebuild the canvas, the observers and the sprite on every render. That effect also fires a repaint, because a paused scope (reduced motion or scrolled away) has no frame to pick up the new data. - Parked still frame — under
prefers-reduced-motion: reducethe loop never starts; one frame is painted with the beam 45° past noon, and each contact takes the brightness its own bearing earns against that parked beam. The result is a scope with a spread of bright and faded contacts, which is a still radar rather than a blank box.
Silk
Flowing bands of light on one inline SVG — stacked sine ribbons, each a soft gradient between two chart tokens, drifting slowly enough to read as fabric.
Nebula
A layered interstellar cloud on canvas — token-tinted noise octaves drifting at different rates under a sparse star layer, with depth from parallax.