# Cursor Glow

> A pointer light with an optional comet trail.

Category: Effects. Docs: https://neonkit.heyaadi.com/components/cursor-glow

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/cursor-glow.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/cursor-glow`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { CursorGlow } from "@/components/ui/cursor-glow"

<section className="relative">
  <CursorGlow trail />
  …
</section>
```

## Examples

### Comet trail

Move the pointer over the panel. The light lags a touch behind it, fast moves leave a tail, and the dots under it burn neon through reveal.

```tsx
import { CursorGlow } from "@/components/ui/cursor-glow";

export default function CursorGlowDemo() {
  return (
    <div className="relative w-full max-w-3xl overflow-hidden rounded-2xl border border-white/10 bg-[#07080d] [--neon:var(--neon-accent,34_211_238)] [background-image:radial-gradient(rgb(255_255_255/0.09)_1px,transparent_1px)] [background-size:22px_22px]">
      {/* Under the light the same dot grid burns neon, as if the panel were perforated and lit from behind. */}
      <CursorGlow
        trail
        reveal={
          <div className="absolute inset-0 [background-image:radial-gradient(rgb(var(--neon))_1.3px,transparent_2px)] [background-size:22px_22px]" />
        }
      />
      <div className="relative px-6 py-20 text-center sm:py-24">
        <h3 className="mx-auto max-w-md text-balance text-3xl font-medium tracking-tight text-white/90 sm:text-4xl">
          Ship at 2am. Sleep anyway.
        </h3>
        <p className="mx-auto mt-4 max-w-sm text-sm leading-relaxed text-white/55">
          Deploys roll back on their own when error rates climb, so the pager stays in the drawer.
        </p>
      </div>
    </div>
  );
}
```

### Size and tail

A small light with a long tail, and a wide, dim one without.

```tsx
import { CursorGlow } from "@/components/ui/cursor-glow";

const panels = [
  { label: "size 180 · trail 600ms", props: { size: 180, trail: true, trailLength: 600 } },
  { label: "size 560 · intensity 0.6", props: { size: 560, intensity: 0.6 } },
];

export default function CursorGlowSettings() {
  return (
    <div className="grid w-full gap-3 sm:grid-cols-2">
      {panels.map((panel) => (
        <div
          key={panel.label}
          className="relative h-56 overflow-hidden rounded-xl border border-white/10 bg-[#07080d] [background-image:linear-gradient(rgb(255_255_255/0.05)_1px,transparent_1px),linear-gradient(90deg,rgb(255_255_255/0.05)_1px,transparent_1px)] [background-size:28px_28px]"
        >
          <CursorGlow {...panel.props} />
          <p className="absolute left-4 top-4 font-mono text-xs text-white/50">{panel.label}</p>
        </div>
      ))}
    </div>
  );
}
```

### Whole page

target="window" covers the viewport and follows the pointer everywhere.

```tsx
"use client";

import * as React from "react";
import { CursorGlow } from "@/components/ui/cursor-glow";
import { Switch } from "@/components/ui/switch";

export default function CursorGlowWindow() {
  const [on, setOn] = React.useState(false);

  return (
    <div className="flex flex-col items-center gap-3 text-center">
      <Switch label="Light the whole page" checked={on} onCheckedChange={setOn} />
      <p className="max-w-xs text-sm text-white/50">
        With target=&quot;window&quot; the light follows the pointer anywhere on the page.
      </p>
      {on && <CursorGlow target="window" size={520} intensity={0.7} trail />}
    </div>
  );
}
```

## API reference

### CursorGlow

A light layer. With the default target it fills its parent, which needs position: relative. Also accepts div attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `target` | `"parent" \| "window"` | `"parent"` | Follow the pointer over the parent element, or over the whole window (a fixed layer). |
| `size` | `number` | `360` | Diameter of the light in pixels. |
| `intensity` | `number` | `1` | Brightness of the light and tail, 0 to 1. |
| `trail` | `boolean` | `false` | A comet tail that streams behind fast movement. |
| `trailLength` | `number` | `260` | How long the tail lingers, in milliseconds. |
| `reveal` | `ReactNode` | — | Content shown only under the light, fading toward its edge. It fills the layer, so a pattern lines up with the same pattern on the parent. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

## Accessibility

- Decorative: aria-hidden and pointer-events none, so nothing under it changes.
- Touch input is ignored. A finger has no hover, and a light left where it lifted looks broken.
- It blends with screen. Place it before your content to light only the background, or after it to light the content too.
- Positions are written straight to transforms from one animation frame loop, which stops as soon as the light settles; React never re-renders while the pointer moves.
- The tail is a canvas sized with ResizeObserver at up to 2x pixel density; it reads the neon color each time the light appears, so accent changes reach it.
- With reduced motion the light follows the pointer without easing and the tail is off.

## Source

`components/ui/cursor-glow.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

export interface CursorGlowProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Follow the pointer over the parent element, or over the whole window. */
  target?: "parent" | "window";
  /** Diameter of the light in pixels. */
  size?: number;
  /** Brightness of the light, 0 to 1. */
  intensity?: number;
  /** A comet tail that streams behind fast movement. */
  trail?: boolean;
  /** How long the tail lingers, in milliseconds. */
  trailLength?: number;
  /**
   * Shown only under the light, fading out toward its edge: a brighter copy of a pattern, a
   * second layer of art. It's positioned like the light layer, so align it with the content below.
   */
  reveal?: React.ReactNode;
}

type Point = { x: number; y: number; t: number };

/** The theme's glow multiplier. A finite check, not `|| 1`, so 0 stays 0. */
function readGlow(node: Element) {
  const value = parseFloat(getComputedStyle(node).getPropertyValue("--neon-glow"));
  return Number.isFinite(value) ? Math.max(0, value) : 1;
}

/**
 * A light layer. With `target="parent"` place it inside a `relative` parent;
 * it fills it. With `target="window"` it covers the viewport.
 */
const CursorGlow = React.forwardRef<HTMLDivElement, CursorGlowProps>(
  (
    {
      className,
      style,
      color,
      target = "parent",
      size = 360,
      intensity = 1,
      trail = false,
      trailLength = 260,
      reveal,
      ...props
    },
    forwardedRef,
  ) => {
    const ref = React.useRef<HTMLDivElement>(null);
    const lightRef = React.useRef<HTMLDivElement>(null);
    const canvasRef = React.useRef<HTMLCanvasElement>(null);
    const revealRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement);

    const strength = Math.min(1, Math.max(0, intensity));
    const settings = React.useRef({ strength, trail, trailLength });
    settings.current = { strength, trail, trailLength };

    React.useEffect(() => {
      const root = ref.current;
      const light = lightRef.current;
      const host: HTMLElement | Window | null = target === "window" ? window : root?.parentElement ?? null;
      if (!root || !light || !host) return;

      const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
      let reduce = motion?.matches ?? false;
      const onMotionChange = () => (reduce = motion?.matches ?? false);
      motion?.addEventListener?.("change", onMotionChange);

      let goalX = 0;
      let goalY = 0;
      let x = 0;
      let y = 0;
      let visible = false;
      let frame = 0;
      let last = 0;
      let rgb = "34 211 238";
      let glow = 1;
      let width = 0;
      let height = 0;
      let points: Point[] = [];

      const canvas = canvasRef.current;
      const context = canvas?.getContext("2d") ?? null;

      const resize = () => {
        width = root.clientWidth;
        height = root.clientHeight;
        if (!canvas || !context) return;
        const ratio = Math.min(window.devicePixelRatio || 1, 2);
        canvas.width = Math.max(1, Math.round(width * ratio));
        canvas.height = Math.max(1, Math.round(height * ratio));
        context.setTransform(ratio, 0, 0, ratio, 0, 0);
      };
      resize();
      const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
      observer?.observe(root);

      // The tail is one tapered ribbon per pass (halo, colored core, white-hot
      // center) with a round head, filled in one go so nothing overlaps and beads up.
      const ribbon = (halfWidth: number, fades: number[], fill: string | CanvasGradient) => {
        if (!context) return;
        const outline: [number, number][] = [];
        const back: [number, number][] = [];
        let nx = 0;
        let ny = 0;
        for (let i = 0; i < points.length; i++) {
          const prev = points[Math.max(0, i - 1)];
          const next = points[Math.min(points.length - 1, i + 1)];
          const dx = next.x - prev.x;
          const dy = next.y - prev.y;
          const length = Math.hypot(dx, dy);
          if (length > 0.001) {
            nx = -dy / length;
            ny = dx / length;
          }
          const h = halfWidth * fades[i];
          outline.push([points[i].x + nx * h, points[i].y + ny * h]);
          back.unshift([points[i].x - nx * h, points[i].y - ny * h]);
        }
        outline.push(...back);
        // Wind the head's circle the same way as the ribbon so the nonzero fill
        // unions them instead of punching a hole.
        let area = 0;
        for (let i = 0; i < outline.length; i++) {
          const [ax, ay] = outline[i];
          const [bx, by] = outline[(i + 1) % outline.length];
          area += ax * by - bx * ay;
        }
        context.beginPath();
        outline.forEach(([px, py], i) => (i ? context.lineTo(px, py) : context.moveTo(px, py)));
        context.closePath();
        const head = points[points.length - 1];
        const radius = halfWidth * fades[fades.length - 1];
        context.moveTo(head.x + radius, head.y);
        context.arc(head.x, head.y, radius, 0, Math.PI * 2, area < 0);
        context.fillStyle = fill;
        context.fill("nonzero");
      };

      const draw = (now: number) => {
        if (!context) return;
        context.clearRect(0, 0, width, height);
        const life = settings.current.trailLength;
        points = points.filter((point) => now - point.t < life);
        if (points.length < 2) return;
        const tail = points[0];
        const head = points[points.length - 1];
        if (Math.hypot(head.x - tail.x, head.y - tail.y) < 1) return;
        const fades = points.map((point) => Math.max(0, 1 - (now - point.t) / life));
        const strength = settings.current.strength;
        const along = (alpha: number, tint: string) => {
          const gradient = context.createLinearGradient(tail.x, tail.y, head.x, head.y);
          gradient.addColorStop(0, `rgb(${tint} / 0)`);
          gradient.addColorStop(1, `rgb(${tint} / ${Math.min(1, alpha * strength * glow)})`);
          return gradient;
        };
        context.globalCompositeOperation = "lighter";
        ribbon(9, fades, along(0.16, rgb));
        ribbon(2.2, fades, along(0.9, rgb));
        ribbon(0.8, fades.map((fade) => fade * fade), along(0.8, "255 255 255"));
      };

      const tick = (now: number) => {
        const dt = last ? Math.min(now - last, 64) : 16;
        last = now;
        // Frame-rate independent easing toward the pointer.
        const follow = reduce ? 1 : 1 - Math.exp(-dt / 55);
        x += (goalX - x) * follow;
        y += (goalY - y) * follow;
        light.style.transform = `translate3d(${x}px, ${y}px, 0)`;
        if (revealRef.current) {
          revealRef.current.style.setProperty("--nk-glow-x", `${x}px`);
          revealRef.current.style.setProperty("--nk-glow-y", `${y}px`);
        }

        const tailing = settings.current.trail && !reduce;
        if (tailing && visible) points.push({ x, y, t: now });
        if (tailing || points.length) draw(now);

        const moving = Math.abs(goalX - x) > 0.1 || Math.abs(goalY - y) > 0.1;
        if ((moving || points.length > 1) && !document.hidden) {
          frame = requestAnimationFrame(tick);
        } else {
          frame = 0;
          last = 0;
          if (context && points.length <= 1) context.clearRect(0, 0, width, height);
        }
      };

      const start = () => {
        if (!frame) frame = requestAnimationFrame(tick);
      };

      const show = (value: boolean) => {
        visible = value;
        light.style.opacity = value ? "1" : "0";
        if (revealRef.current) revealRef.current.style.opacity = value ? "1" : "0";
      };

      const onMove = (event: PointerEvent) => {
        // A finger has no hover, and a light stuck where it lifted looks broken.
        if (event.pointerType === "touch") return;
        const rect = root.getBoundingClientRect();
        goalX = event.clientX - rect.left;
        goalY = event.clientY - rect.top;
        if (!visible) {
          // Read the color fresh each time the light appears: an ancestor's
          // --neon-accent can change without this component re-rendering.
          rgb = getComputedStyle(root).getPropertyValue("--neon").trim() || rgb;
          glow = readGlow(root);
          if (!points.length) {
            x = goalX;
            y = goalY;
          }
          show(true);
        }
        start();
      };

      const onLeave = (event: PointerEvent | Event) => {
        if ("pointerType" in event && event.pointerType === "touch") return;
        show(false);
        start();
      };

      const onVisibility = () => {
        if (document.hidden && frame) {
          cancelAnimationFrame(frame);
          frame = 0;
          last = 0;
        }
      };

      // Leaving the window fires mouseleave on <html>; it doesn't bubble to document.
      const leaveTarget = target === "window" ? document.documentElement : (host as HTMLElement);
      host.addEventListener("pointermove", onMove as EventListener, { passive: true });
      leaveTarget.addEventListener(target === "window" ? "mouseleave" : "pointerleave", onLeave);
      window.addEventListener("blur", onLeave);
      document.addEventListener("visibilitychange", onVisibility);

      return () => {
        cancelAnimationFrame(frame);
        observer?.disconnect();
        motion?.removeEventListener?.("change", onMotionChange);
        host.removeEventListener("pointermove", onMove as EventListener);
        leaveTarget.removeEventListener(target === "window" ? "mouseleave" : "pointerleave", onLeave);
        window.removeEventListener("blur", onLeave);
        document.removeEventListener("visibilitychange", onVisibility);
      };
    }, [target, trail]);

    return (
      <div
        ref={ref}
        aria-hidden
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "pointer-events-none overflow-hidden mix-blend-screen",
          target === "window" ? "fixed inset-0 z-50" : "absolute inset-0 rounded-[inherit]",
          className,
        )}
        {...props}
      >
        <div
          ref={lightRef}
          className="absolute left-0 top-0 opacity-0 transition-opacity duration-300 ease-out will-change-transform"
        >
          <div style={{ opacity: strength }}>
            <div
              className="rounded-full [background:radial-gradient(closest-side,rgb(var(--neon)/CALC(0.3*var(--neon-glow,1))),rgb(var(--neon)/CALC(0.12*var(--neon-glow,1)))_45%,rgb(var(--neon)/CALC(0.04*var(--neon-glow,1)))_70%,transparent)]"
              style={{ width: size, height: size, marginLeft: -size / 2, marginTop: -size / 2 }}
            />
            <div
              className="absolute left-0 top-0 rounded-full [background:radial-gradient(closest-side,rgb(var(--neon)/CALC(0.3*var(--neon-glow,1))),rgb(var(--neon)/CALC(0.1*var(--neon-glow,1)))_50%,transparent)]"
              style={{ width: size / 4, height: size / 4, marginLeft: -size / 8, marginTop: -size / 8 }}
            />
          </div>
        </div>
        {reveal != null && (
          <div
            ref={revealRef}
            className="absolute inset-0 opacity-0 transition-opacity duration-300 ease-out"
            style={{
              maskImage: `radial-gradient(circle ${size * 0.42}px at var(--nk-glow-x, -999px) var(--nk-glow-y, -999px), #000, rgb(0 0 0 / 0.35) 45%, transparent)`,
              WebkitMaskImage: `radial-gradient(circle ${size * 0.42}px at var(--nk-glow-x, -999px) var(--nk-glow-y, -999px), #000, rgb(0 0 0 / 0.35) 45%, transparent)`,
            }}
          >
            {reveal}
          </div>
        )}
        {trail && <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />}
      </div>
    );
  },
);
CursorGlow.displayName = "CursorGlow";

export { CursorGlow };
```
