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. Children stay interactive; settles to a static centered spotlight on touch pointers and 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";
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;
}
const DEFAULT_SPRING = { stiffness: 200, damping: 28, mass: 0.7 };
const PULSE_PEAK = 1.32;
const BASE_GLOW = 0.3;
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. Scoped entirely to
* its wrapper (never hijacks the page cursor); children stay fully interactive
* because every overlay is `pointer-events-none`. On coarse/touch pointers, when
* `paused`, and under `prefers-reduced-motion` 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,
}: 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 only with a real cursor, motion allowed, and not paused. Otherwise
// we render the static centered spotlight below.
const follow = finePointer && !reducedMotion && !paused;
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%)`;
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 onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
mx.set(e.clientX - rect.left);
my.set(e.clientY - rect.top);
};
const onLeave = () => seatCenter();
const onDown = () => {
if (pulseOnClick) pulse.set(PULSE_PEAK);
};
const onUp = () => pulse.set(1);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", 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("pointerdown", onDown);
window.removeEventListener("pointerup", onUp);
pulse.set(1);
};
}, [follow, pulseOnClick, 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 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). |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.