Cursors & Pointer

Cursor Glow

A soft aurora glow that trails the pointer behind the content on screen blend, stretching toward travel direction and easing back to a circle at rest.

Install

npx shadcn@latest add @paragon/cursor-glow

cursor-glow.tsx

"use client";

import * as React from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * CursorGlow — a soft aurora blob that follows the pointer with spring lag,
 * sitting BEHIND the content on `screen` blend so it lights the surface like a
 * moving light source rather than a cursor. It stretches subtly in the
 * direction of travel (velocity-aware) and eases back to a circle at rest.
 *
 * Wrap any content: `<CursorGlow>…surface…</CursorGlow>`. The glow layer is
 * pointer-events-none and does not hide the native cursor (it is ambient, not a
 * pointer replacement) unless `hideCursor` is set. Gated on fine-pointer
 * devices. Reduced motion pins a static, centered glow.
 */
export interface CursorGlowProps extends React.ComponentProps<"div"> {
  /** Glow color. Defaults to the primary token. */
  color?: string;
  /** Glow diameter in px. */
  size?: number;
  /** Glow opacity, 0–1. */
  intensity?: number;
  /** Also hide the native cursor within the surface. */
  hideCursor?: boolean;
}

const GLOW_SPRING: SpringOptions = { stiffness: 150, damping: 22, mass: 1 };

export function CursorGlow({
  color = "var(--color-primary)",
  size = 260,
  intensity = 0.5,
  hideCursor = false,
  className,
  children,
  ...props
}: CursorGlowProps) {
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [fine, setFine] = React.useState(false);
  const [inside, setInside] = React.useState(false);
  const reduced = useReducedMotion();

  const x = useMotionValue(-9999);
  const y = useMotionValue(-9999);
  const gx = useSpring(x, GLOW_SPRING);
  const gy = useSpring(y, GLOW_SPRING);

  // Velocity-aware stretch: the lag between raw and sprung position implies
  // travel direction; convert to a gentle scale skew.
  const stretchX = useTransform([x, gx], ([rx, sx]: number[]) =>
    reduced ? 1 : 1 + Math.min(0.28, Math.abs(rx - sx) / 380),
  );
  const stretchY = useTransform([y, gy], ([ry, sy]: number[]) =>
    reduced ? 1 : 1 + Math.min(0.28, Math.abs(ry - sy) / 380),
  );

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    const el = hostRef.current;
    if (!el || !fine) return;
    if (reduced) {
      const rect = el.getBoundingClientRect();
      x.set(rect.width / 2);
      y.set(rect.height / 2);
      setInside(true);
      return;
    }
    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      x.set(e.clientX - rect.left);
      y.set(e.clientY - rect.top);
    };
    const onEnter = () => setInside(true);
    const onLeave = () => setInside(false);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, [fine, reduced, x, y]);

  return (
    <div
      ref={hostRef}
      className={cn(
        "relative overflow-hidden",
        fine && hideCursor && "[&_*]:cursor-none",
        className,
      )}
      style={fine && hideCursor ? { cursor: "none" } : undefined}
      {...props}
    >
      {fine && (
        <motion.div
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-0 rounded-full"
          style={{
            x: reduced ? x : gx,
            y: reduced ? y : gy,
            scaleX: stretchX,
            scaleY: stretchY,
            width: size,
            height: size,
            marginLeft: -size / 2,
            marginTop: -size / 2,
            background: `radial-gradient(circle at center, ${color} 0%, transparent 68%)`,
            filter: "blur(28px)",
            mixBlendMode: "screen",
            opacity: inside ? intensity : 0,
            transition: "opacity 300ms ease",
          }}
        />
      )}
      <div className="relative z-10">{children}</div>
    </div>
  );
}