A soft outer glow that breathes around a surface on desynchronized periods — a calm ambient pulse, not a strobe.
npx shadcn@latest add @paragon/glow-pulse"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface GlowPulseProps extends React.ComponentProps<"div"> {
/** Seconds per breath (one full swell + settle). */
duration?: number;
/** Corner radius of the glow in px. Match the parent's radius. */
borderRadius?: number;
/** Peak glow opacity, 0–1. */
strength?: number;
/** Glow spread in px at rest; the pulse grows it ~40% at the crest. */
spread?: number;
/**
* Glow color. Defaults to the accent so it reads in both themes; a single
* color keeps it calm. Two colors cross-fade the two breathing layers.
*/
colors?: string[];
}
const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);
/**
* A soft outer glow that breathes around a surface — a slow, enterprise-calm
* pulse, not a strobe.
*
* Technique: two blurred radial halos sit behind the surface and animate only
* opacity + a small transform scale on desynchronized periods (×1 / ×1.47), so
* the swell never mechanically repeats and the blur is never recomputed per
* frame. The layers are isolated behind the content and masked to fade inward,
* so the glow reads as ambient bloom hugging the edge rather than a filled
* plate. Pauses offscreen via IntersectionObserver; reduced motion parks a
* steady glow at mid-strength. Absolutely positioned — parent needs
* position: relative; render it as the first child so it sits behind content.
*/
export function GlowPulse({
duration = 4.5,
borderRadius = 12,
strength = 0.55,
spread = 22,
colors = ["#4D80E6", "#38bdf8"],
className,
style,
ref: forwardedRef,
...props
}: GlowPulseProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const localRef = React.useRef<HTMLDivElement | null>(null);
const [inView, setInView] = React.useState(true);
React.useEffect(() => {
const node = localRef.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
setInView(entry?.isIntersecting ?? true);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
const level = clamp01(strength);
const spreadPx = Math.max(0, spread);
const layers = colors.length > 0 ? colors : ["#4D80E6"];
// Desynchronized periods so the two halos breathe out of phase.
const periods = [1, 1.47];
return (
<>
<style href={`paragon-glow-pulse-${id}`} precedence="paragon">{`
@keyframes glow-pulse-${id} {
0%, 100% { opacity: 0.55; transform: scale(0.97); }
50% { opacity: 1; transform: scale(1.04); }
}
[data-glowpulse="${id}"][data-paused] > span { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
[data-glowpulse="${id}"] > span {
animation: none !important;
opacity: 0.8 !important;
transform: none !important;
}
}
`}</style>
<div
aria-hidden
data-glowpulse={id}
data-paused={!inView || undefined}
ref={(node) => {
localRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
className={cn("pointer-events-none absolute inset-0", className)}
style={{ borderRadius, ...style }}
{...props}
>
{layers.map((color, i) => {
const period = duration * periods[i % periods.length];
const grow = spreadPx * (1 + i * 0.35);
return (
<span
key={`${color}-${i}`}
style={{
position: "absolute",
inset: -grow,
borderRadius: borderRadius + grow,
background: color,
filter: `blur(${Math.min(grow * 0.9 + 6, 40)}px)`,
opacity: level,
// Fade the plate inward so it hugs the edge as bloom.
maskImage:
"radial-gradient(ellipse at center, transparent 42%, black 82%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, transparent 42%, black 82%)",
willChange: "opacity, transform",
animation: `glow-pulse-${id} ${period}s var(--ease-in-out) infinite`,
animationDelay: `${(-period * (i * 0.5)).toFixed(2)}s`,
}}
/>
);
})}
</div>
</>
);
}