Ripple Rings
Concentric ripples expanding out of one origin — a pure CSS/DOM sonar backdrop for heroes, empty states and CTA bands.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ripple-rings.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RippleRings" component — concentric
ripples expanding out of one origin, used as a background for heroes, empty
states and CTA bands. Its only dependency is a cn() class merger (clsx +
tailwind-merge). No canvas, no SVG, no animation library: the whole effect is
absolutely-positioned DOM circles plus one CSS keyframe loop.
Contract
- export function RippleRings(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- count?: number (default 8) — rings in flight; clamp to 1..24 with
Math.round, and fall back to 8 for a non-finite value. This is the density
knob AND the cost knob (each ring is one composited span).
- speed?: number (default 6) — seconds for one ring to travel origin -> edge.
Non-finite or <= 0 falls back to 6, so a caller passing 0 cannot freeze
every ring on its first frame.
- origin?: "center" | "top" | "bottom" | { x: number; y: number }
(default "center") — where the ripple is born, as PERCENTAGES of the
container box: center = 50/50, top = 50/0, bottom = 50/100. A non-finite
number in the object form falls back to 50. Values outside 0..100 are
allowed on purpose (they park the source just outside the box).
- interactive?: boolean (default false) — pointer drags the origin.
- tone?: "primary" | "muted" | "foreground" (default "primary") — which
semantic token the ring stroke is mixed from.
- fade?: boolean (default true) — radial mask dissolving rings toward the
container edges.
- children render above the effect; className merges onto the root via cn(), so
sizing, padding, rounding, border and the surface color come from the call
site — the component paints no background of its own.
- "use client" is required: the component attaches pointer listeners and reads
matchMedia.
Behavior
- Three nested layers plus a content layer:
1. root: relative isolate overflow-hidden, holds the internal ref used for
pointer math (and clips every ring).
2. ripple layer: aria-hidden, pointer-events-none, absolute inset-0,
overflow-hidden. Carries --zy-ripple-ink (the tone color) and, when fade
is on, the mask.
3. origin layer: absolute inset-0 with container-type: size, translated by
`translate: calc(var(--zy-ripple-x, <originX>%) - 50%)
calc(var(--zy-ripple-y, <originY>%) - 50%)`. Moving the ORIGIN LAYER (not
each ring) is what makes the origin animatable in one property.
4. content: a separate relative z-10 wrapper for children.
- Each ring is a span with: position absolute, left/top 50%,
width/height 100cqmax, a pill border-radius, a 2px border of
var(--zy-ripple-ink), transform translate(-50%,-50%) scale(var(--zy-ripple-
scale)), opacity var(--zy-ripple-opacity), and one shared animation.
100cqmax (resolved against the size container above) makes every ring a
square whose side equals the container's LONGER edge — real circles that
reach the far edge in both a wide hero and a tall sidebar, with no
ResizeObserver and no JS measuring.
- One shared @keyframes: opacity 0 -> 1 by 12%, held at ~0.9 until 70%, then
down to 0 at 100%; transform scale .06 -> 1.15. Holding the plateau matters:
a plain linear fade spends most of the cycle washed out, and because a scaled
ring scales its border with it, the big late-cycle rings would be both thin
AND faint. Ending past scale 1 means a ring finishes dissolving just outside
the box instead of popping at the edge.
- Per-ring values are derived from the index alone — never Math.random(), so
render stays pure and SSR output is stable: animation-delay is
-(i * speed / count) seconds (negative, so the field is already spread out on
the first painted frame rather than launching in unison), and the timing
function is linear so equal time offsets also mean equal spatial spacing.
- prefers-reduced-motion: the per-ring base style IS the static fallback. The
class sets transform scale(var(--zy-ripple-scale)) and
opacity var(--zy-ripple-opacity) from index-derived values —
scale = 0.25 + 0.75 * (i + 1) / count, opacity = 1 - 0.35 * (i / count) — and
a `@media (prefers-reduced-motion: reduce)` rule sets animation: none. The
result is a set of evenly spaced concentric circles, not a blank box. The
0.25 scale floor exists because a scaled ring scales its border too: without
it the innermost circle would render as an invisible hairline.
- Ship the keyframes AND the ring class in one React 19 hoisted
<style href="zyeon-ripple-rings" precedence="medium"> tag (multiple instances
dedupe by href). The animation shorthand must live in the CLASS, not in an
inline style: an inline animation out-ranks any stylesheet rule, so the
reduced-motion media query could never switch it off. Inline styles therefore
carry nothing but custom properties.
- interactive: in an effect (deps: [interactive]) attach a passive pointermove
listener plus a pointerleave listener to the root. pointermove caches the
latest point as a percentage of the root's bounding rect and schedules ONE
requestAnimationFrame; the rAF callback writes --zy-ripple-x / --zy-ripple-y
with style.setProperty and clears the pending point. Never setState here — a
trackpad emits pointermove far above 60Hz and a re-render would drag the whole
subtree with it. Skip writes entirely while matchMedia("(prefers-reduced-
motion: reduce)").matches.
Ownership split that makes cleanup trivial: JS only ever writes the
--zy-ripple-x/y vars, while the DECLARED origin lives in those vars' CSS
fallback (React-owned inline style). pointerleave and the effect cleanup
removeProperty both vars, so the origin snaps back to exactly the declared
value with no stale coordinate to reconcile. Cleanup also cancels the pending
rAF and removes both listeners.
- The origin layer carries transition-[translate] duration-500 ease-out (Tailwind
v4's translate utilities write the `translate` PROPERTY, so transition-transform
would animate nothing) with motion-reduce:transition-none, so the pointer
follow and the return to the declared origin both glide instead of snapping.
Rendering & styling
- Semantic tokens only, no hex / rgb() / oklch() anywhere. Ring ink is
color-mix(in oklab, var(--primary) 48%, transparent) /
color-mix(in oklab, var(--muted-foreground) 40%, transparent) /
color-mix(in oklab, var(--foreground) 26%, transparent) per tone — the alphas
differ because --foreground carries far more contrast than --primary, and this
is what keeps the rings readable in both light and dark themes for free.
- fade uses an alpha-only mask: maskImage + WebkitMaskImage set to
radial-gradient(ellipse at center, black 55%, transparent 95%). black and
transparent here are mask keywords, not paint. Put the mask on the ripple
layer (whose box equals the container) and NOT on the translated origin layer:
mask-clip is border-box, so masking the moved layer would hard-cut the rings
halfway across the container whenever origin is not center.
- The whole effect lives in one aria-hidden, pointer-events-none layer, so it
never enters the accessibility tree and never eats a click; overflow-hidden on
the root guarantees the oversized rings cannot bleed out of the parent.
Customization levers
- Density and pace: count (1..24) and speed are the two dials that change the
character most — 4 slow rings read as a calm empty state, 16 fast ones read as
an alert/radar sweep.
- Focus point: origin moves the source; "top"/"bottom" turn the effect into an
edge band, and { x, y } lines the source up with an icon or headline. Add
interactive when the section is meant to feel alive under the cursor.
- Palette: tone picks the token; add a fourth entry to the tone map (e.g.
var(--destructive) for an incident banner) or change the color-mix percentage
to make the rings louder or quieter.
- Ring weight: the 2px border in the ring class is the stroke dial. Because the
border scales with the ring, early rings are deliberately hairline — bump it
to 3px for a heavier ripple, or swap border for a background of
color-mix(..., 6%, transparent) to get filled discs instead of outlines.
- Travel: the keyframes' end scale (1.15) decides how far past the edge a ring
dissolves — drop it to ~0.9 to keep every ring inside the box; move the 12%
opacity stop to control how abruptly a new ring appears.
- Static look: MIN_SCALE and the opacity ramp shape the reduced-motion (and
screenshot) rendering independently of the animation.
- Follow feel: duration-500 on the origin layer is the pointer lag — shorten it
for a tight follow, lengthen it for a lazy drift.Concepts
- Phase-offset shared loop — all rings run the same keyframes; only
animation-delaydiffers, at-(i × speed / count)seconds. Negative delays start each ring mid-flight, so the field is already spread out on the first painted frame instead of pulsing in unison, and alineartiming function keeps the spacing even in space as well as in time. - Origin as a CSS variable with a fallback — the declared origin lives in the fallback of
var(--zy-ripple-x, 50%), written by React; the pointer only ever writes the variable itself. Clearing the variable onpointerleaveor unmount restores the declared origin exactly, so JS and React never fight over the same style property. - rAF-throttled pointer origin —
pointermovecaches the latest coordinate and schedules a singlerequestAnimationFrame; the frame callback writes two custom properties. NosetState, so a high-frequency pointer stream repaints the decoration layer without re-rendering the card it sits behind. - Container-query-sized rings — the origin layer is a
container-type: sizecontainer and each ring is100cqmaxsquare, so ring diameter tracks the container's longer edge. True circles in both a wide hero and a tall panel, with no measuring code and no resize observer. - Static reduced-motion fallback — the ring class's own
transform/opacityare index-derived, so they are the still frame. Underprefers-reduced-motiononlyanimationis switched off and what remains is a set of evenly spaced concentric circles — the decoration degrades in motion, not in looks. - Mask on the unmoved layer —
mask-clipdefaults toborder-box, so the edge-fade mask sits on the layer whose box equals the container. Masking the translated origin layer instead would hard-cut every ring at that layer's edge as soon as the origin left the centre.