Guiding Light — Cursor Spotlight with Trailing Flame
Goal
Build a three-section scrolling page. The middle section is the star: a cursor-driven spotlight. When the pointer enters that section a dark veil drops over the whole section, punched through by a soft circular hole that follows the cursor — so content (heading + paragraph) is dimmed everywhere except a moving "flashlight" window around the pointer. Inside that section a small animated flame (Lottie) with a pulsing warm glow trails the cursor with a smooth lag, drifting from its home position toward wherever you point and easing back to center when the pointer leaves the section. Both the spotlight hole and the flame move with lerped smoothing, not snapping. The other two sections (before/after) are plain full-screen headings you scroll past with Lenis smooth scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. No GSAP is used at all. The only runtime dependencies are:
lenis(npm) — page-wide smooth scroll, started withnew Lenis({ autoRaf: true }).lottie-web(npm) — loads and plays the flame animation JSON,renderer: "svg",loop: true,autoplay: true.
All motion of the spotlight and flame is produced by a single hand-rolled requestAnimationFrame loop that linearly interpolates (lerps) values into CSS custom properties and a transform. Do not reach for any tween library.
Layout / HTML
Three stacked full-viewport <section>s. The middle one holds the spotlight machinery.
<section class="intro">
<h1>Enter the Field</h1>
</section>
<section class="spotlight">
<div class="lottie-container">
<div class="lottie"></div>
<div class="fire-glow"></div>
</div>
<h1>Guided by Interaction</h1>
<p>This space reacts with subtlety rather than instruction, responding to
presence instead of force. Movement becomes a signal, gently shifting
emphasis as light traces what matters in the moment. Nothing here
competes for attention or demands a fixed outcome. Focus forms gradually,
shaped by interaction, hesitation, and intent, allowing detail to surface
only when it is approached.</p>
<div class="spotlight-mask"></div>
</section>
<section class="outro">
<h1>The Interaction Ends</h1>
</section>
<script type="module" src="./script.js"></script>
Use this neutral placeholder copy (no real brand names). The class names .spotlight, .lottie-container, .lottie, .fire-glow, .spotlight-mask are load-bearing (JS and CSS target them). lottie-web injects its SVG inside .lottie.
Styling
Font: Amarante (Google Fonts) — a display serif, applied to the whole body.
Color tokens (on :root):
:root {
--light: #d0f2c2; /* pale mint green — text */
--dark: #141414; /* near-black — background & the veil */
--mouse-x: 0; /* px, live-updated by JS */
--mouse-y: 0; /* px, live-updated by JS */
}
- Global reset:
* { margin:0; padding:0; box-sizing:border-box; user-select:none; }. body { font-family:"Amarante",sans-serif; background-color:var(--dark); color:var(--light); }.h1 { font-size:clamp(3rem, 5vw, 7rem); font-weight:500; letter-spacing:-0.15rem; }.p { font-size:2rem; };.spotlight p { width:60%; text-align:center; }.section { position:relative; width:100%; height:100svh; padding:2rem; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:1rem; }— every section is a centered full-height column.
Flame container:
.lottie-container { position:relative; width:8rem; height:8rem; pointer-events:none; }— the element JS translates..lottie { width:100%; height:100%; transform:scale(1.25); }— the SVG flame, slightly enlarged.
Warm glow behind the flame (pure CSS, independent of JS):
.fire-glow { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:40%; height:100%; z-index:-1; pointer-events:none; filter:blur(20px); opacity:0.25; }- Background is a radial gradient of warm fire colors:
radial-gradient(circle, rgba(255,0,72,0.75) 0%, rgba(255,145,0,0.6) 30%, rgba(255,242,140,0.25) 50%, transparent 70%) — hot pink-red core → orange → pale yellow → transparent.
- Continuously animated with
animation: firePulse 2s ease-in-out infinite;:
@keyframes firePulse {
0%, 100% { transform:translate(-50%,-50%) scaleY(1); opacity:0.5; }
25% { transform:translate(-50%,-50%) scaleY(1.2); opacity:0.4; }
50% { transform:translate(-50%,-50%) scaleY(0.9); opacity:0.35; }
75% { transform:translate(-50%,-50%) scaleY(1.1); opacity:0.5; }
}
The spotlight veil / mask (the heart of the look):
.spotlight-mask {
position:absolute; top:0; left:0; width:100%; height:100%;
pointer-events:none;
background: var(--dark);
-webkit-mask: radial-gradient(
circle 200px at var(--mouse-x) var(--mouse-y),
transparent 0%,
transparent 40%,
var(--dark) 80%,
var(--dark) 100%
);
mask: radial-gradient(
circle 200px at var(--mouse-x) var(--mouse-y),
transparent 0%,
transparent 40%,
var(--dark) 80%,
var(--dark) 100%
);
transition: opacity 0.3s ease;
opacity: 0;
}
.spotlight-mask.active { opacity: 0.85; }
How this reads: .spotlight-mask is a full-section overlay filled with the dark background color. The CSS mask (alpha mask) is a 200px-radius radial gradient centered at (--mouse-x, --mouse-y): alpha 0 (fully transparent) from the center out to 40% of the radius, ramping to opaque (var(--dark) is fully opaque) by 80%. Where the mask is transparent the veil is punched out → the section content shows through bright; where it's opaque the veil covers the content → dimmed. So the result is an inverted spotlight: a soft ~160px-radius clear circle around the cursor, everything else darkened. The veil is invisible (opacity:0) until JS adds .active (fades to opacity:0.85 over 0.3s) when the cursor is inside the section. --mouse-x/--mouse-y are set live by JS on the .spotlight element and inherited by the mask.
Responsive: @media (max-width:1000px){ .spotlight p { font-size:1.5rem; width:100%; } }.
The effect (be exhaustive — this is the whole component)
No GSAP timeline. Two synchronized behaviors both run off one requestAnimationFrame loop with a lerp factor of 0.1: (A) the spotlight hole position (via CSS vars) and (B) the flame container translate. Trigger is mousemove (plus scroll to keep it aligned).
Init on load
new Lenis({ autoRaf: true })— smooth scroll for the whole page.- Load the flame:
lottie.loadAnimation({ container: .lottie, renderer:"svg", loop:true, autoplay:true, path: "/path/to/fire.json" }). - Grab
spotlight(.spotlight),lottieContainer(.lottie-container),spotlightMask(.spotlight-mask).
State & position bookkeeping
const state = { isTracking: false, cursorDetected: false };
const pos = {
mouse: { target:{x:0,y:0}, current:{x:0,y:0}, last:{x:0,y:0} },
lottie: { current:{x:0,y:0}, center:{x:0,y:0} },
};
init() (run once via setTimeout(init, 100) and again on window resize):
spotlightRect = spotlight.getBoundingClientRect(),lottieRect = lottieContainer.getBoundingClientRect().- Store the flame's home center relative to the spotlight section's top-left:
pos.lottie.center.x = lottieRect.left - spotlightRect.left + lottieRect.width/2; same for .y.
- Seed the mouse at the section center:
pos.mouse.current.x = pos.mouse.target.x = spotlightRect.width/2; same for.y.
Pointer / scroll input → updateCursor(x, y)
function updateCursor(x, y) {
if (!state.cursorDetected) return;
pos.mouse.last.x = x; pos.mouse.last.y = y; // remember last screen pos
const r = spotlight.getBoundingClientRect();
const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
if (inside) {
pos.mouse.target.x = x - r.left; // target = pointer, relative to section
pos.mouse.target.y = y - r.top;
state.isTracking = true;
spotlightMask.classList.add("active"); // fade the veil in
} else {
state.isTracking = false;
spotlightMask.classList.remove("active"); // fade the veil out
}
}
Wire it up:
windowmouseenter({ once:true }) andmouseover({ once:true }): setstate.cursorDetected = truethenupdateCursor(e.clientX, e.clientY)— first-contact bootstrap so the effect can start.documentmousemove:state.cursorDetected = true; updateCursor(e.clientX, e.clientY)— the main driver.windowscroll: ifstate.cursorDetected, callupdateCursor(pos.mouse.last.x, pos.mouse.last.y)— re-evaluates using the last known cursor position so the spotlight stays aligned to the content as it scrolls under a stationary pointer (and correctly deactivates when the section scrolls away from the cursor).windowresize:init.
The rAF loop (animate) — runs forever
function animate() {
// (A) smooth the spotlight hole toward the pointer (lerp 0.1)
pos.mouse.current.x += (pos.mouse.target.x - pos.mouse.current.x) * 0.1;
pos.mouse.current.y += (pos.mouse.target.y - pos.mouse.current.y) * 0.1;
spotlight.style.setProperty("--mouse-x", `${pos.mouse.current.x}px`);
spotlight.style.setProperty("--mouse-y", `${pos.mouse.current.y}px`);
// (B) flame target: offset from its home center while tracking, else 0 (return home)
const targetX = state.isTracking ? pos.mouse.current.x - pos.lottie.center.x : 0;
const targetY = state.isTracking ? pos.mouse.current.y - pos.lottie.center.y : 0;
pos.lottie.current.x += (targetX - pos.lottie.current.x) * 0.1; // lerp 0.1
pos.lottie.current.y += (targetY - pos.lottie.current.y) * 0.1;
lottieContainer.style.transform =
`translate(${pos.lottie.current.x}px, ${pos.lottie.current.y}px)`;
requestAnimationFrame(animate);
}
setTimeout(init, 100);
animate();
Behavior that produces:
- Spotlight hole:
pos.mouse.currenteases towardpos.mouse.targetat 0.1/frame, and its value is written into--mouse-x/--mouse-y, which reposition the mask gradient's center. The hole therefore chases the cursor with a soft trailing lag rather than snapping. - Flame trail: while
isTracking, the flame's target iscurrent mouse − flame home center, i.e. the vector from its resting spot to the pointer; lerpingpos.lottie.currenttoward that at 0.1/frame drifts the flame toward the cursor. When the pointer leaves the section (isTracking=false), the target becomes0, so the flame eases back to its home position. The.fire-glowkeeps pulsing independently via its CSSfirePulsekeyframes the whole time. - Because both use the same 0.1 factor, hole and flame feel unified — a gentle, weighty follow, never instantaneous.
Assets / images
One Lottie JSON (no bitmap images): a looping stylized animated flame — a small candle/torch-like fire, warm palette (hot red core, orange mid, pale-yellow tips), transparent background, roughly 4:3 source (≈1600×1200), a seamless ~2s loop at 60fps. Rendered as inline SVG at 8rem box, scaled 1.25. Any tasteful animated flame works; do not use branded artwork. Load it from an assets path such as /path/to/fire.json.
Behavior notes
- Desktop / pointer-driven only. The whole spotlight is
mousemove-based (plusscrollre-alignment); there is no touch/click path. Outside the.spotlightsection the veil is inactive and the flame sits at home. - Lenis smooth scroll applies to the entire 3-section page; the spotlight/flame effect is scoped to the middle section.
initis deliberately delayed 100ms after load (so layout is settled) and re-run on resize to recompute the flame's home center and section geometry.- The rAF loop runs continuously with no reduced-motion branch. Keep
--light #d0f2c2,--dark #141414, the warm glow gradient, and the mask stops (200px radius; transparent 0–40%, opaque 80–100%) at these values for the look to read correctly.