Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * One contact on the scope. Positions are polar and expressed in the
 * consumer's own terms, so the field can carry meaning (regions, sensors,
 * nodes) instead of being decorative noise.
 */
export interface SonarBlip {
  /** Bearing in degrees: 0 is straight up, values increase clockwise. Any value wraps. */
  angle: number
  /** Range from the origin: 0 at the centre, 1 on the outermost ring. Clamped to 0..1. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sonar.json

Prompt

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π, then exp(−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 drawImage per 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-out pass with a transparent → black radial gradient, its strength carried by globalAlpha. 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)) for contain, the far-corner distance for cover. 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 blips array 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: reduce the 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.

On This Page