Effects & Borders

Pulse Ring

Expanding rings that pulse outward from a point for status or notification emphasis, static under reduced motion.

Install

npx shadcn@latest add @paragon/pulse-ring

pulse-ring.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface PulseRingProps extends React.ComponentProps<"span"> {
  /** Core dot diameter in px. Rings overflow this footprint. */
  size?: number;
  /** Number of concurrently expanding rings. */
  rings?: number;
  /** Seconds for one ring to travel from the core to fully faded. */
  duration?: number;
  /** How far rings expand, as a multiple of the core size. */
  scale?: number;
  /** Render the resting state: the core dot plus one static halo ring. */
  static?: boolean;
}

/**
 * Expanding rings that pulse outward from a point — status or notification
 * emphasis (live indicators, unread markers, incident dots).
 *
 * Technique: ring layers animate only transform (scale from 1, never below)
 * and opacity, desynchronized via negative animation delays so the loop is
 * already mid-flight on mount. Color rides currentColor — set it with a
 * text-* class. Pauses offscreen via IntersectionObserver; under
 * prefers-reduced-motion (or `static`) it collapses to a single still ring.
 *
 * Decorative: aria-hidden by default. Pair it with visible text or an
 * sr-only label that carries the actual status.
 */
export function PulseRing({
  size = 10,
  rings = 3,
  duration = 2.2,
  scale = 2.6,
  static: isStatic = false,
  className,
  style,
  ...props
}: PulseRingProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const ref = React.useRef<HTMLSpanElement>(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]);

  const haloScale = Math.min(1.6, scale);

  if (isStatic) {
    return (
      <span
        aria-hidden="true"
        data-slot="pulse-ring"
        className={cn(
          "pointer-events-none relative inline-flex text-primary select-none",
          className,
        )}
        style={{ width: size, height: size, ...style }}
        {...props}
      >
        <span
          className="absolute inset-0 rounded-full border border-current"
          style={{ transform: `scale(${haloScale})`, opacity: 0.35 }}
        />
        <span className="relative size-full rounded-full bg-current" />
      </span>
    );
  }

  return (
    <span
      ref={ref}
      aria-hidden="true"
      data-slot="pulse-ring"
      data-pulse={id}
      data-paused={offscreen || undefined}
      className={cn(
        "pointer-events-none relative inline-flex text-primary select-none",
        className,
      )}
      style={{ width: size, height: size, ...style }}
      {...props}
    >
      <style href={`paragon-pulse-ring-${id}`} precedence="paragon">{`
        @keyframes pulse-ring-${id} {
          0% { transform: scale(1); opacity: 0.5; }
          100% { transform: scale(${scale}); opacity: 0; }
        }
        @keyframes pulse-core-${id} {
          0%, 100% { transform: scale(1); }
          50% { transform: scale(0.9); }
        }
        [data-pulse="${id}"][data-paused] [data-pulse-layer] {
          animation-play-state: paused;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-pulse="${id}"] [data-pulse-layer] {
            animation: none;
          }
          [data-pulse="${id}"] [data-pulse-ring] {
            opacity: 0;
          }
          [data-pulse="${id}"] [data-pulse-ring="0"] {
            opacity: 0.35;
            transform: scale(${haloScale});
          }
        }
      `}</style>
      {Array.from({ length: rings }, (_, i) => (
        <span
          key={i}
          data-pulse-layer=""
          data-pulse-ring={i}
          className="absolute inset-0 rounded-full border border-current opacity-0 will-change-transform"
          style={{
            animation: `pulse-ring-${id} ${duration}s var(--ease-out) infinite`,
            animationDelay: `${(-(i * duration) / rings).toFixed(3)}s`,
          }}
        />
      ))}
      <span
        data-pulse-layer=""
        className="relative size-full rounded-full bg-current"
        style={{
          animation: `pulse-core-${id} ${duration}s var(--ease-in-out) infinite`,
        }}
      />
    </span>
  );
}