"use client";
import { useCallback, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface TorchlightProps {
children?: ReactNode;
className?: string;
radius?: number;
softness?: number;
darkness?: number;
restReveal?: number;
smoothing?: number;
flicker?: number;
torchColor?: string;
reducedMotion?: boolean;
}
const TORCH_CSS = `
@property --torch-x { syntax: "<length>"; inherits: true; initial-value: -9999px; }
@property --torch-y { syntax: "<length>"; inherits: true; initial-value: -9999px; }
@keyframes sg-torch-flicker {
0%, 100% { opacity: calc(0.4 - var(--sg-flick, 0) * 0.03); transform: scale(1); }
18% { opacity: calc(0.4 - var(--sg-flick, 0) * 0.16); transform: scale(calc(1 - var(--sg-flick, 0) * 0.035)); }
37% { opacity: 0.4; transform: scale(calc(1 + var(--sg-flick, 0) * 0.025)); }
55% { opacity: calc(0.4 - var(--sg-flick, 0) * 0.07); transform: scale(1); }
72% { opacity: calc(0.4 - var(--sg-flick, 0) * 0.2); transform: scale(calc(1 - var(--sg-flick, 0) * 0.02)); }
88% { opacity: calc(0.4 - var(--sg-flick, 0) * 0.05); transform: scale(calc(1 + var(--sg-flick, 0) * 0.015)); }
}
@media (prefers-reduced-motion: reduce) {
[data-sg-torch-glow] { animation: none !important; }
}
`;
export default function Torchlight({
children,
className,
radius = 140,
softness = 0.5,
darkness = 0.92,
restReveal = 0,
smoothing = 0.15,
flicker = 0.15,
torchColor = "#a855f7",
reducedMotion = false,
}: TorchlightProps) {
const rootRef = useRef<HTMLDivElement>(null);
const moveTo = useCallback((clientX: number, clientY: number) => {
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
el.style.setProperty("--torch-x", `${clientX - rect.left}px`);
el.style.setProperty("--torch-y", `${clientY - rect.top}px`);
}, []);
const park = useCallback(() => {
const el = rootRef.current;
if (!el) return;
el.style.setProperty("--torch-x", "-9999px");
el.style.setProperty("--torch-y", "-9999px");
}, []);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => moveTo(e.clientX, e.clientY),
[moveTo]
);
const onTouchMove = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
const t = e.touches[0];
if (t) moveTo(t.clientX, t.clientY);
},
[moveTo]
);
const soft = Math.min(Math.max(softness, 0), 1);
const inner = Math.max(radius * (1 - soft), 0);
const outer = radius * (1 + soft) + 1;
const overlayOpacity =
Math.min(Math.max(darkness, 0), 1) * (1 - Math.min(Math.max(restReveal, 0), 1));
const lagSeconds = (0.03 + Math.min(Math.max(smoothing, 0.02), 1) * 0.57).toFixed(3);
const flickAmt = Math.min(Math.max(flicker, 0), 1);
const glowActive = !reducedMotion && flickAmt > 0;
const revealMask = `radial-gradient(circle at var(--torch-x) var(--torch-y), transparent ${inner}px, #000 ${outer}px)`;
return (
<div
ref={rootRef}
onPointerMove={onPointerMove}
onPointerLeave={park}
onTouchMove={onTouchMove}
onTouchEnd={park}
onTouchCancel={park}
className={cn(
"relative isolate touch-none select-none overflow-hidden",
className,
)}
style={
{
"--torch-x": "-9999px",
"--torch-y": "-9999px",
transition: `--torch-x ${lagSeconds}s cubic-bezier(0.22, 1, 0.36, 1), --torch-y ${lagSeconds}s cubic-bezier(0.22, 1, 0.36, 1)`,
} as React.CSSProperties
}
>
<style>{TORCH_CSS}</style>
<div className="relative z-0">{children}</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10"
style={{
background: "rgb(5 5 8)",
opacity: overlayOpacity,
maskImage: revealMask,
WebkitMaskImage: revealMask,
}}
/>
<div
aria-hidden
data-sg-torch-glow
className="pointer-events-none absolute inset-0 z-20"
style={
{
background: `radial-gradient(circle at var(--torch-x) var(--torch-y), ${torchColor} 0px, transparent ${(radius * 1.15).toFixed(0)}px)`,
mixBlendMode: "plus-lighter",
transformOrigin: "var(--torch-x) var(--torch-y)",
opacity: 0.4,
"--sg-flick": flickAmt,
animation: glowActive
? "sg-torch-flicker 140ms steps(2, end) infinite"
: "none",
} as React.CSSProperties
}
/>
</div>
);
}