bottega

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-bg

Usage

Usagetsx
<RippleBg />

Props

PropTypeDefaultDescription
ringCountnumber5Number of staggered rings in the sonar pulse. Default 5.
speednumber4Seconds for one ring to travel from centre to full expansion. Default 4.
colorenumborderToken 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