backgrounds
Ripple Background
A sonar-pulse background layer. Thin token-coloured rings (--border / --primary / --accent at 30% alpha) expand from the container centre with scale + opacity keyframes, staggered via negative animation-delay so the loop is continuous. Reads on both Atelier dark and light themes via token-only colours. Decorative (aria-hidden, pointer-events:none); an IntersectionObserver pauses offscreen.
ripple-bg
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/ripple-bgUsage
Usagetsx
<RippleBg />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| ringCount | number | 5 | Number of staggered rings in the sonar pulse. Default 5. |
| speed | number | 4 | Seconds for one ring to travel from centre to full expansion. Default 4. |
| color | enum | border | Token to color the rings ("border" | "primary" | "accent"). Default "border". |
Source
ripple-bg.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./ripple-bg.module.css";
type RippleColor = "border" | "primary" | "accent";
/** Map token names to CSS variable references for inline style injection. */
const TOKEN_MAP: Record<RippleColor, string> = {
border: "var(--border)",
primary: "var(--primary)",
accent: "var(--accent)",
};
export interface RippleBgProps extends React.HTMLAttributes<HTMLDivElement> {
/** Number of staggered rings in the sonar pulse. Default 5. */
ringCount?: number;
/** Seconds for one ring to travel from centre to full expansion. Default 4. */
speed?: number;
/** Token to color the rings ("border" | "primary" | "accent"). Default "border". */
color?: RippleColor;
}
/**
* Sonar-pulse background layer (client component).
*
* Concentric rings expand from the centre of the container with scale + opacity
* keyframes — thin token-coloured circles at controlled alpha. Rings are evenly
* staggered via negative animation-delay so the pulse is continuous (no cold-
* start gap). The layer is fully decorative: aria-hidden, pointer-events:none.
*
* Mount gate + null-safe useReducedMotion() guard (avoids React #418 and the
* reduced-motion flash). Reduced-motion users and SSR see a static set of
* concentric rings from --border instead. An IntersectionObserver pauses the
* animation when the layer is scrolled out of view.
*
* Place sibling content at z-10 or above; this layer sits at z-0.
*/
export function RippleBg({
ringCount = 5,
speed = 4,
color = "border",
className = "",
style,
"aria-hidden": ariaHidden = true,
...props
}: RippleBgProps) {
// Contract mount-gate + null-safe reduce check (avoids React #418 + flash).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
const layerRef = useRef<HTMLDivElement>(null);
const [running, setRunning] = useState(true);
// Pause the rings when scrolled out of view to save GPU time.
useEffect(() => {
if (!animate) return;
const el = layerRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setRunning(entry.isIntersecting),
{ rootMargin: "0px" },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
const rippleColor = TOKEN_MAP[color];
return (
<div
role="presentation"
aria-hidden={ariaHidden}
className={[styles.root, className].filter(Boolean).join(" ")}
style={style}
{...props}
>
{/* Animated layer: only mounted for non-reduced clients after hydration. */}
{animate && (
<div
ref={layerRef}
className={styles.layer}
style={
{
"--ripple-color": rippleColor,
"--ripple-duration": `${speed}s`,
} as React.CSSProperties
}
>
{Array.from({ length: ringCount }, (_, i) => {
// Negative delays distribute rings evenly across the full cycle so
// frame 0 has rings at every stage — no empty cold-start pause.
// Ring i is (i / ringCount) of the way through its expansion.
const delay = -((i / ringCount) * speed);
return (
<span
key={i}
className={styles.ring}
style={
{
"--ripple-delay": `${delay}s`,
animationPlayState: running ? "running" : "paused",
} as React.CSSProperties
}
/>
);
})}
</div>
)}
{/* Static fallback: always in the DOM so SSR/first paint and reduced-motion
users see a spatial base. Hidden once the animated layer takes over
(non-reduced, mounted clients). No random geometry — pure CSS rings. */}
<div
className={[
styles.staticFallback,
animate ? styles.staticFallbackHidden : "",
]
.filter(Boolean)
.join(" ")}
aria-hidden="true"
>
<span className={styles.staticRing} />
<span className={styles.staticRing} />
<span className={styles.staticRing} />
<span className={styles.staticRing} />
<span className={styles.staticRing} />
</div>
</div>
);
}
Dependencies
- motion
- @bottega/tokens