Expanding rings that pulse outward from a point for status or notification emphasis, static under reduced motion.
npx shadcn@latest add @paragon/pulse-ring"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>
);
}