Backgrounds

Ripple Rings

Concentric ripples expanding out of one origin — a pure CSS/DOM sonar backdrop for heroes, empty states and CTA bands.

Preview in your theme

Loading preview…

"use client"

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

/**
 * One hoisted sheet carries the ring geometry and the loop.
 *
 * The `animation` shorthand lives in a class rather than an inline style for
 * two reasons: an inline animation would out-rank every stylesheet rule, so the
 * reduced-motion media query below could never switch it off; and the per-ring
 * inline style is then nothing but plain custom properties.
 *
 * Ring size is `100cqmax` — the origin layer is a size container, so each ring

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/ripple-rings.json

Prompt

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-delay differs, 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 a linear timing 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 on pointerleave or unmount restores the declared origin exactly, so JS and React never fight over the same style property.
  • rAF-throttled pointer originpointermove caches the latest coordinate and schedules a single requestAnimationFrame; the frame callback writes two custom properties. No setState, 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: size container and each ring is 100cqmax square, 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/opacity are index-derived, so they are the still frame. Under prefers-reduced-motion only animation is 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 layermask-clip defaults to border-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.

On This Page