bottega

effects

Ripple Dot Loader

A four-circle loading indicator in which each circle simultaneously pulses its scale while its inner filled dot collapses to nothing and an expanding ripple ring radiates outward from its edge. The three layers — outer ring scale, inner dot scale, and ripple outline — are driven by three co-located CSS keyframes (circle-pulse, dot-pulse, ripple). Each circle staggers its animation-delay by one-third of the cycle speed, producing a continuous left-to-right wave. Exposes size and speed props; all colors are resolved from --primary and --foreground via color-mix. Pure CSS art — no JS or pointer logic — so it ships as a server component.

Ported from Li-Deheng (MIT)

Loading…

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-dot-loader

Usage

Usagetsx
<RippleDotLoader />

Props

PropTypeDefaultDescription
labelstringLoading…Accessible label announced to screen readers.
sizenumber20Diameter of each circle in px (default 20). The ripple ring scales relative to this size.
speednumber2Full animation cycle duration in seconds (default 2). Lower = faster spinner; raise for a more relaxed pace.
classNamestringAdditional class names merged onto the root element.
styleReact.CSSPropertiesForwarded to the root <div>.

Source

ripple-dot-loader.tsxtsx
import type React from "react";
import styles from "./ripple-dot-loader.module.css";

export type RippleDotLoaderProps = {
  /**
   * Accessible label announced to screen readers.
   * @default "Loading…"
   */
  label?: string;
  /**
   * Diameter of each circle in px (default 20).
   * The ripple ring scales relative to this size.
   */
  size?: number;
  /**
   * Full animation cycle duration in seconds (default 2).
   * Lower = faster spinner; raise for a more relaxed pace.
   */
  speed?: number;
  /** Additional class names merged onto the root element. */
  className?: string;
  /** Forwarded to the root <div>. */
  style?: React.CSSProperties;
};

const CIRCLE_COUNT = 4;

/**
 * RippleDotLoader — four circles pulse in a staggered sequence.
 * Each circle shrinks its filled dot to zero while an expanding ripple ring
 * radiates outward, then the dot fills back in — creating a breathing,
 * liquid-pulse effect. Stagger delay increments by speed/6.67 per circle.
 */
export function RippleDotLoader({
  label = "Loading…",
  size = 20,
  speed = 2,
  className = "",
  style,
}: RippleDotLoaderProps) {
  const staggerStep = speed / 3; // seconds between each circle's animation start

  return (
    <div
      role="status"
      aria-label={label}
      className={[styles.loader, className].filter(Boolean).join(" ")}
      style={
        {
          ...style,
          "--size": `${size}px`,
          "--speed": `${speed}s`,
          "--stagger": `${staggerStep}s`,
          "--gap": `${Math.round(size * 0.5)}px`,
        } as React.CSSProperties
      }
    >
      {Array.from({ length: CIRCLE_COUNT }, (_, i) => (
        <div
          key={i}
          className={styles.circle}
          style={
            {
              "--delay": `${(i * staggerStep).toFixed(2)}s`,
              "--outline-delay": `${((i + 3) * staggerStep).toFixed(2)}s`,
            } as React.CSSProperties
          }
        >
          <div className={styles.dot} aria-hidden="true" />
          <div className={styles.outline} aria-hidden="true" />
        </div>
      ))}
      <span className={styles.srOnly}>{label}</span>
    </div>
  );
}

Dependencies

  • @bottega/tokens