Backgrounds

Ripple Background

Concentric circles that emanate slowly from a center point, paused offscreen and static under reduced motion.

Install

npx shadcn@latest add @paragon/ripple-background

ripple-background.tsx

"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>
  );
}