Glow Pulse
Effects & Borders

Glow Pulse

A soft outer glow that breathes around a surface on desynchronized periods — a calm ambient pulse, not a strobe.

Install

npx shadcn@latest add @paragon/glow-pulse

glow-pulse.tsx

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