Lantern
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.
"use client";
import * as React from "react";
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/lanternManual — install dependencies, then copy the source
npm install motionProps
| 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?). |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.