Spotlight overlay

Docs Surfaces

Spotlight overlay

The pointer-tracking spotlight over the dot grid, and why it is one fixed layer.

Purpose

This document describes the current full-page spotlight overlay implementation used to highlight the existing dot grid as the pointer moves.

The effect is designed to stay visually clean:

  • Reuse the same 24px dot grid already rendered by body::before
  • Highlight dots across the whole page, not only inside the hero
  • Fade out smoothly when pointer movement stops
  • Keep runtime work isolated to a single fixed overlay layer

Implementation Summary

The spotlight is implemented as a global fixed overlay mounted in the base layout.

Files involved:

  • src/layouts/Layout.astro
  • src/styles/globals.css

The overlay does not render new particles or draw to canvas. It only reveals a brighter copy of the same dot grid through moving CSS masks.

DOM Structure

The overlay is mounted once near the top of <body>:

HTML
<div class="spotlight-overlay" data-spotlight-overlay aria-hidden="true">
  <div class="spotlight-overlay__grid spotlight-overlay__grid--soft"></div>
  <div class="spotlight-overlay__grid spotlight-overlay__grid--core"></div>
</div>

The rest of the page content is wrapped in .site-shell so content remains above the overlay in stacking order.

Visual Layers

Base Grid

The static grid remains on body::before:

CSS
body::before {
  background-image: radial-gradient(circle, hsl(var(--grid)) 1px, transparent 1px);
  background-size: 24px 24px;
}

This layer is always visible and acts as the baseline visual texture.

Soft Highlight Layer

.spotlight-overlay__grid--soft uses:

  • The same dot pattern as the base grid
  • Slight blur
  • An elliptical mask offset by pointer velocity

This creates a subtle trailing edge without introducing a visible shadow or glow bloom.

Core Highlight Layer

.spotlight-overlay__grid--core uses:

  • The same dot pattern
  • No blur
  • A tighter ellipse centered on the pointer

This provides the crisp highlight that makes the spotlight feel precise.

Runtime Behavior

The animation logic lives in an inline script in Layout.astro.

Input Handling

The script listens to:

  • pointermove
  • mouseout
  • blur

Only fine pointers are enabled:

TypeScript
const prefersFinePointer = window.matchMedia('(pointer: fine)');

Reduced motion disables the effect entirely:

TypeScript
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

State Model

The effect tracks:

  • targetX, targetY: latest pointer coordinates
  • currentX, currentY: smoothed spotlight coordinates
  • velocityX, velocityY: recent pointer delta for trailing shape
  • lastPointerMoveTime: timestamp of latest movement
  • currentOpacity: smoothed rendered opacity

Idle Fade

The spotlight now starts fading immediately when pointer movement stops.

It does not wait before fading. Instead, visibility is derived continuously from elapsed idle time:

TypeScript
const idleElapsedMs = hasPointer
  ? Math.max(0, timestamp - lastPointerMoveTime)
  : idleFadeDurationMs;

const idleVisibility = Math.max(0, 1 - idleElapsedMs / idleFadeDurationMs);
targetOpacity = idleVisibility;

Current fade duration:

  • idleFadeDurationMs = 600

This means:

  • Moving pointer: spotlight stays fully active
  • Pointer stops: spotlight begins fading immediately
  • After 600 ms of no movement: spotlight reaches zero opacity

Animation Model

The effect is driven by requestAnimationFrame.

Instead of fixed per-frame increments, smoothing is time-based:

TypeScript
const deltaMs = Math.min(32, timestamp - lastFrameTime || 16.67);
const positionBlend = 1 - Math.exp(-deltaMs / positionSmoothingMs);
const opacityBlend = 1 - Math.exp(-deltaMs / opacitySmoothingMs);
const velocityDecay = Math.exp(-deltaMs / velocityDecayMs);

This keeps the motion more stable across different refresh rates and under occasional frame drops.

Current timing constants:

  • positionSmoothingMs = 42
  • opacitySmoothingMs = 72
  • velocityDecayMs = 58

These values were tuned for a more responsive, less floaty feel.

CSS Variable Contract

The script only mutates variables on .spotlight-overlay, not on html or body.

Variables updated at runtime:

  • --spotlight-x
  • --spotlight-y
  • --spotlight-tail-x
  • --spotlight-tail-y
  • --spotlight-soft-radius
  • --spotlight-core-radius
  • --spotlight-soft-opacity
  • --spotlight-core-opacity

This keeps style invalidation scoped to the overlay subtree.

Performance Notes

The current implementation intentionally avoids canvas and keeps the effect CSS-driven.

Performance-related choices:

  • Use one fixed overlay instead of per-section effects
  • Reuse the existing grid pattern
  • Write CSS variables to the overlay element only
  • Add contain: strict to the overlay
  • Add contain: paint to child layers
  • Avoid timers for fade orchestration
  • Use a single requestAnimationFrame loop only while animation is active

Relevant CSS:

CSS
.spotlight-overlay {
  contain: strict;
  transform: translateZ(0);
  backface-visibility: hidden;
}

.spotlight-overlay__grid {
  contain: paint;
}

Tuning Guide

Make It More Responsive

Reduce:

  • positionSmoothingMs
  • opacitySmoothingMs

Make It More Stable

Increase:

  • positionSmoothingMs
  • velocityDecayMs

Make It Smaller

Reduce:

  • Base --spotlight-soft-radius
  • Base --spotlight-core-radius

Make It Brighter

Increase:

  • Soft layer opacity multiplier
  • Core layer opacity multiplier
  • Dot alpha in .spotlight-overlay__grid

Current Defaults

Current radius values generated by the script:

  • Soft radius: 236px + speed * 28
  • Core radius: 124px + speed * 14

Current opacity scaling:

  • Soft layer: currentOpacity * 0.28
  • Core layer: currentOpacity * 0.96

Limitations

  • The effect is pointer-driven, so touch devices do not use it
  • The overlay assumes the base grid stays at 24px spacing
  • If the base grid style changes, the spotlight grid must be updated to match

Future Improvements

Possible refinements if needed later:

  • Respect hover-capable devices instead of only fine pointers
  • Add route-level opt-out for pages that should stay fully static
  • Promote the grid spacing to a shared CSS custom property to remove duplication
  • Add DevTools-friendly debug mode for radius and opacity tuning