Ripple
component / motion
Concentric rings spreading outward from a centre slot at an even, cadence-capped beat, fading as they grow — the background mark for a logo or a feature icon, in pure CSS.
- type
- component
- files
- 2
- registry deps
- none
- npm deps
- none
installs
- components/automindz/ripple.tsx
- styles/automindz-ripple.css
- Cadence capped at 1.5Hz in the arithmetic, whatever ring count — a fast ripple is a strobe
- Even beat: phase is -(i/n) × lap, re-derived whenever the lap gets clamped
- Rings fade as they grow, so the loop restarts on a ring already at zero
- A background layer — takes a centre slot, nests around icon-cloud or orbiting-icons
- Reduced motion leaves evenly spaced rings fading outward: the same frame, still
The stylesheet is not optional: add import "@/styles/automindz-ripple.css" to your root layout once, or nothing emits. Skipping it is safe, never blank — the rings still fan out evenly from the centre, each fainter than the one inside it, which is the frame the animation holds at t = 0 and also the prefers-reduced-motion path. Ripple takes the centre slot as children and never positions itself: it is an inline-grid box that centres its child and draws the rings around it, so it composes by nesting (<Ripple><IconCloud …/></Ripple> works, as does <Ripple><OrbitingIcons …/></Ripple>). It reserves size in both axes and grows past it if the centre slot is bigger, so the rings never need an ancestor to clip them. **The cadence is capped, not documented**: rippleDuration() stretches the lap until rings emit at 1.5Hz or slower — half the WCAG 2.3.1 flash threshold — whatever rings count you pass, so 12 rings at duration={1} runs an 8s lap rather than strobing. Set the lap with the duration prop and never by overriding --automindz-ripple-duration, which the component writes and the per-ring delays are measured against. The rings are drawn in currentColor; the dials are custom properties — --automindz-ripple-from (starting scale, default 0.42 — raise it to clear a large centre slot), --automindz-ripple-opacity (default 0.35) and --automindz-ripple-thickness (default 1px).
Variations
one state per section
- 01Five rings behind a mark
- 02The strobe you cannot ask for
- 03It is a background layer, so it nests
- 04Every dial is a custom property
- 05One ring, no rings, no centre
- 06Standing still is the base state
Five rings behind a mark
The default. Fix on the centre and count the rings leaving it: the gap between emissions is even, because each ring's phase is -(i/n) of the lap rather than a hand-picked delay per ring. Each one fades as it grows, so the outermost dissolves at the edge instead of stopping on a hard line — which is also what makes the loop seamless, since the snap back to the centre happens on a ring already at zero.

The strobe you cannot ask for
Both were asked for a 1s lap. On the left, three rings would emit at 3Hz — the WCAG flash threshold — so the lap was stretched to 2s. On the right, twelve rings would emit at 12Hz; the lap became 8s. The cap is arithmetic in rippleDuration, not a line in the docs, and it is applied in the one place both the CSS duration and the per-ring delays are derived from, so a clamped lap re-spaces the rings instead of leaving them bunched.
It is a background layer, so it nests
Nothing here is fixed or absolutely positioned at the outer edge: the mark centres whatever you give it and draws the rings around it, so composing is nesting. On the left an IconCloud, on the right an OrbitingIcons, both with --automindz-ripple-from raised so the innermost ring clears the diagram rather than cutting through it. Four independent animations, no shared clock, no JavaScript.

Every dial is a custom property
Same component, three sets of custom properties — the starting scale, the innermost ring's opacity and the stroke width — set on the element rather than passed as props, so one rule on an ancestor re-styles every ripple on a page. The middle one is tinted with text-primary and nothing else: the rings are currentColor, so they inherit whatever ink they land in and never name a brand colour.
One ring, no rings, no centre
A single ring is a single circle, still expanding and still fading out at the edge. Zero rings with a centre slot is the centre slot, correctly placed and reserving nothing extra. Zero rings and no centre renders nothing at all — not an empty 320px square holding a column open. None of the three needs a guard at the call site.
Standing still is the base state
Turn on Reduce Motion in your OS and reload: concentric rings, evenly spaced from the centre out to the edge, each fainter than the one inside it. That is not a fallback, it is the frame the animation holds at t = 0 — the component places ring i at exactly i/n through the keyframes and the travel is linear, so the still mark and the first painted frame of the moving one are the same picture. It is also what an install renders with the stylesheet import forgotten.
