Concentric circles that emanate slowly from a center point, paused offscreen and static under reduced motion.
npx shadcn@latest add @paragon/ripple-background"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface RippleBackgroundProps extends React.ComponentProps<"div"> {
/** Number of concurrently expanding rings. */
rings?: number;
/** Ring diameter at the start of its journey, in px. */
size?: number;
/** How far each ring expands, as a multiple of its starting size. */
scale?: number;
/** Seconds for one ring to travel from the center to fully faded. */
duration?: number;
/** Horizontal position of the epicenter (any CSS length or percentage). */
originX?: string;
/** Vertical position of the epicenter (any CSS length or percentage). */
originY?: string;
/** Render the resting state: fixed concentric rings, no animation. */
static?: boolean;
}
/**
* Concentric circles that emanate slowly from a center point — ambient
* depth behind hero panels, empty states, and "listening" surfaces.
*
* Technique: each ring is a 1px-bordered circle (borders, not box-shadow)
* animating only transform (scale from 1, never below) and opacity, with
* long negative delays so the loop is already mid-flight on mount. Color
* rides currentColor — retint via a text-* class. Pauses offscreen via
* IntersectionObserver; under prefers-reduced-motion (or `static`) it
* settles into fixed concentric rings.
*
* Decorative: aria-hidden, pointer-events-none. Absolutely positioned —
* parent needs position: relative.
*/
export function RippleBackground({
rings = 4,
size = 120,
scale = 6,
duration = 10,
originX = "50%",
originY = "50%",
static: isStatic = false,
className,
style,
...props
}: RippleBackgroundProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const ref = React.useRef<HTMLDivElement>(null);
const [offscreen, setOffscreen] = React.useState(false);
React.useEffect(() => {
if (isStatic) return;
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
setOffscreen(!entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}, [isStatic]);
// The resting state: rings frozen evenly along their journey, fading
// outward. Shared by the `static` render and the reduced-motion override.
const resting = Array.from({ length: rings }, (_, i) => {
const t = (i + 1) / rings;
return {
scale: 1 + t * (scale - 1),
opacity: 1 - t * 0.75,
};
});
const ringBox: React.CSSProperties = {
width: size,
height: size,
left: -size / 2,
top: -size / 2,
};
if (isStatic) {
return (
<div
aria-hidden="true"
data-slot="ripple-background"
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden text-foreground/15 select-none",
className,
)}
style={style}
{...props}
>
<div className="absolute" style={{ left: originX, top: originY }}>
{resting.map((ring, i) => (
<span
key={i}
className="absolute rounded-full border border-current"
style={{
...ringBox,
transform: `scale(${ring.scale.toFixed(3)})`,
opacity: ring.opacity.toFixed(3),
}}
/>
))}
</div>
</div>
);
}
return (
<div
ref={ref}
aria-hidden="true"
data-slot="ripple-background"
data-ripple={id}
data-paused={offscreen || undefined}
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden text-foreground/15 select-none",
className,
)}
style={style}
{...props}
>
<style href={`paragon-ripple-background-${id}`} precedence="paragon">{`
@keyframes ripple-${id} {
0% { transform: scale(1); opacity: 0; }
12% { opacity: 1; }
100% { transform: scale(${scale}); opacity: 0; }
}
[data-ripple="${id}"] [data-ripple-ring] {
animation: ripple-${id} ${duration}s var(--ease-out) infinite;
}
${resting
.map(
(_, i) =>
`[data-ripple="${id}"] [data-ripple-ring="${i}"] { animation-delay: ${(-(i * duration) / rings).toFixed(3)}s; }`,
)
.join("\n ")}
[data-ripple="${id}"][data-paused] [data-ripple-ring] {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
[data-ripple="${id}"] [data-ripple-ring] {
animation: none;
}
${resting
.map(
(ring, i) =>
`[data-ripple="${id}"] [data-ripple-ring="${i}"] { transform: scale(${ring.scale.toFixed(3)}); opacity: ${ring.opacity.toFixed(3)}; }`,
)
.join("\n ")}
}
`}</style>
<div className="absolute" style={{ left: originX, top: originY }}>
{Array.from({ length: rings }, (_, i) => (
<span
key={i}
data-ripple-ring={i}
className="absolute rounded-full border border-current opacity-0 will-change-transform"
style={ringBox}
/>
))}
</div>
</div>
);
}