# Lantern — Cursor Effect for React

> A spotlight cursor: wraps any content, dims the surface, and cuts a soft radial light that spring-follows the pointer to reveal what's beneath — ringed by a faint warm falloff and flaring brighter on press (rapid presses stack). Children stay interactive; idles on its own when untouched, and settles to a static centered spotlight under reduced motion.

- Page: https://crucibleui.com/components/cursors/lantern
- Category: [cursors](https://crucibleui.com/components/cursors)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/lantern
```

Registry JSON: https://crucibleui.com/r/lantern.json

Import from `@/components/crucible/lantern`.

## Props (`LanternProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `React.ReactNode` | — | Content revealed beneath the light. Everything outside the spotlight is dimmed. |
| `className?` | `string` | — | — |
| `radius?` | `number` | `190` | Spotlight radius in px. |
| `softness?` | `number` | `0.6` | Edge feather, `0` (crisp cut) → `0.95` (very soft falloff). |
| `intensity?` | `number` | `0.9` | How dark everything outside the light gets, `0` → `1`. |
| `colors?` | `{ /** Tint of the darkened area outside the light. @default "#050608" */ dim?: string; /** Warm light cast at the spotlight's falloff edge. @default "#ffce8a" */ glow?: string; }` | — | Palette of the lantern. |
| `spring?` | `{ stiffness?: number; damping?: number; mass?: number }` | — | Spring physics for the follow. Softer = the light lags further behind the cursor. |
| `pulseOnClick?` | `boolean` | `true` | Expand + brighten the light on pointer press. |
| `paused?` | `boolean` | `false` | Freeze to the static centered spotlight (no follow). |
| `idle?` | `boolean` | `true` | Idle autoplay: after 1.5s with no pointer inside, a ghost hand sweeps the light along a slow Lissajous path (with the odd gentle flare) so the surface never looks dead; a real pointer takes over instantly. Also runs on touch-only devices. Never under reduced motion; sleeps offscreen. |
| `ref?` | `React.Ref<LanternHandle>` | — | React 19 ref prop — imperative `pulse(x?, y?)`. |

## Usage example

```tsx
import { Lantern } from "@/components/crucible/lantern";

export default function LanternDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <Lantern className="flex h-full w-full items-center justify-center">
        <div className="flex flex-col items-center gap-6 px-6 py-12 text-center">
          <p className="text-3xl font-semibold tracking-tight text-white">Move your cursor to reveal</p>
          <p className="max-w-md text-sm leading-relaxed text-neutral-300">
            The surface is dark. A soft light follows your pointer and lifts the
            content beneath it out of the shadow. Press and hold to flare it brighter.
          </p>
          <div className="grid w-full max-w-lg grid-cols-3 gap-3">
            {["Aperture", "Falloff", "Umbra"].map((label) => (
              <div
                key={label}
                className="rounded-xl border border-white/10 bg-white/[0.04] p-4 text-left"
              >
                <p className="text-sm font-medium text-white">{label}</p>
                <p className="mt-1 text-xs text-neutral-400">Hidden until the light lands on it.</p>
              </div>
            ))}
          </div>
        </div>
      </Lantern>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`lantern.tsx`)

```tsx
"use client";

import * as React from "react";
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

export interface LanternHandle {
  /**
   * Flare the light once. Pass container-local coordinates (px from the
   * top-left) to move the light there first. Repeated calls stack — each adds
   * to the flare, up to a cap — instead of restarting it.
   */
  pulse: (x?: number, y?: number) => void;
}

export interface LanternProps {
  /** Content revealed beneath the light. Everything outside the spotlight is dimmed. */
  children?: React.ReactNode;
  className?: string;
  /** Spotlight radius in px. @default 190 */
  radius?: number;
  /** Edge feather, `0` (crisp cut) → `0.95` (very soft falloff). @default 0.6 */
  softness?: number;
  /** How dark everything outside the light gets, `0` → `1`. @default 0.9 */
  intensity?: number;
  /** Palette of the lantern. */
  colors?: {
    /** Tint of the darkened area outside the light. @default "#050608" */
    dim?: string;
    /** Warm light cast at the spotlight's falloff edge. @default "#ffce8a" */
    glow?: string;
  };
  /** Spring physics for the follow. Softer = the light lags further behind the cursor. */
  spring?: { stiffness?: number; damping?: number; mass?: number };
  /** Expand + brighten the light on pointer press. @default true */
  pulseOnClick?: boolean;
  /** Freeze to the static centered spotlight (no follow). @default false */
  paused?: boolean;
  /**
   * Idle autoplay: after 1.5s with no pointer inside, a ghost hand sweeps the
   * light along a slow Lissajous path (with the odd gentle flare) so the
   * surface never looks dead; a real pointer takes over instantly. Also runs on
   * touch-only devices. Never under reduced motion; sleeps offscreen.
   * @default true
   */
  idle?: boolean;
  /** React 19 ref prop — imperative `pulse(x?, y?)`. */
  ref?: React.Ref<LanternHandle>;
}

const DEFAULT_SPRING = { stiffness: 200, damping: 28, mass: 0.7 };
const PULSE_PEAK = 1.32;
const BASE_GLOW = 0.3;
/** Each stacked press adds this much flare on top of whatever is still glowing. */
const PULSE_STEP = PULSE_PEAK - 1;
const PULSE_CAP = 1.75;
const IDLE_MS = 1500;
const GHOST_SPEED = 170; // px/s — an unhurried hand
const GHOST_FLARE_EVERY = 5.2; // s between the ghost's gentle flares

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/**
 * Lantern — a flashlight / spotlight cursor. Wraps arbitrary children, dims the
 * whole surface, and cuts a soft radial hole of light that spring-follows the
 * pointer to reveal the content beneath, ringed by a faint warm falloff. On
 * press the light expands and brightens in a quick pulse; rapid presses stack
 * into a bigger flare (capped), and `ref.pulse(x?, y?)` fires one imperatively.
 * With no pointer for 1.5s an idle ghost sweeps the light on a slow Lissajous
 * path (offscreen-paused) until a real pointer takes over. Scoped entirely to
 * its wrapper (never hijacks the page cursor); children stay fully interactive
 * because every overlay is `pointer-events-none`. When `paused`, under
 * `prefers-reduced-motion`, and on touch pointers with `idle={false}` it settles to an aesthetic static
 * spotlight centered on the surface — a designed resting frame, never a blank
 * box.
 */
export function Lantern({
  children,
  className,
  radius = 190,
  softness = 0.6,
  intensity = 0.9,
  colors,
  spring,
  pulseOnClick = true,
  paused = false,
  idle = true,
  ref,
}: LanternProps) {
  const wrapperRef = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const [finePointer, setFinePointer] = React.useState(false);

  const dim = colors?.dim ?? "#050608";
  const glow = colors?.glow ?? "#ffce8a";

  const r = Math.max(40, radius);
  const soft = clamp(softness, 0, 0.95);
  const dimOpacity = clamp(intensity, 0, 1);
  // Transparent core radius as a % of the gradient; higher softness ⇒ smaller
  // clear core and a wider feather out to the dim.
  const innerPct = Math.round((1 - soft) * 100);
  const glowInner = Math.max(0, innerPct - 14);
  const glowMid = Math.min(98, innerPct + 4);

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

  // Follow with a real cursor — or, with `idle`, on any device so the ghost can
  // drive it — when motion is allowed and not paused. Otherwise we render the
  // static centered spotlight below.
  const follow = (finePointer || idle) && !reducedMotion && !paused;
  const ghostOn = follow && idle;

  const springCfg = { ...DEFAULT_SPRING, ...spring };
  const mx = useMotionValue(0);
  const my = useMotionValue(0);
  const x = useSpring(mx, springCfg);
  const y = useSpring(my, springCfg);

  // Press pulse: expands the radius and lifts the warm glow, then springs back.
  const pulse = useSpring(1, { stiffness: 320, damping: 18, mass: 0.5 });
  const rDim = useTransform(pulse, (p) => r * p);
  const rGlow = useTransform(pulse, (p) => r * p * 1.05);
  const glowOpacity = useTransform(pulse, (p) => clamp(BASE_GLOW + (p - 1) * 0.8, 0, 0.65));

  const dimBg = useMotionTemplate`radial-gradient(circle ${rDim}px at ${x}px ${y}px, transparent 0%, transparent ${innerPct}%, ${dim} 100%)`;
  const glowBg = useMotionTemplate`radial-gradient(circle ${rGlow}px at ${x}px ${y}px, transparent ${glowInner}%, ${glow} ${glowMid}%, transparent 100%)`;

  // Pointer presence for the idle ghost. `inside` = a real pointer is over the
  // surface; `lastReal` = when it last moved/left (ms, performance.now()).
  const presenceRef = React.useRef({ inside: false, lastReal: 0 });
  const ghostRef = React.useRef({ t: 0, last: 0, on: false, nextFlare: 0, flareUntil: 0 });
  const flareTimerRef = React.useRef(0);

  /** Stack another flare onto whatever is still glowing (capped). */
  const flare = React.useCallback(() => {
    pulse.set(Math.min(PULSE_CAP, Math.max(PULSE_PEAK, pulse.get() + PULSE_STEP)));
  }, [pulse]);

  const tick = React.useCallback(
    (elapsed: number) => {
      const el = wrapperRef.current;
      const g = ghostRef.current;
      if (elapsed < g.last) g.last = elapsed;
      const dt = Math.min(elapsed - g.last, 0.05);
      g.last = elapsed;
      if (!el) return;
      const pr = presenceRef.current;
      if (pr.inside || performance.now() - pr.lastReal < IDLE_MS) {
        if (g.on) {
          g.on = false;
          if (g.flareUntil) pulse.set(1);
          g.flareUntil = 0;
        }
        return;
      }
      const w = el.clientWidth;
      const h = el.clientHeight;
      if (w < 2 || h < 2) return;
      if (!g.on) {
        g.on = true;
        g.nextFlare = elapsed + GHOST_FLARE_EVERY * 0.6;
      }
      // Slow 3:2 Lissajous over the middle of the surface.
      const ax = w * 0.32;
      const ay = h * 0.26;
      g.t += (dt * GHOST_SPEED) / Math.max(90, (ax * 3 + ay * 2) * 0.7);
      mx.set(w / 2 + ax * Math.sin(3 * g.t + 0.6));
      my.set(h / 2 + ay * Math.sin(2 * g.t));
      // The ghost "presses" now and then — a softer flare than a real click.
      if (pulseOnClick && elapsed >= g.nextFlare) {
        g.nextFlare = elapsed + GHOST_FLARE_EVERY;
        g.flareUntil = elapsed + 0.32;
        pulse.set(1 + PULSE_STEP * 0.7);
      } else if (g.flareUntil && elapsed >= g.flareUntil) {
        g.flareUntil = 0;
        pulse.set(1);
      }
    },
    [mx, my, pulse, pulseOnClick]
  );

  // The ghost loop rides the overlay so it sleeps offscreen / on hidden tabs.
  const ghostLoopRef = useVisibilityPause<HTMLDivElement>(tick, { paused: !ghostOn });

  React.useEffect(() => {
    if (!follow) return;
    const el = wrapperRef.current;
    if (!el) return;

    // Seat the light at the surface centre until the pointer arrives.
    const seatCenter = () => {
      const rect = el.getBoundingClientRect();
      mx.set(rect.width / 2);
      my.set(rect.height / 2);
    };
    seatCenter();

    const pr = presenceRef.current;
    pr.lastReal = performance.now();
    const onMove = (e: PointerEvent) => {
      pr.inside = true;
      pr.lastReal = performance.now();
      const rect = el.getBoundingClientRect();
      mx.set(e.clientX - rect.left);
      my.set(e.clientY - rect.top);
    };
    const onLeave = () => {
      pr.inside = false;
      pr.lastReal = performance.now();
      seatCenter();
    };
    const onDown = (e: PointerEvent) => {
      pr.inside = true;
      pr.lastReal = performance.now();
      const rect = el.getBoundingClientRect();
      mx.set(e.clientX - rect.left);
      my.set(e.clientY - rect.top);
      // Rapid presses stack: each adds to the flare still in flight.
      if (pulseOnClick) flare();
    };
    const onUp = () => pulse.set(1);

    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    el.addEventListener("pointercancel", onLeave);
    el.addEventListener("pointerdown", onDown);
    // Release anywhere so a drag that ends off-surface still relaxes the pulse.
    window.addEventListener("pointerup", onUp);

    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      el.removeEventListener("pointercancel", onLeave);
      el.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointerup", onUp);
      window.clearTimeout(flareTimerRef.current);
      pr.inside = false;
      pulse.set(1);
    };
  }, [follow, pulseOnClick, mx, my, pulse, flare]);

  React.useImperativeHandle(
    ref,
    () => ({
      pulse(px, py) {
        if (!follow) return;
        if (px !== undefined && py !== undefined) {
          mx.set(px);
          my.set(py);
          // Hold the ghost off so the light stays where it was sent.
          presenceRef.current.lastReal = performance.now();
        }
        flare();
        window.clearTimeout(flareTimerRef.current);
        flareTimerRef.current = window.setTimeout(() => pulse.set(1), 180);
      },
    }),
    [follow, flare, mx, my, pulse]
  );

  // Static resting frame (reduced motion / coarse pointer / paused): a centered
  // spotlight, fully painted — the same visual language, just anchored.
  const staticDimBg = `radial-gradient(circle ${r}px at 50% 50%, transparent 0%, transparent ${innerPct}%, ${dim} 100%)`;
  const staticGlowBg = `radial-gradient(circle ${r * 1.05}px at 50% 50%, transparent ${glowInner}%, ${glow} ${glowMid}%, transparent 100%)`;

  return (
    <div ref={wrapperRef} data-crucible="lantern" className={cn("relative isolate", className)}>
      {children}
      <div ref={ghostLoopRef} aria-hidden className="pointer-events-none absolute inset-0 z-30">
        {follow ? (
          <>
            <motion.div className="absolute inset-0" style={{ background: dimBg, opacity: dimOpacity }} />
            <motion.div
              className="absolute inset-0"
              style={{ background: glowBg, opacity: glowOpacity, mixBlendMode: "screen" }}
            />
          </>
        ) : (
          <>
            <div className="absolute inset-0" style={{ background: staticDimBg, opacity: dimOpacity }} />
            <div
              className="absolute inset-0"
              style={{ background: staticGlowBg, opacity: BASE_GLOW, mixBlendMode: "screen" }}
            />
          </>
        )}
      </div>
    </div>
  );
}
```
