ripple
A Backgrounds block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call ripple() with no arguments for a working demo, or edit the code below live.
Implementation notes
Pure CSS: every ring plays the identical 'breathe' @keyframes (scale 0.92 -> 1 -> 0.92, ease, infinite), only animation-delay differs per ring (index * 0.06s), so the staggered starts alone produce the outward-ripple read with no ring ever actually growing past its own fixed diameter -- matches the spec's own description precisely ('faking a traveling ripple purely through staggered timing'). Ring size/opacity step per index (+70px / -0.03) and defaults (mainCircleSize 210, mainCircleOpacity 0.24, numCircles 8) match the spec's research note. The stack is mask-image-clipped (linear-gradient to transparent at the bottom) so it fades out toward the container's bottom edge. Rings intentionally disable the low-opacity doctor rule (they're ambient decoration, not interactive controls, which is exactly what that rule's own message text carves out an exception for) and the missing-color rule (no text of their own, aria-hidden).
Status: ported · Reference: Magic UI original
// Magic UI "Ripple" — clean-room reimplementation.
//
// A stack of concentric, gently pulsing circular rings centered behind other
// content — typically used to draw attention to a focal element such as a
// logo or headline. Implemented purely from the block's public
// functional/visual spec — no upstream Magic UI source was viewed or
// copied.
//
// Pure CSS: every ring plays the exact same `@keyframes` "breathe" loop
// (scale slightly inward, then back to full size, eased, infinite) — only
// each ring's `animation-delay` differs, growing by a small constant per
// ring index. Because every ring is just breathing in place with a staggered
// start, the population reads as an outward-radiating ripple purely through
// timing, with no ring ever actually growing past its own fixed diameter.
// The whole stack is `mask-image`-clipped so it fades to nothing toward the
// bottom edge of its container (the same `linear-gradient` mask technique as
// this package's `progressiveBlur`).
import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface RippleProps {
/** Diameter of the innermost ring, in px. Defaults to `210`. */
mainCircleSize?: number;
/** Opacity of the innermost ring. Each successive ring loses a further `0.03`. Defaults to `0.24`. */
mainCircleOpacity?: number;
/** How many concentric rings to render. Defaults to `8`. */
numCircles?: number;
/** Theme color family for the ring borders/glow. Defaults to `"neutral"`. */
color?: ThemeColor;
/** Foreground content layered above/centered within the ripple. Defaults to a small demo heading. */
children?: DomphyElement | DomphyElement[];
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
let rippleInstanceCounter = 0;
const RING_SIZE_STEP = 70;
const RING_OPACITY_STEP = 0.03;
const RING_DELAY_STEP_SECONDS = 0.06;
const PULSE_KEYFRAMES = {
"0%": { transform: "translate(-50%, -50%) scale(0.92)" },
"50%": { transform: "translate(-50%, -50%) scale(1)" },
"100%": { transform: "translate(-50%, -50%) scale(0.92)" },
};
function ringElement(
index: number,
mainCircleSize: number,
mainCircleOpacity: number,
color: ThemeColor,
animationName: string,
): DomphyElement {
const size = mainCircleSize + index * RING_SIZE_STEP;
const opacity = Math.max(0.02, mainCircleOpacity - index * RING_OPACITY_STEP);
const delaySeconds = index * RING_DELAY_STEP_SECONDS;
return {
div: null,
_key: `ring-${index}`,
ariaHidden: "true",
// Decorative ring outline with no text of its own — exempt from the
// missing-color contract (mirrors meteors.ts's streak spans). Also
// exempt from low-opacity: this is an ambient background ring, not an
// interactive control that needs to stay discoverable at rest.
_doctorDisable: ["missing-color", "low-opacity"],
style: {
position: "absolute",
top: "50%",
left: "50%",
width: `${size}px`,
height: `${size}px`,
borderRadius: "50%",
transform: "translate(-50%, -50%)",
opacity,
borderWidth: "1px",
borderStyle: "solid",
borderColor: (listener: Listener) => themeColor(listener, "shift-9", color),
boxShadow: (listener: Listener) =>
`0 0 ${themeSpacing(6)} ${themeColor(listener, "shift-7", color)}`,
animation: `${animationName} 2s ease-in-out ${delaySeconds}s infinite`,
[`@keyframes ${animationName}`]: PULSE_KEYFRAMES,
} as StyleObject,
} as DomphyElement;
}
/**
* A stack of concentric rings that gently pulse with staggered delays,
* reading as an outward ripple radiating from a focal center. Call with no
* arguments for a working demo — 8 rings behind a centered heading.
*/
function ripple(props: RippleProps = {}): DomphyElement<"div"> {
const mainCircleSize = Math.max(1, props.mainCircleSize ?? 210);
const mainCircleOpacity = props.mainCircleOpacity ?? 0.24;
const numCircles = Math.max(1, Math.round(props.numCircles ?? 8));
const color = props.color ?? "neutral";
const instanceId = ++rippleInstanceCounter;
const animationName = `ripple-pulse-${hashString(
JSON.stringify({ instanceId, PULSE_KEYFRAMES }),
)}`;
const rings: DomphyElement[] = Array.from({ length: numCircles }, (_unused, index) =>
ringElement(index, mainCircleSize, mainCircleOpacity, color, animationName),
);
const contentChildren: DomphyElement[] = props.children
? Array.isArray(props.children)
? props.children
: [props.children]
: [
{ h2: "Ripple", $: [heading()] } as DomphyElement,
{
p: "Concentric rings gently pulsing outward from the center.",
$: [paragraph()],
} as DomphyElement,
];
const ringsWrapper: DomphyElement = {
div: rings,
ariaHidden: "true",
style: {
position: "absolute",
inset: 0,
overflow: "hidden",
pointerEvents: "none",
maskImage: "linear-gradient(to bottom, black 55%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 55%, transparent 100%)",
} as StyleObject,
} as DomphyElement;
return {
div: [
ringsWrapper,
{
div: contentChildren,
style: {
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
},
} as DomphyElement,
],
dataTone: "shift-15",
style: {
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
borderRadius: themeSpacing(4),
padding: themeSpacing(8),
minHeight: themeSpacing(64),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { ripple };