113

Spotlight Background

Free

A theatrical GSAP and WebGL spotlight background: two stage follow-spots in a hazed room sweep in from the wings, cross over your headline and land on it, then trail the cursor like spots worked by an operator.

advanced
7 more details
webgl-shaderspotlightmouse-followload-sequenceambientpointer-effectstouch-gestures

This demo reads better at your own screen size than in the frame below:

Released

About this effect

A stage-lighting background for a dark hero, drawn as real light rather than gradients. Two follow-spots hang at the top of the frame: a warm white key light and a rose theatre gel. Each fixture has a small, hot lamp with a soft bloom; its beam is a cone of slowly rolling smoke that thins with distance and fades softly at the edges; and a soft elliptical pool glows where the beam lands. Where the two beams cross, the colours add together the way real light does.

Read the full effect overview

On load the lamps strike and the beams swing in from opposite wings, cross once over the headline and settle on it in about a second and a half. The headline catches the light: as a beam passes over it, a highlight travels across the letterforms, so the type looks lit rather than sitting in front of a glow. The heading reads fully on its own ink when unlit; the catch only ever brightens it.

After the entrance the spots follow the cursor the way an operator works them: heavy, a little behind, with a small overshoot when you stop. The gel spot lags the key, so the two separate while you move and re-converge when you rest. Moving fast widens the cones and thickens the haze. Press and hold to pull both into a tight, bright pin spot; let go and they spring back open. Left alone for a few seconds, they drift home to the headline and breathe while the smoke keeps rolling.

Every colour and the haze density are CSS custom properties. Visitors without WebGL or JavaScript get a lit CSS still of the same scene, and reduced motion gets a single rendered frame.

Spotlight Background - GSAP animation effect preview

What's included

14 items
  • Real stage light in WebGL: a hot lamp with bloom at each fixture, cones of rolling haze and soft pools where the beams land
  • Theatrical load sequence: the lamps strike and the beams sweep in from the wings, cross once over the headline and land, in under 1.6 seconds
  • Headline catches the light: a highlight crosses the letterforms with each beam and never lowers the heading's contrast
  • Operator-style follow: weighted, lagged aim with a slight overshoot when the pointer stops, the gel beam trailing the key
  • Press and hold for a tight pin spot; release springs the beams back open
  • Pointer speed widens the cones and thickens the haze
  • Additive light where the two beams and pools overlap
  • Idle return: after a few seconds the beams drift home to the headline and sway gently while the smoke rolls
  • Touch: the beams sweep on their own, drag to aim, tap for a pin-spot pulse
  • Keyboard: arrow keys aim, hold Space or Enter to focus
  • Restyle in CSS: key colour, gel colour, haze and ground are custom properties
  • One or two beams, one intensity control, and an off switch for every autonomous motion
  • Pauses off screen and in hidden tabs; a rendered still under reduced motion and a lit CSS still without WebGL or JavaScript
  • Small API: aim, focus, replay, pause and play

Perfect for

5 use cases
  • Launch and event pages that want a single dramatic moment on arrival
  • Theatre, music, film and venue sites
  • Product reveals and keynote-style hero sections on a dark ground
  • Portfolio and studio landing pages that put one line of type centre stage
  • Coming-soon and waitlist pages

How it works

3 sections

GSAP owns every motion. One timeline plays the entrance on an expo ease: the lamps fade up, the beams swing past each other over the headline and swing back to land. After that each beam follows the pointer on its own weighted chase, the gel beam on a longer one so it trails the key, with a back ease for the overshoot. The pin spot tightens quickly and springs back open on an elastic ease, and GSAP's media queries switch between pointer, touch and reduced-motion behaviour.

A three.js WebGL shader turns those values into light each frame: the lamp glow, the cones of haze, the pools and the additive mixing where the beams overlap. The smoke is a slowly drifting field the beams pass through, so it never smears or stretches when a beam turns or widens, and a fine dither keeps the dark falloff free of banding. The headline's catch is a highlight clipped to the type, laid over the heading's own colour.

The background stops drawing when it leaves the screen or the tab is hidden, and goes fully idle once the beams settle with ambient motion switched off.

Difficulty Advanced
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <title>Spotlight Background Demo | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
    <!-- Required. Runs before first paint: if WebGL is available it stamps
         html.gl, which hides the CSS rendition of the beams so it never
         flashes on a page that is about to get a canvas (three.js is a
         network fetch away). No JavaScript or no WebGL: no class, and the
         CSS rendition shows. The probe context is released at once. -->
    <script>
        (function () {
            try {
                var c = document.createElement('canvas');
                var gl = c.getContext('webgl2') || c.getContext('webgl');
                if (!gl) return;
                var lose = gl.getExtension('WEBGL_lose_context');
                if (lose) lose.loseContext();
                document.documentElement.classList.add('gl');
            } catch (e) {}
        })();
    </script>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Mona+Sans:wght@400;500;800&display=swap">
</head>
<body>
    <main class="stage" tabindex="0" data-spotlight-keys
          aria-label="Northlight spring season" aria-describedby="stage-keys">

        <!-- The background layer. It fills the positioned .stage and sits
             under the content; everything inside it is decorative. The
             script draws the light into a canvas it adds here; the beam and
             pool elements are the CSS rendition shown without WebGL. -->
        <div class="spotlight" data-spotlight data-spotlight-target=".stage__title" aria-hidden="true">
            <div class="spotlight__beam" data-spotlight-beam="key">
                <div class="spotlight__cone"></div>
                <div class="spotlight__haze"></div>
            </div>
            <div class="spotlight__beam" data-spotlight-beam="gel">
                <div class="spotlight__cone"></div>
                <div class="spotlight__haze"></div>
            </div>
            <div class="spotlight__pool" data-spotlight-pool="key"></div>
            <div class="spotlight__pool" data-spotlight-pool="gel"></div>
        </div>

        <div class="stage__content">
            <h1 class="stage__title">Opening night is 14 March.</h1>
            <p class="stage__lede">Six new productions in the restored Corn Exchange. The good seats go first.</p>
            <a class="stage__btn" href="#season">See what’s on</a>
            <p class="stage__cue" data-vault-badge-pad>
                <span class="stage__cue--fine">Move to aim the lights. Hold to focus.</span>
                <span class="stage__cue--touch">Drag to aim the lights. Tap to focus.</span>
            </p>
        </div>

        <p class="sr-only" id="stage-keys">Arrow keys aim the stage lights; hold Space to focus them.</p>
    </main>

    <!-- GSAP core: every motion value, matchMedia and teardown. -->
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <!-- three.js as an ES module through an import map; the shim below sets
         window.THREE and then loads the effect as a plain script. If three
         cannot load, the CSS rendition comes back. -->
    <script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.min.js"
      }
    }
    </script>
    <script type="module">
      const showFallback = () => document.documentElement.classList.remove('gl');
      import('three').then((THREE) => {
        window.THREE = THREE;
        const effect = document.createElement('script');
        effect.src = 'assets/script.js';
        effect.onerror = showFallback;
        document.body.appendChild(effect);
      }).catch(showFallback);
    </script>
</body>
</html>
/**
 * Spotlight Background
 *
 * Two stage follow-spots hung at the top of the frame, drawn in WebGL as
 * real light in a hazed room: a small hot lamp at each fixture, a cone of
 * lit smoke that fades with distance, and a soft pool
 * where each beam lands. On load the beams sweep in from the wings, cross
 * once over the headline and land on it; after that they follow the
 * pointer like spots worked by an operator, heavy and lagged, and drift
 * home to breathe on the headline when left alone. Press and hold pulls
 * both into a tight pin spot. The heading the light falls on catches it: a
 * highlight travels across the letterforms with each beam.
 *
 * Who owns what:
 *   GSAP   every motion value: the entrance timeline, the lagged aim
 *          (quickTo with a back ease for the overshoot), the pin spot and
 *          its elastic release, the idle return and sway, matchMedia
 *          (pointer, touch, reduced motion) and teardown. The frame loop
 *          runs on gsap.ticker.
 *   Shader reads those values as uniforms each frame and draws the light:
 *          cones, haze, pools, lamps, additive mixing and dither.
 *
 * Without WebGL or JavaScript the stylesheet's CSS rendition shows instead:
 * a lit, resting frame. Under reduced motion the WebGL frame is drawn once,
 * as a still.
 *
 * @plugins none (GSAP core, three.js for the canvas)
 * @techniques webgl-shader, spotlight, mouse-follow, load-sequence, ambient, pointer-effects, touch-gestures
 */

/* Every helper and shared value is declared before first use. The demo
   build obfuscates this file and turns function declarations into
   non-hoisted consts. */

/* Reads a numeric data attribute so that an explicit "0" is honoured. */
const SPOTLIGHT_NUM = function (value, fallback) {
    if (value === undefined || value === null || value === '') return fallback;
    const n = parseFloat(value);
    return isFinite(n) ? n : fallback;
};

const SPOTLIGHT_CLAMP = function (value, min, max) {
    return value < min ? min : (value > max ? max : value);
};

const SPOTLIGHT_ON = function (value) {
    return value !== '0' && value !== 'false';
};

/* Any CSS colour (hex, rgb(), hsl(), oklch(), color-mix()...) to an sRGB
   triple in 0..1, by letting the browser paint it into one pixel. */
const SPOTLIGHT_COLOR = function (value, fallback) {
    try {
        const probe = document.createElement('canvas');
        probe.width = 1;
        probe.height = 1;
        const g = probe.getContext('2d', { willReadFrequently: true });
        g.fillStyle = fallback;
        g.fillStyle = (value || '').trim() || fallback;
        g.fillRect(0, 0, 1, 1);
        const px = g.getImageData(0, 0, 1, 1).data;
        return [px[0] / 255, px[1] / 255, px[2] / 255];
    } catch (error) {
        return [1, 1, 1];
    }
};

/* three.js logs a failed context as several console errors before it
   throws, so support is probed with a throwaway context first, and that
   context is released at once: browsers cap live contexts per page. */
const SPOTLIGHT_HAS_WEBGL = function () {
    try {
        const canvas = document.createElement('canvas');
        const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
        if (!gl) return false;
        const lose = gl.getExtension('WEBGL_lose_context');
        if (lose) lose.loseContext();
        return true;
    } catch (error) {
        return false;
    }
};

/* How the operator feels. The key spot answers in under a second; the gel
   spot takes half as long again, so the two separate while the pointer
   moves and re-converge after it stops. The back ease is the slight
   overshoot a heavy fixture makes when the operator stops it. */
const SPOTLIGHT_KEY_FOLLOW = { duration: 0.85, ease: 'back.out(1.5)' };
const SPOTLIGHT_GEL_FOLLOW = { duration: 1.3, ease: 'back.out(1.8)' };
const SPOTLIGHT_IDLE_AFTER = 2.5;

/* The haze clock the reduced-motion still is drawn at: a frame where the
   smoke has body in both beams. */
const SPOTLIGHT_STILL_TIME = 14;

/* ============================================================================
   SHADERS
   One full-screen triangle pair; everything is in the fragment shader. The
   strings are globals so a thumbnail page can reuse them unchanged.
   ============================================================================ */

const SPOTLIGHT_VERTEX_SHADER = /* glsl */ `
varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;

const SPOTLIGHT_FRAGMENT_SHADER = /* glsl */ `
precision highp float;

uniform vec2 uRes;        // layer size in CSS px
uniform float uDpr;
uniform float uTime;      // seconds, wrapped
uniform vec3 uGround;     // sRGB 0..1
uniform vec3 uKeyColor;   // sRGB 0..1
uniform vec3 uGelColor;
uniform vec4 uKeyGeo;     // apex.xy, land.xy  (CSS px, y down)
uniform vec4 uGelGeo;
uniform vec3 uKeyShape;   // pool half-width px, brightness, focus 0..1
uniform vec3 uGelShape;
uniform float uHaze;      // haze density, 0..~2

/* 3D simplex noise. Ashima Arts / Ian McEwan, MIT. */
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
    const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
    const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
    vec3 i  = floor(v + dot(v, C.yyy));
    vec3 x0 = v - i + dot(i, C.xxx);
    vec3 g  = step(x0.yzx, x0.xyz);
    vec3 l  = 1.0 - g;
    vec3 i1 = min(g.xyz, l.zxy);
    vec3 i2 = max(g.xyz, l.zxy);
    vec3 x1 = x0 - i1 + C.xxx;
    vec3 x2 = x0 - i2 + C.yyy;
    vec3 x3 = x0 - D.yyy;
    i = mod289(i);
    vec4 p = permute(permute(permute(
              i.z + vec4(0.0, i1.z, i2.z, 1.0))
            + i.y + vec4(0.0, i1.y, i2.y, 1.0))
            + i.x + vec4(0.0, i1.x, i2.x, 1.0));
    float n_ = 0.142857142857;
    vec3 ns = n_ * D.wyz - D.xzx;
    vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
    vec4 x_ = floor(j * ns.z);
    vec4 y_ = floor(j - 7.0 * x_);
    vec4 x = x_ * ns.x + ns.yyyy;
    vec4 y = y_ * ns.x + ns.yyyy;
    vec4 h = 1.0 - abs(x) - abs(y);
    vec4 b0 = vec4(x.xy, y.xy);
    vec4 b1 = vec4(x.zw, y.zw);
    vec4 s0 = floor(b0) * 2.0 + 1.0;
    vec4 s1 = floor(b1) * 2.0 + 1.0;
    vec4 sh = -step(h, vec4(0.0));
    vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
    vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
    vec3 p0 = vec3(a0.xy, h.x);
    vec3 p1 = vec3(a0.zw, h.y);
    vec3 p2 = vec3(a1.xy, h.z);
    vec3 p3 = vec3(a1.zw, h.w);
    vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
    p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
    vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
    m = m * m;
    return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}

/* Three octaves of the noise above: rolling smoke at three scales. The
   field lives in screen space, not beam space, so it is the same air
   whichever way a beam points: nothing streaks when a beam turns or
   widens, the beam just lights a different slice of it. */
float fbm(vec3 p) {
    float sum = 0.0;
    float amp = 0.55;
    for (int i = 0; i < 3; i++) {
        sum += amp * snoise(p);
        p = p * 2.07 + vec3(19.1, -7.3, 3.7);
        amp *= 0.5;
    }
    return sum;
}

/* One fixture. Returns the light it puts into this pixel, before colour:
   x = light in the haze, y = the pool on the floor, z = the lamp itself. */
vec3 fixture(vec2 p, vec4 geo, vec3 shape) {
    vec2 apex = geo.xy;
    vec2 land = geo.zw;
    float halfW = max(shape.x, 4.0);
    float focus = shape.z;

    vec2 axis = land - apex;
    float len = max(length(axis), 1.0);
    vec2 dir = axis / len;
    vec2 nrm = vec2(-dir.y, dir.x);
    vec2 v = p - apex;
    float t = dot(v, dir);              // distance down the beam
    float s = dot(v, nrm);              // distance across it

    /* Cone radius grows from the lens (a few px) to the pool. */
    float radius = 4.0 + max(t, 0.0) * halfW / len;
    float u = abs(s) / radius;

    /* Seen side-on, a cone of lit haze is brightest down the middle,
       where the line of sight crosses the most of it: chord length. The
       edge is a soft lens edge, a little crisper when focused. */
    float chord = sqrt(max(1.0 - u * u, 0.0));
    float edge = smoothstep(1.0, mix(0.72, 0.86, focus), u);
    float start = smoothstep(0.0, 18.0, t);

    /* Brightness falls off with distance: inverse square over a widening
       cross-section reads as roughly 1 / distance in the air. */
    float fall = 1.7 / (1.0 + t / (0.14 * len));

    /* The beam stops where it meets the floor: fade across the pool's
       near half, measured on the floor's own vertical axis. */
    float floorY = (p.y - land.y) / (halfW * 0.3);
    float stopAt = (1.0 - smoothstep(-0.6, 0.9, floorY))
                 * (1.0 - smoothstep(len - 0.15 * halfW, len + 0.45 * halfW, t));

    float inside = edge * start * stopAt;
    float air = chord * inside * fall;

    /* The pool: a floor ellipse, flat-ish in the middle with a soft lens
       edge, plus a faint wide spill where light bounces off the floor. */
    /* An oblique beam throws a longer pool. */
    vec2 e = (p - land) / (vec2(1.0 + 0.5 * abs(dir.x), 0.3) * halfW);
    float q = length(e);
    float pool = smoothstep(1.0, mix(0.55, 0.8, focus), q) * (0.45 + 0.55 * exp(-q * q * 2.2));
    pool += 0.08 * exp(-q * q * 0.5);

    /* The lamp: a small hot lens with a soft bloom, thrown a little
       forward along the beam. */
    float dl = length(v);
    float lamp = exp(-dl * dl / 10.0) * 2.4
               + 0.5 / (1.0 + dl * dl / 140.0)
               + 0.025 * exp(-dl / 60.0);
    lamp *= 0.7 + 0.3 * smoothstep(-30.0, 30.0, t);

    return vec3(air, pool, lamp);
}

vec3 toLinear(vec3 c) { return pow(max(c, 0.0), vec3(2.2)); }
vec3 toSrgb(vec3 c) { return pow(max(c, 0.0), vec3(1.0 / 2.2)); }

void main() {
    /* CSS px, origin top-left, y down: the same space the script aims in. */
    vec2 p = vec2(gl_FragCoord.x, uRes.y * uDpr - gl_FragCoord.y) / uDpr;

    vec3 key = fixture(p, uKeyGeo, uKeyShape) * uKeyShape.y;
    vec3 gel = fixture(p, uGelGeo, uGelShape) * uGelShape.y;

    /* Haze density, only computed where there is light to see it in. */
    float density = 1.0;
    float lit = key.x + gel.x;
    if (lit > 0.0005) {
        vec3 hp = vec3(p / 230.0, 0.0);
        hp.xy += vec2(uTime * 0.011, -uTime * 0.017);
        hp.z = uTime * 0.045;
        float n = fbm(hp);
        density = mix(1.0, 0.12 + 1.75 * smoothstep(-0.45, 0.55, n), clamp(uHaze, 0.0, 1.0));
        density *= 0.55 + 0.45 * clamp(uHaze, 0.0, 2.0);
    }

    vec3 keyLin = toLinear(uKeyColor);
    vec3 gelLin = toLinear(uGelColor);

    const float AIR = 0.26;
    const float POOL = 0.065;
    vec3 light = keyLin * (key.x * density * AIR + key.y * POOL + key.z)
               + gelLin * (gel.x * density * AIR + gel.y * POOL + gel.z);

    /* Additive light, soft-clipped so the crossing and the lamps roll off
       to white instead of hard-clipping. */
    vec3 ground = toLinear(uGround);
    vec3 col = ground + (1.0 - exp(-light * 1.15)) * (1.0 - ground);
    col = toSrgb(col);

    /* Dither last: one level of noise so the dark falloff never bands. */
    float ign = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
    col += (ign - 0.5) / 255.0;

    gl_FragColor = vec4(col, 1.0);
}
`;

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initSpotlightBackground() {
    if (typeof gsap === 'undefined') {
        /* Without GSAP the CSS rendition stays on screen. */
        document.documentElement.classList.remove('gl');
        return;
    }

    const previousContext = window.gsapContext;
    const previousApi = window.SpotlightBackground;
    let instances = [];

    const createSpotlight = function (root, conditions, mediaContext) {
        const isMotion = conditions.isMotion;
        const isFine = conditions.isFine;
        const isTouch = isMotion && !isFine;

        const CONFIG = {
            beams: SPOTLIGHT_NUM(root.dataset.spotlightBeams, 2) >= 2 ? 2 : 1,
            follow: SPOTLIGHT_ON(root.dataset.spotlightFollow),
            idle: SPOTLIGHT_ON(root.dataset.spotlightIdle),
            entrance: SPOTLIGHT_ON(root.dataset.spotlightEntrance),
            intensity: SPOTLIGHT_CLAMP(SPOTLIGHT_NUM(root.dataset.spotlightIntensity, 1), 0, 1)
        };
        /* Ambient motion (sway, touch sweep, drifting haze). */
        const ambient = CONFIG.idle && isMotion;

        const host = root.parentElement || document.body;
        let target = null;
        if (root.dataset.spotlightTarget) {
            try {
                target = host.querySelector(root.dataset.spotlightTarget)
                    || document.querySelector(root.dataset.spotlightTarget);
            } catch (error) {
                target = null;
            }
        }
        if (!target) target = host.querySelector('h1, h2, h3');
        const names = CONFIG.beams === 2 ? ['key', 'gel'] : ['key'];

        /* -------------------------------------------------------------
           Renderer. One canvas, one full-screen quad, one shader.
           ------------------------------------------------------------- */
        let renderer;
        try {
            renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' });
        } catch (error) {
            /* Support was probed first, so this is the page's context
               budget running out. The CSS rendition is still there. */
            return null;
        }
        const dprCap = isFine || !isMotion ? 2 : 1.5;
        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dprCap));
        const canvas = renderer.domElement;
        canvas.className = 'spotlight__canvas';
        canvas.setAttribute('aria-hidden', 'true');
        root.insertBefore(canvas, root.firstChild);

        /* Colours and haze come from the CSS custom properties, read once
           here, so a restyle never touches this file. */
        const rootStyle = getComputedStyle(root);
        const readVar = function (name) { return rootStyle.getPropertyValue(name).trim(); };
        const ground = SPOTLIGHT_COLOR(readVar('--spotlight-ground') || rootStyle.backgroundColor, '#0a0a0a');
        const keyColor = SPOTLIGHT_COLOR(readVar('--spotlight-key'), '#fff1dc');
        const gelColor = SPOTLIGHT_COLOR(readVar('--spotlight-gel'), '#ff2d6f');
        const hazeBase = SPOTLIGHT_CLAMP(SPOTLIGHT_NUM(readVar('--spotlight-haze'), 0.6), 0, 1);
        renderer.setClearColor(new THREE.Color(ground[0], ground[1], ground[2]), 1);

        const uniforms = {
            uRes: { value: new THREE.Vector2(1, 1) },
            uDpr: { value: renderer.getPixelRatio() },
            uTime: { value: SPOTLIGHT_STILL_TIME },
            uGround: { value: new THREE.Vector3(ground[0], ground[1], ground[2]) },
            uKeyColor: { value: new THREE.Vector3(keyColor[0], keyColor[1], keyColor[2]) },
            uGelColor: { value: new THREE.Vector3(gelColor[0], gelColor[1], gelColor[2]) },
            uKeyGeo: { value: new THREE.Vector4(0, -100, 0, 0) },
            uGelGeo: { value: new THREE.Vector4(0, -100, 0, 0) },
            uKeyShape: { value: new THREE.Vector3(100, 0, 0) },
            uGelShape: { value: new THREE.Vector3(100, 0, 0) },
            uHaze: { value: hazeBase }
        };
        const geometry = new THREE.PlaneGeometry(2, 2);
        /* The shader writes sRGB itself, so no colour-space chunk: the
           colours above are handed over exactly as CSS defines them. */
        const material = new THREE.ShaderMaterial({
            vertexShader: SPOTLIGHT_VERTEX_SHADER,
            fragmentShader: SPOTLIGHT_FRAGMENT_SHADER,
            uniforms: uniforms,
            depthTest: false,
            depthWrite: false
        });
        const mesh = new THREE.Mesh(geometry, material);
        mesh.frustumCulled = false;
        const scene = new THREE.Scene();
        scene.add(mesh);
        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

        /* Everything the script writes to is restored exactly on revert. */
        const touched = [target, host]
            .filter(function (el, i, list) { return el && list.indexOf(el) === i; });
        const savedStyles = touched.map(function (el) { return el.getAttribute('style'); });
        const targetWasLit = target ? target.classList.contains('spotlight-lit') : false;
        const listeners = [];
        const observers = [];
        let destroyed = false;
        let contextLost = false;

        const on = function (el, type, fn, options) {
            const wrapped = mediaContext.add(null, fn);
            el.addEventListener(type, wrapped, options);
            listeners.push(function () { el.removeEventListener(type, wrapped, options); });
        };

        /* The heading keeps its own ink; the catch only ever adds light. */
        if (target) {
            target.style.setProperty('--spotlight-ink', getComputedStyle(target).color);
            target.style.setProperty('--spotlight-key-c', readVar('--spotlight-key') || '#fff');
            target.style.setProperty('--spotlight-gel-c', readVar('--spotlight-gel') || '#fff');
            target.style.setProperty('--spotlight-ga', '0');
            target.classList.add('spotlight-lit');
        }
        if (isTouch && CONFIG.follow) host.style.touchAction = 'pan-y';

        /* ---------------------------------------------------------------
           Geometry, in CSS px relative to the layer
           --------------------------------------------------------------- */
        const geo = {
            w: 0, h: 0, fy: 0, poolW: 300,
            fx: { key: 0, gel: 0 },
            home: { kx: 0, ky: 0, gx: 0, gy: 0 },
            tx: 0, ty: 0
        };

        const measure = function () {
            const rect = root.getBoundingClientRect();
            const w = rect.width;
            const h = rect.height;
            const narrow = w < 640;
            geo.w = w;
            geo.h = h;
            /* The lamps hang just inside the top edge, so they are seen. */
            geo.fy = SPOTLIGHT_CLAMP(h * 0.04, 16, 34);
            if (names.length === 2) {
                geo.fx.key = w * (narrow ? 0.14 : 0.27);
                geo.fx.gel = w * (narrow ? 0.86 : 0.73);
            } else {
                geo.fx.key = w * 0.5;
            }
            geo.poolW = SPOTLIGHT_CLAMP(w * (narrow ? 0.56 : 0.3), 190, 460);

            let cx = w / 2;
            let cy = h * 0.6;
            let spread = geo.poolW * 0.24;
            if (target) {
                const t = target.getBoundingClientRect();
                geo.tx = t.left - rect.left;
                geo.ty = t.top - rect.top;
                cx = geo.tx + t.width / 2;
                cy = geo.ty + t.height / 2;
                spread = Math.min(t.width * 0.2, geo.poolW * 0.24);
            }
            if (names.length === 2) {
                geo.home.kx = cx - spread;
                geo.home.gx = cx + spread;
            } else {
                geo.home.kx = cx;
                geo.home.gx = cx;
            }
            geo.home.ky = cy;
            geo.home.gy = cy;

            renderer.setSize(Math.max(1, Math.round(w)), Math.max(1, Math.round(h)), false);
            uniforms.uRes.value.set(Math.max(1, Math.round(w)), Math.max(1, Math.round(h)));
            uniforms.uDpr.value = renderer.getPixelRatio();
        };

        /* ---------------------------------------------------------------
           State. GSAP tweens these plain numbers; render() hands them to
           the shader.
           --------------------------------------------------------------- */
        const S = { kx: 0, ky: 0, gx: 0, gy: 0, on: 0, focus: 0, sway: 0, sweep: 0, wide: 1 };
        const E = { kx: 0, ky: 0, gx: 0, gy: 0 };   // the entrance path
        let entering = false;
        let clock = SPOTLIGHT_STILL_TIME;           // haze time
        let swayClock = 0;
        let energy = 0;                             // pointer speed, decays
        let energySmooth = 0;
        let pointerSeen = false;

        const toKX = gsap.quickTo(S, 'kx', SPOTLIGHT_KEY_FOLLOW);
        const toKY = gsap.quickTo(S, 'ky', SPOTLIGHT_KEY_FOLLOW);
        const toGX = gsap.quickTo(S, 'gx', SPOTLIGHT_GEL_FOLLOW);
        const toGY = gsap.quickTo(S, 'gy', SPOTLIGHT_GEL_FOLLOW);

        /* jump: the key spot snaps (keyboard, resize); the gel still eases. */
        const aimTo = function (x, y, jump) {
            const yy = SPOTLIGHT_CLAMP(y, geo.h * 0.3, geo.h * 1.02);
            const xx = SPOTLIGHT_CLAMP(x, -geo.w * 0.05, geo.w * 1.05);
            const offset = names.length === 2 ? geo.poolW * 0.06 : 0;
            if (jump) {
                toKX(xx - offset, xx - offset);
                toKY(yy, yy);
            } else {
                toKX(xx - offset);
                toKY(yy);
            }
            toGX(xx + offset);
            toGY(yy);
        };

        const aimHome = function (instant) {
            if (instant) {
                toKX(geo.home.kx, geo.home.kx);
                toKY(geo.home.ky, geo.home.ky);
                toGX(geo.home.gx, geo.home.gx);
                toGY(geo.home.gy, geo.home.gy);
                return;
            }
            toKX(geo.home.kx);
            toKY(geo.home.ky);
            toGX(geo.home.gx);
            toGY(geo.home.gy);
        };

        /* ---------------------------------------------------------------
           Render: motion values in, one frame out.
           --------------------------------------------------------------- */
        const lastProps = {};
        const writeProp = function (name, value) {
            if (lastProps[name] === value) return;
            lastProps[name] = value;
            target.style.setProperty(name, value);
        };

        const render = function () {
            if (destroyed || contextLost) return;
            const I = CONFIG.intensity;
            const f = S.focus;
            /* Speed widens the cones a little; the pin spot narrows them. */
            const spread = S.wide * (1 + 0.28 * energySmooth * I) * (1 - 0.62 * f);
            const breathe = 1 + 0.05 * Math.sin(swayClock * 0.7) * S.sway;
            const bright = S.on * I * (1 + 0.25 * f) * breathe;
            const halfW = geo.poolW * spread * 0.5;

            uniforms.uTime.value = clock;
            /* Speed thickens the haze. */
            uniforms.uHaze.value = hazeBase * (1 + 1.1 * energySmooth * I);

            ['key', 'gel'].forEach(function (name, i) {
                const isKey = name === 'key';
                const geoU = isKey ? uniforms.uKeyGeo.value : uniforms.uGelGeo.value;
                const shapeU = isKey ? uniforms.uKeyShape.value : uniforms.uGelShape.value;
                if (names.indexOf(name) < 0) {
                    shapeU.set(halfW, 0, f);
                    return;
                }
                let ax = entering ? (isKey ? E.kx : E.gx) : (isKey ? S.kx : S.gx);
                const ay = entering ? (isKey ? E.ky : E.gy) : (isKey ? S.ky : S.gy);

                /* Touch screens: the operator sweeps on their own, the two
                   spots crossing each other over the content. */
                if (S.sweep > 0) {
                    ax += Math.sin(swayClock * 0.42 + i * 2.7) * geo.w * 0.26 * S.sweep * I;
                }

                const fx = geo.fx[name];
                const dx = ax - fx;
                const dy = Math.max(40, ay - geo.fy);
                /* Idle breathing: a two-degree sway about the fixture. */
                const sway = S.sway * I * (2 * Math.PI / 180) * Math.sin(swayClock * 0.55 + i * 1.9);
                const angle = Math.atan2(dx, dy) + sway;
                const dist = Math.sqrt(dx * dx + dy * dy);
                const px = fx + Math.sin(angle) * dist;
                const py = geo.fy + Math.cos(angle) * dist;

                geoU.set(fx, geo.fy, px, py);
                shapeU.set(halfW, bright, f);

                if (target) {
                    /* The heading's catch: centred where this beam lands,
                       sized to its pool, brightening the type it crosses. */
                    const p = isKey ? '--spotlight-k' : '--spotlight-g';
                    const rx = halfW * 1.25;
                    writeProp(p + 'x', (px - geo.tx).toFixed(1) + 'px');
                    writeProp(p + 'y', (py - geo.ty).toFixed(1) + 'px');
                    writeProp(p + 'r', rx.toFixed(1) + 'px');
                    writeProp(p + 'ry', (rx * 0.62).toFixed(1) + 'px');
                    writeProp(p + 'a', SPOTLIGHT_CLAMP(S.on * I * (0.9 + 0.1 * f), 0, 1).toFixed(3));
                }
            });

            renderer.render(scene, camera);
        };

        /* ---------------------------------------------------------------
           Loop: on gsap.ticker, so tweens are current when the frame
           draws. Runs only while on screen, tab visible, not paused, and
           something is moving (ambient motion counts).
           --------------------------------------------------------------- */
        let running = false;
        let visible = true;
        let paused = false;
        let quietFrames = 0;

        const isActive = function () {
            return ambient || entering || S.sway > 0.001 || S.sweep > 0.001 || energySmooth > 0.002
                || gsap.isTweening(S) || gsap.isTweening(E);
        };

        const tick = function (time, deltaTime) {
            if (!root.isConnected) {
                stopLoop();
                return;
            }
            const dt = SPOTLIGHT_CLAMP(deltaTime || 16.7, 0, 50) / 1000;
            /* The haze drifts only while ambient motion is on. */
            if (ambient) {
                clock += dt;
                if (clock > 1000) clock -= 1000;
            }
            swayClock += dt;
            if (swayClock > 1e4) swayClock -= 1e4;
            energy *= Math.exp(-dt * 4);
            energySmooth += (energy - energySmooth) * Math.min(1, dt * 6);
            if (energySmooth < 0.002 && energy < 0.002) { energy = 0; energySmooth = 0; }
            render();
            if (isActive()) {
                quietFrames = 0;
            } else if (++quietFrames > 3) {
                stopLoop();
            }
        };

        const stopLoop = function () {
            if (!running) return;
            running = false;
            gsap.ticker.remove(tick);
        };

        const wake = function () {
            if (running || paused || !visible || document.hidden || !isMotion || destroyed || contextLost) return;
            running = true;
            quietFrames = 0;
            gsap.ticker.add(tick);
        };

        /* ---------------------------------------------------------------
           Idle: home to the heading, then breathe (or sweep on touch).
           --------------------------------------------------------------- */
        const goIdle = function () {
            aimHome(false);
            pointerSeen = false;
            if (ambient) {
                gsap.to(S, isTouch
                    ? { sweep: 1, duration: 2.4, ease: 'sine.inOut', overwrite: 'auto' }
                    : { sway: 1, duration: 1.6, ease: 'sine.inOut', overwrite: 'auto' });
            }
            wake();
        };
        const idleCall = gsap.delayedCall(SPOTLIGHT_IDLE_AFTER, goIdle).pause();

        const noteInput = function () {
            if (S.sway > 0 || S.sweep > 0) {
                gsap.to(S, { sway: 0, sweep: 0, duration: 0.6, ease: 'power2.out', overwrite: 'auto' });
            }
            if (isMotion && !paused) idleCall.restart(true);
        };

        /* ---------------------------------------------------------------
           Focus: hold for a pin spot, release springs back.
           --------------------------------------------------------------- */
        const focus = function (onState) {
            if (!isMotion) {
                S.focus = onState ? 1 : 0;
                render();
                return;
            }
            if (onState) {
                gsap.to(S, { focus: 1, duration: 0.32, ease: 'power3.out', overwrite: 'auto' });
            } else {
                gsap.to(S, { focus: 0, duration: 1.1, ease: 'elastic.out(1, 0.42)', overwrite: 'auto' });
            }
            wake();
        };

        const pulse = function () {
            gsap.timeline()
                .to(S, { focus: 1, duration: 0.22, ease: 'power3.out', overwrite: 'auto' })
                .to(S, { focus: 0, duration: 1.1, ease: 'elastic.out(1, 0.42)' });
            wake();
        };

        /* ---------------------------------------------------------------
           Entrance: the lamps strike, the beams swing in from the wings,
           cross once over the heading and land.
           --------------------------------------------------------------- */
        let entrance = null;
        let pendingAim = null;

        const land = function () {
            entering = false;
            entrance = null;
            root.classList.add('is-landed');
            if (pendingAim) {
                aimTo(pendingAim.x, pendingAim.y, false);
                pendingAim = null;
            }
            if (!paused) idleCall.restart(true);
            wake();
        };

        const playEntrance = function () {
            if (entrance) entrance.kill();
            root.classList.remove('is-landed');
            aimHome(true);
            const w = geo.w;
            const h = geo.h;
            const crossBy = Math.max(w * 0.16, geo.poolW * 0.55);
            E.kx = -w * 0.18;
            E.gx = names.length === 2 ? w * 1.18 : -w * 0.18;
            E.ky = h * 0.2;
            E.gy = h * 0.2;
            S.on = 0;
            S.sway = 0;
            S.sweep = 0;
            S.wide = 1.3;
            entering = true;
            entrance = gsap.timeline({ onComplete: land })
                .to(S, { on: 1, duration: 0.4, ease: 'power2.out' }, 0)
                .to(E, {
                    kx: geo.home.kx + crossBy,
                    gx: names.length === 2 ? geo.home.gx - crossBy : geo.home.kx + crossBy,
                    ky: geo.home.ky - h * 0.05,
                    gy: geo.home.gy - h * 0.05,
                    duration: 0.9,
                    ease: 'expo.inOut'
                }, 0)
                .to(E, {
                    kx: geo.home.kx,
                    gx: names.length === 2 ? geo.home.gx : geo.home.kx,
                    ky: geo.home.ky,
                    gy: geo.home.gy,
                    duration: 0.6,
                    ease: 'expo.inOut'
                }, 0.9)
                .to(S, { wide: 1, duration: 1.1, ease: 'power2.inOut' }, 0.4);
            if (paused) entrance.pause();
            wake();
        };

        /* ---------------------------------------------------------------
           Input
           --------------------------------------------------------------- */
        const localPoint = function (clientX, clientY) {
            const rect = root.getBoundingClientRect();
            const x = clientX - rect.left;
            const y = clientY - rect.top;
            if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
            return { x: x, y: y };
        };

        let lastMove = null;
        const feedSpeed = function (x, y) {
            const now = performance.now();
            if (lastMove) {
                const dt = Math.max(8, now - lastMove.t);
                const speed = Math.hypot(x - lastMove.x, y - lastMove.y) / dt * 1000;
                energy = Math.max(energy, SPOTLIGHT_CLAMP(speed / 2600, 0, 1));
            }
            lastMove = { x: x, y: y, t: now };
        };

        const isControl = function (el) {
            return !!(el && el.closest && el.closest('a, button, input, select, textarea, label, [contenteditable]'));
        };

        if (isMotion && isFine) {
            if (CONFIG.follow) {
                on(window, 'pointermove', function (event) {
                    if (event.pointerType === 'touch') return;
                    const p = localPoint(event.clientX, event.clientY);
                    if (!p) return;
                    pointerSeen = true;
                    feedSpeed(p.x, p.y);
                    noteInput();
                    if (entering) {
                        pendingAim = p;
                        return;
                    }
                    aimTo(p.x, p.y, false);
                    wake();
                }, { passive: true });
            }

            on(window, 'pointerdown', function (event) {
                if (event.pointerType === 'touch' || event.button !== 0) return;
                if (isControl(event.target) || !localPoint(event.clientX, event.clientY)) return;
                noteInput();
                focus(true);
            });
            const release = function () { if (S.focus > 0 || gsap.isTweening(S)) focus(false); };
            on(window, 'pointerup', release);
            on(window, 'pointercancel', release);
            on(window, 'blur', release);
        }

        if (isTouch) {
            let touchStart = null;
            on(host, 'touchstart', function (event) {
                const t = event.touches[0];
                if (!t) return;
                touchStart = { x: t.clientX, y: t.clientY, time: performance.now(), moved: false };
                noteInput();
            }, { passive: true });
            on(host, 'touchmove', function (event) {
                const t = event.touches[0];
                if (!t || !touchStart) return;
                if (Math.hypot(t.clientX - touchStart.x, t.clientY - touchStart.y) > 10) touchStart.moved = true;
                if (!CONFIG.follow) return;
                const p = localPoint(t.clientX, t.clientY);
                if (!p || entering) return;
                noteInput();
                aimTo(p.x, p.y, false);
                wake();
            }, { passive: true });
            on(host, 'touchend', function (event) {
                if (!touchStart) return;
                const quick = performance.now() - touchStart.time < 320;
                const tapped = !touchStart.moved && quick && !isControl(event.target);
                const t = event.changedTouches[0];
                touchStart = null;
                if (!tapped || entering) return;
                if (CONFIG.follow && t) {
                    const p = localPoint(t.clientX, t.clientY);
                    if (p) aimTo(p.x, p.y, false);
                }
                pulse();
            }, { passive: true });
        }

        /* Keyboard: an opted-in focusable ancestor (data-spotlight-keys).
           Arrows jump the key spot a step; hold Space or Enter to focus. */
        const keysEl = root.closest('[data-spotlight-keys]');
        if (keysEl) {
            on(keysEl, 'keydown', function (event) {
                if (event.target !== keysEl) return;
                const stepX = geo.w * 0.08;
                const stepY = geo.h * 0.08;
                const moves = { ArrowLeft: [-stepX, 0], ArrowRight: [stepX, 0], ArrowUp: [0, -stepY], ArrowDown: [0, stepY] };
                if (moves[event.key] && CONFIG.follow) {
                    event.preventDefault();
                    const offset = names.length === 2 ? geo.poolW * 0.06 : 0;
                    aimTo(S.kx + offset + moves[event.key][0], S.ky + moves[event.key][1], true);
                    if (!isMotion) { S.gx = S.kx + offset * 2; S.gy = S.ky; render(); }
                    noteInput();
                    wake();
                } else if (event.key === ' ' || event.key === 'Enter') {
                    event.preventDefault();
                    if (!event.repeat) { noteInput(); focus(true); }
                } else if (event.key === 'Escape') {
                    aimHome(!isMotion);
                    if (!isMotion) { Object.assign(S, { kx: geo.home.kx, ky: geo.home.ky, gx: geo.home.gx, gy: geo.home.gy }); render(); }
                    wake();
                }
            });
            on(keysEl, 'keyup', function (event) {
                if (event.target !== keysEl) return;
                if (event.key === ' ' || event.key === 'Enter') focus(false);
            });
            on(keysEl, 'blur', function () { if (S.focus > 0) focus(false); });
        }

        /* ---------------------------------------------------------------
           Size, visibility, context loss
           --------------------------------------------------------------- */
        const relayout = function () {
            if (destroyed) return;
            measure();
            if (!pointerSeen || !isMotion) {
                aimHome(true);
                S.kx = geo.home.kx; S.ky = geo.home.ky; S.gx = geo.home.gx; S.gy = geo.home.gy;
            }
            render();
            wake();
        };

        if (typeof ResizeObserver !== 'undefined') {
            let first = true;
            const ro = new ResizeObserver(function () {
                if (first) { first = false; return; }
                relayout();
            });
            ro.observe(root);
            if (target) ro.observe(target);
            observers.push(ro);
        }
        if (document.fonts && document.fonts.ready) {
            document.fonts.ready.then(function () { if (root.isConnected && !destroyed) relayout(); });
        }

        if (typeof IntersectionObserver !== 'undefined') {
            const io = new IntersectionObserver(function (entries) {
                visible = entries[entries.length - 1].isIntersecting;
                if (visible) wake(); else stopLoop();
            }, { threshold: 0 });
            io.observe(root);
            observers.push(io);
        }
        on(document, 'visibilitychange', function () {
            if (document.hidden) stopLoop(); else wake();
        });

        /* A lost context (GPU reset, too many tabs) pauses drawing; three
           rebuilds its programs on restore and the next frame catches up. */
        on(canvas, 'webglcontextlost', function (event) {
            event.preventDefault();
            contextLost = true;
            stopLoop();
        });
        on(canvas, 'webglcontextrestored', function () {
            contextLost = false;
            render();
            wake();
        });

        /* ---------------------------------------------------------------
           Start
           --------------------------------------------------------------- */
        measure();
        aimHome(true);
        S.kx = geo.home.kx; S.ky = geo.home.ky; S.gx = geo.home.gx; S.gy = geo.home.gy;

        if (isMotion && CONFIG.entrance) {
            playEntrance();
            render();
        } else {
            /* Reduced motion (or no entrance): the landed frame, drawn now.
               Under reduced motion it is the only frame ever drawn, apart
               from redraws on resize or a keyboard aim. */
            S.on = 1;
            render();
            root.classList.add('is-landed');
            if (isMotion) idleCall.restart(true);
            wake();
        }
        root.classList.add('is-live');

        return {
            aim: function (x, y) {
                if (x === undefined || x === null) {
                    aimHome(!isMotion);
                    if (!isMotion) Object.assign(S, { kx: geo.home.kx, ky: geo.home.ky, gx: geo.home.gx, gy: geo.home.gy });
                } else {
                    const px = SPOTLIGHT_NUM(x, 0.5) * geo.w;
                    const py = SPOTLIGHT_NUM(y, 0.6) * geo.h;
                    pointerSeen = true;
                    if (isMotion) {
                        aimTo(px, py, false);
                    } else {
                        S.kx = px; S.ky = py; S.gx = px; S.gy = py;
                    }
                    noteInput();
                }
                if (isMotion) wake(); else render();
            },
            focus: focus,
            replay: function () {
                if (!isMotion) return;
                pointerSeen = false;
                measure();
                playEntrance();
            },
            pause: function () {
                paused = true;
                stopLoop();
                if (entrance) entrance.pause();
                idleCall.pause();
            },
            play: function () {
                if (!paused) return;
                paused = false;
                if (entrance) entrance.resume();
                else if (isMotion) idleCall.restart(true);
                wake();
            },
            destroy: function () {
                stopLoop();
                destroyed = true;
                if (entrance) entrance.kill();
                idleCall.kill();
                gsap.killTweensOf([S, E]);
                listeners.forEach(function (off) { off(); });
                observers.forEach(function (o) { o.disconnect(); });

                /* Release the GPU by hand: dropped references do not free
                   it, and a page may only hold a handful of contexts. */
                geometry.dispose();
                material.dispose();
                renderer.dispose();
                renderer.forceContextLoss();
                if (canvas.parentNode) canvas.parentNode.removeChild(canvas);

                root.classList.remove('is-live', 'is-landed');
                if (target && !targetWasLit) target.classList.remove('spotlight-lit');
                /* Put back the exact inline styles the page had. */
                touched.forEach(function (el, i) {
                    if (!savedStyles[i]) el.removeAttribute('style');
                    else el.setAttribute('style', savedStyles[i]);
                });
            }
        };
    };

    const ctx = gsap.context(function spotlightContext(rootContext) {
        const mm = gsap.matchMedia();

        mm.add({
            isFine: '(pointer: fine) and (prefers-reduced-motion: no-preference)',
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function spotlightMedia(context) {
            /* No three.js or no WebGL: the CSS rendition is the product.
               The head probe hid it on the promise of a canvas; take the
               promise back. */
            if (typeof THREE === 'undefined' || !SPOTLIGHT_HAS_WEBGL()) {
                document.documentElement.classList.remove('gl');
                return;
            }
            const created = [];
            document.querySelectorAll('[data-spotlight]').forEach(function (root) {
                const instance = createSpotlight(root, context.conditions, context);
                if (instance) created.push(instance);
            });
            instances = created;
            if (!created.length) document.documentElement.classList.remove('gl');

            return function cleanup() {
                created.forEach(function (instance) { instance.destroy(); });
                if (instances === created) instances = [];
            };
        });

        /* revert(), never kill(): kill() skips the cleanup above, which is
           where the WebGL context is released. */
        const handleUnload = function () { rootContext.revert(); };
        window.addEventListener('beforeunload', handleUnload);
        return function cleanupPage() {
            window.removeEventListener('beforeunload', handleUnload);
            /* After a revert the CSS rendition is what the layer shows. */
            document.documentElement.classList.remove('gl');
            if (window.gsapContext === rootContext) {
                if (previousContext === undefined) delete window.gsapContext;
                else window.gsapContext = previousContext;
            }
            if (window.SpotlightBackground === api) {
                if (previousApi === undefined) delete window.SpotlightBackground;
                else window.SpotlightBackground = previousApi;
            }
        };
    });

    /* One namespace for every [data-spotlight] layer on the page. */
    const api = {
        aim: function (x, y) { instances.forEach(function (i) { i.aim(x, y); }); },
        focus: function (onState) { instances.forEach(function (i) { i.focus(!!onState); }); },
        replay: function () { instances.forEach(function (i) { i.replay(); }); },
        pause: function () { instances.forEach(function (i) { i.pause(); }); },
        play: function () { instances.forEach(function (i) { i.play(); }); },
        revert: function () { ctx.revert(); }
    };
    window.SpotlightBackground = api;
    window.gsapContext = ctx;
});
const SPOTLIGHT_NUM=function(e,t){if(null==e||""===e)return t;const n=parseFloat(e);return isFinite(n)?n:t},SPOTLIGHT_CLAMP=function(e,t,n){return e<t?t:e>n?n:e},SPOTLIGHT_ON=function(e){return"0"!==e&&"false"!==e},SPOTLIGHT_COLOR=function(e,t){try{const n=document.createElement("canvas");n.width=1,n.height=1;const o=n.getContext("2d",{willReadFrequently:!0});o.fillStyle=t,o.fillStyle=(e||"").trim()||t,o.fillRect(0,0,1,1);const i=o.getImageData(0,0,1,1).data;return[i[0]/255,i[1]/255,i[2]/255]}catch(e){return[1,1,1]}},SPOTLIGHT_HAS_WEBGL=function(){try{const e=document.createElement("canvas"),t=e.getContext("webgl2")||e.getContext("webgl");if(!t)return!1;const n=t.getExtension("WEBGL_lose_context");return n&&n.loseContext(),!0}catch(e){return!1}},SPOTLIGHT_KEY_FOLLOW={duration:.85,ease:"back.out(1.5)"},SPOTLIGHT_GEL_FOLLOW={duration:1.3,ease:"back.out(1.8)"},SPOTLIGHT_IDLE_AFTER=2.5,SPOTLIGHT_STILL_TIME=14,SPOTLIGHT_VERTEX_SHADER="\nvarying vec2 vUv;\nvoid main() {\n    vUv = uv;\n    gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n",SPOTLIGHT_FRAGMENT_SHADER="\nprecision highp float;\n\nuniform vec2 uRes;        // layer size in CSS px\nuniform float uDpr;\nuniform float uTime;      // seconds, wrapped\nuniform vec3 uGround;     // sRGB 0..1\nuniform vec3 uKeyColor;   // sRGB 0..1\nuniform vec3 uGelColor;\nuniform vec4 uKeyGeo;     // apex.xy, land.xy  (CSS px, y down)\nuniform vec4 uGelGeo;\nuniform vec3 uKeyShape;   // pool half-width px, brightness, focus 0..1\nuniform vec3 uGelShape;\nuniform float uHaze;      // haze density, 0..~2\n\n/* 3D simplex noise. Ashima Arts / Ian McEwan, MIT. */\nvec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }\nvec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }\nfloat snoise(vec3 v) {\n    const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);\n    const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);\n    vec3 i  = floor(v + dot(v, C.yyy));\n    vec3 x0 = v - i + dot(i, C.xxx);\n    vec3 g  = step(x0.yzx, x0.xyz);\n    vec3 l  = 1.0 - g;\n    vec3 i1 = min(g.xyz, l.zxy);\n    vec3 i2 = max(g.xyz, l.zxy);\n    vec3 x1 = x0 - i1 + C.xxx;\n    vec3 x2 = x0 - i2 + C.yyy;\n    vec3 x3 = x0 - D.yyy;\n    i = mod289(i);\n    vec4 p = permute(permute(permute(\n              i.z + vec4(0.0, i1.z, i2.z, 1.0))\n            + i.y + vec4(0.0, i1.y, i2.y, 1.0))\n            + i.x + vec4(0.0, i1.x, i2.x, 1.0));\n    float n_ = 0.142857142857;\n    vec3 ns = n_ * D.wyz - D.xzx;\n    vec4 j = p - 49.0 * floor(p * ns.z * ns.z);\n    vec4 x_ = floor(j * ns.z);\n    vec4 y_ = floor(j - 7.0 * x_);\n    vec4 x = x_ * ns.x + ns.yyyy;\n    vec4 y = y_ * ns.x + ns.yyyy;\n    vec4 h = 1.0 - abs(x) - abs(y);\n    vec4 b0 = vec4(x.xy, y.xy);\n    vec4 b1 = vec4(x.zw, y.zw);\n    vec4 s0 = floor(b0) * 2.0 + 1.0;\n    vec4 s1 = floor(b1) * 2.0 + 1.0;\n    vec4 sh = -step(h, vec4(0.0));\n    vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;\n    vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;\n    vec3 p0 = vec3(a0.xy, h.x);\n    vec3 p1 = vec3(a0.zw, h.y);\n    vec3 p2 = vec3(a1.xy, h.z);\n    vec3 p3 = vec3(a1.zw, h.w);\n    vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));\n    p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;\n    vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);\n    m = m * m;\n    return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));\n}\n\n/* Three octaves of the noise above: rolling smoke at three scales. The\n   field lives in screen space, not beam space, so it is the same air\n   whichever way a beam points: nothing streaks when a beam turns or\n   widens, the beam just lights a different slice of it. */\nfloat fbm(vec3 p) {\n    float sum = 0.0;\n    float amp = 0.55;\n    for (int i = 0; i < 3; i++) {\n        sum += amp * snoise(p);\n        p = p * 2.07 + vec3(19.1, -7.3, 3.7);\n        amp *= 0.5;\n    }\n    return sum;\n}\n\n/* One fixture. Returns the light it puts into this pixel, before colour:\n   x = light in the haze, y = the pool on the floor, z = the lamp itself. */\nvec3 fixture(vec2 p, vec4 geo, vec3 shape) {\n    vec2 apex = geo.xy;\n    vec2 land = geo.zw;\n    float halfW = max(shape.x, 4.0);\n    float focus = shape.z;\n\n    vec2 axis = land - apex;\n    float len = max(length(axis), 1.0);\n    vec2 dir = axis / len;\n    vec2 nrm = vec2(-dir.y, dir.x);\n    vec2 v = p - apex;\n    float t = dot(v, dir);              // distance down the beam\n    float s = dot(v, nrm);              // distance across it\n\n    /* Cone radius grows from the lens (a few px) to the pool. */\n    float radius = 4.0 + max(t, 0.0) * halfW / len;\n    float u = abs(s) / radius;\n\n    /* Seen side-on, a cone of lit haze is brightest down the middle,\n       where the line of sight crosses the most of it: chord length. The\n       edge is a soft lens edge, a little crisper when focused. */\n    float chord = sqrt(max(1.0 - u * u, 0.0));\n    float edge = smoothstep(1.0, mix(0.72, 0.86, focus), u);\n    float start = smoothstep(0.0, 18.0, t);\n\n    /* Brightness falls off with distance: inverse square over a widening\n       cross-section reads as roughly 1 / distance in the air. */\n    float fall = 1.7 / (1.0 + t / (0.14 * len));\n\n    /* The beam stops where it meets the floor: fade across the pool's\n       near half, measured on the floor's own vertical axis. */\n    float floorY = (p.y - land.y) / (halfW * 0.3);\n    float stopAt = (1.0 - smoothstep(-0.6, 0.9, floorY))\n                 * (1.0 - smoothstep(len - 0.15 * halfW, len + 0.45 * halfW, t));\n\n    float inside = edge * start * stopAt;\n    float air = chord * inside * fall;\n\n    /* The pool: a floor ellipse, flat-ish in the middle with a soft lens\n       edge, plus a faint wide spill where light bounces off the floor. */\n    /* An oblique beam throws a longer pool. */\n    vec2 e = (p - land) / (vec2(1.0 + 0.5 * abs(dir.x), 0.3) * halfW);\n    float q = length(e);\n    float pool = smoothstep(1.0, mix(0.55, 0.8, focus), q) * (0.45 + 0.55 * exp(-q * q * 2.2));\n    pool += 0.08 * exp(-q * q * 0.5);\n\n    /* The lamp: a small hot lens with a soft bloom, thrown a little\n       forward along the beam. */\n    float dl = length(v);\n    float lamp = exp(-dl * dl / 10.0) * 2.4\n               + 0.5 / (1.0 + dl * dl / 140.0)\n               + 0.025 * exp(-dl / 60.0);\n    lamp *= 0.7 + 0.3 * smoothstep(-30.0, 30.0, t);\n\n    return vec3(air, pool, lamp);\n}\n\nvec3 toLinear(vec3 c) { return pow(max(c, 0.0), vec3(2.2)); }\nvec3 toSrgb(vec3 c) { return pow(max(c, 0.0), vec3(1.0 / 2.2)); }\n\nvoid main() {\n    /* CSS px, origin top-left, y down: the same space the script aims in. */\n    vec2 p = vec2(gl_FragCoord.x, uRes.y * uDpr - gl_FragCoord.y) / uDpr;\n\n    vec3 key = fixture(p, uKeyGeo, uKeyShape) * uKeyShape.y;\n    vec3 gel = fixture(p, uGelGeo, uGelShape) * uGelShape.y;\n\n    /* Haze density, only computed where there is light to see it in. */\n    float density = 1.0;\n    float lit = key.x + gel.x;\n    if (lit > 0.0005) {\n        vec3 hp = vec3(p / 230.0, 0.0);\n        hp.xy += vec2(uTime * 0.011, -uTime * 0.017);\n        hp.z = uTime * 0.045;\n        float n = fbm(hp);\n        density = mix(1.0, 0.12 + 1.75 * smoothstep(-0.45, 0.55, n), clamp(uHaze, 0.0, 1.0));\n        density *= 0.55 + 0.45 * clamp(uHaze, 0.0, 2.0);\n    }\n\n    vec3 keyLin = toLinear(uKeyColor);\n    vec3 gelLin = toLinear(uGelColor);\n\n    const float AIR = 0.26;\n    const float POOL = 0.065;\n    vec3 light = keyLin * (key.x * density * AIR + key.y * POOL + key.z)\n               + gelLin * (gel.x * density * AIR + gel.y * POOL + gel.z);\n\n    /* Additive light, soft-clipped so the crossing and the lamps roll off\n       to white instead of hard-clipping. */\n    vec3 ground = toLinear(uGround);\n    vec3 col = ground + (1.0 - exp(-light * 1.15)) * (1.0 - ground);\n    col = toSrgb(col);\n\n    /* Dither last: one level of noise so the dark falloff never bands. */\n    float ign = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n    col += (ign - 0.5) / 255.0;\n\n    gl_FragColor = vec4(col, 1.0);\n}\n";!function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){if("undefined"==typeof gsap)return void document.documentElement.classList.remove("gl");const e=window.gsapContext,t=window.SpotlightBackground;let n=[];const o=function(e,t,n){const o=t.isMotion,i=t.isFine,s=o&&!i,a={beams:SPOTLIGHT_NUM(e.dataset.spotlightBeams,2)>=2?2:1,follow:SPOTLIGHT_ON(e.dataset.spotlightFollow),idle:SPOTLIGHT_ON(e.dataset.spotlightIdle),entrance:SPOTLIGHT_ON(e.dataset.spotlightEntrance),intensity:SPOTLIGHT_CLAMP(SPOTLIGHT_NUM(e.dataset.spotlightIntensity,1),0,1)},l=a.idle&&o,r=e.parentElement||document.body;let c=null;if(e.dataset.spotlightTarget)try{c=r.querySelector(e.dataset.spotlightTarget)||document.querySelector(e.dataset.spotlightTarget)}catch(e){c=null}c||(c=r.querySelector("h1, h2, h3"));const u=2===a.beams?["key","gel"]:["key"];let d;try{d=new THREE.WebGLRenderer({antialias:!1,alpha:!1,powerPreference:"high-performance"})}catch(e){return null}const f=i||!o?2:1.5;d.setPixelRatio(Math.min(window.devicePixelRatio||1,f));const h=d.domElement;h.className="spotlight__canvas",h.setAttribute("aria-hidden","true"),e.insertBefore(h,e.firstChild);const p=getComputedStyle(e),y=function(e){return p.getPropertyValue(e).trim()},g=SPOTLIGHT_COLOR(y("--spotlight-ground")||p.backgroundColor,"#0a0a0a"),x=SPOTLIGHT_COLOR(y("--spotlight-key"),"#fff1dc"),m=SPOTLIGHT_COLOR(y("--spotlight-gel"),"#ff2d6f"),v=SPOTLIGHT_CLAMP(SPOTLIGHT_NUM(y("--spotlight-haze"),.6),0,1);d.setClearColor(new THREE.Color(g[0],g[1],g[2]),1);const w={uRes:{value:new THREE.Vector2(1,1)},uDpr:{value:d.getPixelRatio()},uTime:{value:14},uGround:{value:new THREE.Vector3(g[0],g[1],g[2])},uKeyColor:{value:new THREE.Vector3(x[0],x[1],x[2])},uGelColor:{value:new THREE.Vector3(m[0],m[1],m[2])},uKeyGeo:{value:new THREE.Vector4(0,-100,0,0)},uGelGeo:{value:new THREE.Vector4(0,-100,0,0)},uKeyShape:{value:new THREE.Vector3(100,0,0)},uGelShape:{value:new THREE.Vector3(100,0,0)},uHaze:{value:v}},T=new THREE.PlaneGeometry(2,2),k=new THREE.ShaderMaterial({vertexShader:SPOTLIGHT_VERTEX_SHADER,fragmentShader:SPOTLIGHT_FRAGMENT_SHADER,uniforms:w,depthTest:!1,depthWrite:!1}),L=new THREE.Mesh(T,k);L.frustumCulled=!1;const O=new THREE.Scene;O.add(L);const S=new THREE.OrthographicCamera(-1,1,1,-1,0,1),E=[c,r].filter(function(e,t,n){return e&&n.indexOf(e)===t}),G=E.map(function(e){return e.getAttribute("style")}),_=!!c&&c.classList.contains("spotlight-lit"),H=[],P=[];let b=!1,C=!1;const I=function(e,t,o,i){const s=n.add(null,o);e.addEventListener(t,s,i),H.push(function(){e.removeEventListener(t,s,i)})};c&&(c.style.setProperty("--spotlight-ink",getComputedStyle(c).color),c.style.setProperty("--spotlight-key-c",y("--spotlight-key")||"#fff"),c.style.setProperty("--spotlight-gel-c",y("--spotlight-gel")||"#fff"),c.style.setProperty("--spotlight-ga","0"),c.classList.add("spotlight-lit")),s&&a.follow&&(r.style.touchAction="pan-y");const M={w:0,h:0,fy:0,poolW:300,fx:{key:0,gel:0},home:{kx:0,ky:0,gx:0,gy:0},tx:0,ty:0},R=function(){const t=e.getBoundingClientRect(),n=t.width,o=t.height,i=n<640;M.w=n,M.h=o,M.fy=SPOTLIGHT_CLAMP(.04*o,16,34),2===u.length?(M.fx.key=n*(i?.14:.27),M.fx.gel=n*(i?.86:.73)):M.fx.key=.5*n,M.poolW=SPOTLIGHT_CLAMP(n*(i?.56:.3),190,460);let s=n/2,a=.6*o,l=.24*M.poolW;if(c){const e=c.getBoundingClientRect();M.tx=e.left-t.left,M.ty=e.top-t.top,s=M.tx+e.width/2,a=M.ty+e.height/2,l=Math.min(.2*e.width,.24*M.poolW)}2===u.length?(M.home.kx=s-l,M.home.gx=s+l):(M.home.kx=s,M.home.gx=s),M.home.ky=a,M.home.gy=a,d.setSize(Math.max(1,Math.round(n)),Math.max(1,Math.round(o)),!1),w.uRes.value.set(Math.max(1,Math.round(n)),Math.max(1,Math.round(o))),w.uDpr.value=d.getPixelRatio()},z={kx:0,ky:0,gx:0,gy:0,on:0,focus:0,sway:0,sweep:0,wide:1},A={kx:0,ky:0,gx:0,gy:0};let W=!1,F=14,D=0,q=0,B=0,K=!1;const N=gsap.quickTo(z,"kx",SPOTLIGHT_KEY_FOLLOW),V=gsap.quickTo(z,"ky",SPOTLIGHT_KEY_FOLLOW),Y=gsap.quickTo(z,"gx",SPOTLIGHT_GEL_FOLLOW),U=gsap.quickTo(z,"gy",SPOTLIGHT_GEL_FOLLOW),X=function(e,t,n){const o=SPOTLIGHT_CLAMP(t,.3*M.h,1.02*M.h),i=SPOTLIGHT_CLAMP(e,.05*-M.w,1.05*M.w),s=2===u.length?.06*M.poolW:0;n?(N(i-s,i-s),V(o,o)):(N(i-s),V(o)),Y(i+s),U(o)},j=function(e){if(e)return N(M.home.kx,M.home.kx),V(M.home.ky,M.home.ky),Y(M.home.gx,M.home.gx),void U(M.home.gy,M.home.gy);N(M.home.kx),V(M.home.ky),Y(M.home.gx),U(M.home.gy)},J={},Q=function(e,t){J[e]!==t&&(J[e]=t,c.style.setProperty(e,t))},Z=function(){if(b||C)return;const e=a.intensity,t=z.focus,n=z.wide*(1+.28*B*e)*(1-.62*t),o=1+.05*Math.sin(.7*D)*z.sway,i=z.on*e*(1+.25*t)*o,s=M.poolW*n*.5;w.uTime.value=F,w.uHaze.value=v*(1+1.1*B*e),["key","gel"].forEach(function(n,o){const a="key"===n,l=a?w.uKeyGeo.value:w.uGelGeo.value,r=a?w.uKeyShape.value:w.uGelShape.value;if(u.indexOf(n)<0)return void r.set(s,0,t);let d=W?a?A.kx:A.gx:a?z.kx:z.gx;const f=W?a?A.ky:A.gy:a?z.ky:z.gy;z.sweep>0&&(d+=Math.sin(.42*D+2.7*o)*M.w*.26*z.sweep*e);const h=M.fx[n],p=d-h,y=Math.max(40,f-M.fy),g=z.sway*e*(2*Math.PI/180)*Math.sin(.55*D+1.9*o),x=Math.atan2(p,y)+g,m=Math.sqrt(p*p+y*y),v=h+Math.sin(x)*m,T=M.fy+Math.cos(x)*m;if(l.set(h,M.fy,v,T),r.set(s,i,t),c){const n=a?"--spotlight-k":"--spotlight-g",o=1.25*s;Q(n+"x",(v-M.tx).toFixed(1)+"px"),Q(n+"y",(T-M.ty).toFixed(1)+"px"),Q(n+"r",o.toFixed(1)+"px"),Q(n+"ry",(.62*o).toFixed(1)+"px"),Q(n+"a",SPOTLIGHT_CLAMP(z.on*e*(.9+.1*t),0,1).toFixed(3))}}),d.render(O,S)};let $=!1,ee=!0,te=!1,ne=0;const oe=function(t,n){if(!e.isConnected)return void ie();const o=SPOTLIGHT_CLAMP(n||16.7,0,50)/1e3;l&&(F+=o,F>1e3&&(F-=1e3)),D+=o,D>1e4&&(D-=1e4),q*=Math.exp(4*-o),B+=(q-B)*Math.min(1,6*o),B<.002&&q<.002&&(q=0,B=0),Z(),l||W||z.sway>.001||z.sweep>.001||B>.002||gsap.isTweening(z)||gsap.isTweening(A)?ne=0:++ne>3&&ie()},ie=function(){$&&($=!1,gsap.ticker.remove(oe))},se=function(){$||te||!ee||document.hidden||!o||b||C||($=!0,ne=0,gsap.ticker.add(oe))},ae=gsap.delayedCall(2.5,function(){j(!1),K=!1,l&&gsap.to(z,s?{sweep:1,duration:2.4,ease:"sine.inOut",overwrite:"auto"}:{sway:1,duration:1.6,ease:"sine.inOut",overwrite:"auto"}),se()}).pause(),le=function(){(z.sway>0||z.sweep>0)&&gsap.to(z,{sway:0,sweep:0,duration:.6,ease:"power2.out",overwrite:"auto"}),o&&!te&&ae.restart(!0)},re=function(e){if(!o)return z.focus=e?1:0,void Z();e?gsap.to(z,{focus:1,duration:.32,ease:"power3.out",overwrite:"auto"}):gsap.to(z,{focus:0,duration:1.1,ease:"elastic.out(1, 0.42)",overwrite:"auto"}),se()};let ce=null,ue=null;const de=function(){W=!1,ce=null,e.classList.add("is-landed"),ue&&(X(ue.x,ue.y,!1),ue=null),te||ae.restart(!0),se()},fe=function(){ce&&ce.kill(),e.classList.remove("is-landed"),j(!0);const t=M.w,n=M.h,o=Math.max(.16*t,.55*M.poolW);A.kx=.18*-t,A.gx=2===u.length?1.18*t:.18*-t,A.ky=.2*n,A.gy=.2*n,z.on=0,z.sway=0,z.sweep=0,z.wide=1.3,W=!0,ce=gsap.timeline({onComplete:de}).to(z,{on:1,duration:.4,ease:"power2.out"},0).to(A,{kx:M.home.kx+o,gx:2===u.length?M.home.gx-o:M.home.kx+o,ky:M.home.ky-.05*n,gy:M.home.gy-.05*n,duration:.9,ease:"expo.inOut"},0).to(A,{kx:M.home.kx,gx:2===u.length?M.home.gx:M.home.kx,ky:M.home.ky,gy:M.home.gy,duration:.6,ease:"expo.inOut"},.9).to(z,{wide:1,duration:1.1,ease:"power2.inOut"},.4),te&&ce.pause(),se()},he=function(t,n){const o=e.getBoundingClientRect(),i=t-o.left,s=n-o.top;return i<0||s<0||i>o.width||s>o.height?null:{x:i,y:s}};let pe=null;const ye=function(e){return!!(e&&e.closest&&e.closest("a, button, input, select, textarea, label, [contenteditable]"))};if(o&&i){a.follow&&I(window,"pointermove",function(e){if("touch"===e.pointerType)return;const t=he(e.clientX,e.clientY);t&&(K=!0,function(e,t){const n=performance.now();if(pe){const o=Math.max(8,n-pe.t),i=Math.hypot(e-pe.x,t-pe.y)/o*1e3;q=Math.max(q,SPOTLIGHT_CLAMP(i/2600,0,1))}pe={x:e,y:t,t:n}}(t.x,t.y),le(),W?ue=t:(X(t.x,t.y,!1),se()))},{passive:!0}),I(window,"pointerdown",function(e){"touch"!==e.pointerType&&0===e.button&&!ye(e.target)&&he(e.clientX,e.clientY)&&(le(),re(!0))});const e=function(){(z.focus>0||gsap.isTweening(z))&&re(!1)};I(window,"pointerup",e),I(window,"pointercancel",e),I(window,"blur",e)}if(s){let e=null;I(r,"touchstart",function(t){const n=t.touches[0];n&&(e={x:n.clientX,y:n.clientY,time:performance.now(),moved:!1},le())},{passive:!0}),I(r,"touchmove",function(t){const n=t.touches[0];if(!n||!e)return;if(Math.hypot(n.clientX-e.x,n.clientY-e.y)>10&&(e.moved=!0),!a.follow)return;const o=he(n.clientX,n.clientY);o&&!W&&(le(),X(o.x,o.y,!1),se())},{passive:!0}),I(r,"touchend",function(t){if(!e)return;const n=performance.now()-e.time<320,o=!e.moved&&n&&!ye(t.target),i=t.changedTouches[0];if(e=null,o&&!W){if(a.follow&&i){const e=he(i.clientX,i.clientY);e&&X(e.x,e.y,!1)}gsap.timeline().to(z,{focus:1,duration:.22,ease:"power3.out",overwrite:"auto"}).to(z,{focus:0,duration:1.1,ease:"elastic.out(1, 0.42)"}),se()}},{passive:!0})}const ge=e.closest("[data-spotlight-keys]");ge&&(I(ge,"keydown",function(e){if(e.target!==ge)return;const t=.08*M.w,n=.08*M.h,i={ArrowLeft:[-t,0],ArrowRight:[t,0],ArrowUp:[0,-n],ArrowDown:[0,n]};if(i[e.key]&&a.follow){e.preventDefault();const t=2===u.length?.06*M.poolW:0;X(z.kx+t+i[e.key][0],z.ky+i[e.key][1],!0),o||(z.gx=z.kx+2*t,z.gy=z.ky,Z()),le(),se()}else" "===e.key||"Enter"===e.key?(e.preventDefault(),e.repeat||(le(),re(!0))):"Escape"===e.key&&(j(!o),o||(Object.assign(z,{kx:M.home.kx,ky:M.home.ky,gx:M.home.gx,gy:M.home.gy}),Z()),se())}),I(ge,"keyup",function(e){e.target===ge&&(" "!==e.key&&"Enter"!==e.key||re(!1))}),I(ge,"blur",function(){z.focus>0&&re(!1)}));const xe=function(){b||(R(),K&&o||(j(!0),z.kx=M.home.kx,z.ky=M.home.ky,z.gx=M.home.gx,z.gy=M.home.gy),Z(),se())};if("undefined"!=typeof ResizeObserver){let t=!0;const n=new ResizeObserver(function(){t?t=!1:xe()});n.observe(e),c&&n.observe(c),P.push(n)}if(document.fonts&&document.fonts.ready&&document.fonts.ready.then(function(){e.isConnected&&!b&&xe()}),"undefined"!=typeof IntersectionObserver){const t=new IntersectionObserver(function(e){ee=e[e.length-1].isIntersecting,ee?se():ie()},{threshold:0});t.observe(e),P.push(t)}return I(document,"visibilitychange",function(){document.hidden?ie():se()}),I(h,"webglcontextlost",function(e){e.preventDefault(),C=!0,ie()}),I(h,"webglcontextrestored",function(){C=!1,Z(),se()}),R(),j(!0),z.kx=M.home.kx,z.ky=M.home.ky,z.gx=M.home.gx,z.gy=M.home.gy,o&&a.entrance?(fe(),Z()):(z.on=1,Z(),e.classList.add("is-landed"),o&&ae.restart(!0),se()),e.classList.add("is-live"),{aim:function(e,t){if(null==e)j(!o),o||Object.assign(z,{kx:M.home.kx,ky:M.home.ky,gx:M.home.gx,gy:M.home.gy});else{const n=SPOTLIGHT_NUM(e,.5)*M.w,i=SPOTLIGHT_NUM(t,.6)*M.h;K=!0,o?X(n,i,!1):(z.kx=n,z.ky=i,z.gx=n,z.gy=i),le()}o?se():Z()},focus:re,replay:function(){o&&(K=!1,R(),fe())},pause:function(){te=!0,ie(),ce&&ce.pause(),ae.pause()},play:function(){te&&(te=!1,ce?ce.resume():o&&ae.restart(!0),se())},destroy:function(){ie(),b=!0,ce&&ce.kill(),ae.kill(),gsap.killTweensOf([z,A]),H.forEach(function(e){e()}),P.forEach(function(e){e.disconnect()}),T.dispose(),k.dispose(),d.dispose(),d.forceContextLoss(),h.parentNode&&h.parentNode.removeChild(h),e.classList.remove("is-live","is-landed"),c&&!_&&c.classList.remove("spotlight-lit"),E.forEach(function(e,t){G[t]?e.setAttribute("style",G[t]):e.removeAttribute("style")})}}},i=gsap.context(function(i){gsap.matchMedia().add({isFine:"(pointer: fine) and (prefers-reduced-motion: no-preference)",isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){if("undefined"==typeof THREE||!SPOTLIGHT_HAS_WEBGL())return void document.documentElement.classList.remove("gl");const t=[];return document.querySelectorAll("[data-spotlight]").forEach(function(n){const i=o(n,e.conditions,e);i&&t.push(i)}),n=t,t.length||document.documentElement.classList.remove("gl"),function(){t.forEach(function(e){e.destroy()}),n===t&&(n=[])}});const a=function(){i.revert()};return window.addEventListener("beforeunload",a),function(){window.removeEventListener("beforeunload",a),document.documentElement.classList.remove("gl"),window.gsapContext===i&&(void 0===e?delete window.gsapContext:window.gsapContext=e),window.SpotlightBackground===s&&(void 0===t?delete window.SpotlightBackground:window.SpotlightBackground=t)}}),s={aim:function(e,t){n.forEach(function(n){n.aim(e,t)})},focus:function(e){n.forEach(function(t){t.focus(!!e)})},replay:function(){n.forEach(function(e){e.replay()})},pause:function(){n.forEach(function(e){e.pause()})},play:function(){n.forEach(function(e){e.play()})},revert:function(){i.revert()}};window.SpotlightBackground=s,window.gsapContext=i});
/* Spotlight Background
   Two follow-spots hung at the top of the frame. The effect is the
   .spotlight layer and the .spotlight-lit rule near the end; everything
   under DEMO is the sample hero and can be replaced wholesale.

   With WebGL the script draws the light into a canvas inside .spotlight.
   The beam and pool elements below are the CSS rendition: what visitors
   without WebGL or JavaScript see, as a lit, resting frame. */

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    color-scheme: dark;

    --page: #0a0a0a;
    --ink: #f1efea;
    --ink-dim: #a9a49c;    /* 7.6:1 on the ground */
    --ink-faint: #918c85;  /* 5.6:1 on the ground */
    --font-sans: 'Mona Sans', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}

html,
body {
    height: 100%;
    overflow: hidden;
}

body {
    background: var(--page);
    color: var(--ink);
    font-family: var(--font-sans);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.5;
}

::selection {
    background: #ff2d6f;
    color: #0a0a0a;
}

/* ============================================
   THE EFFECT
   ============================================ */

/* The layer fills whatever positioned box it is placed in. Content sits
   above it as a sibling; the layer never takes pointer events. */
.spotlight {
    /* Restyle here. Key is the warm white lamp, gel the coloured one. */
    --spotlight-ground: #0a0a0a;
    --spotlight-key: #fff1dc;
    --spotlight-gel: #ff2d6f;
    /* Haze density inside the cones, 0 to 1. */
    --spotlight-haze: 0.6;

    position: absolute;
    inset: 0;
    z-index: 0;
    overflow: hidden;
    background: var(--spotlight-ground);
    pointer-events: none;
    isolation: isolate;
    contain: strict;
}

/* The WebGL light. The script adds it and removes it on revert. */
.spotlight__canvas {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
}

/* CSS rendition. A beam is a fixed-size cone pointing down from its apex
   at top centre, turned and stretched by a transform; the soft edges come
   from the conic mask. The 16.7deg half angle matches the 600 x 1000 box
   (atan(300 / 1000)). Hidden before first paint when WebGL is available
   (html.gl, from the probe in <head>) and removed once the canvas is up. */
.spotlight__beam {
    --beam: var(--spotlight-key);
    position: absolute;
    left: 0;
    top: 0;
    width: 600px;
    height: 1000px;
    transform-origin: 50% 0;
    will-change: transform, opacity;
    mix-blend-mode: screen;
    mix-blend-mode: plus-lighter;
    -webkit-mask-image:
        conic-gradient(from 163.3deg at 50% 0, transparent 0deg, #000 10deg, #000 23.4deg, transparent 33.4deg),
        linear-gradient(to bottom, transparent 0%, #000 7%, #000 84%, transparent 100%);
    -webkit-mask-composite: source-in;
    mask-image:
        conic-gradient(from 163.3deg at 50% 0, transparent 0deg, #000 10deg, #000 23.4deg, transparent 33.4deg),
        linear-gradient(to bottom, transparent 0%, #000 7%, #000 84%, transparent 100%);
    mask-composite: intersect;
}

.spotlight__beam[data-spotlight-beam="gel"],
.spotlight__pool[data-spotlight-pool="gel"] {
    --beam: var(--spotlight-gel);
}

/* Light in the air: brightest near the lamp, thinning towards the pool,
   with a narrow hot core down the middle. */
.spotlight__cone {
    position: absolute;
    inset: 0;
    background:
        conic-gradient(from 173deg at 50% 0, transparent 0deg, color-mix(in srgb, var(--beam) 20%, transparent) 7deg, transparent 14deg),
        linear-gradient(to bottom,
            color-mix(in srgb, var(--beam) 46%, transparent) 0%,
            color-mix(in srgb, var(--beam) 19%, transparent) 50%,
            color-mix(in srgb, var(--beam) 10%, transparent) 100%);
}

/* Haze: a beam-coloured fill seen through a fixed turbulence texture. The
   texture is rasterised once; drift is a transform. Isotropic, low-frequency
   noise so it reads as smoke when the beam scales, and one untiled 600 x 1600
   mask covering the whole element so there is no repeat seam. */
.spotlight__haze {
    position: absolute;
    left: 0;
    right: 0;
    top: -30%;
    height: 160%;
    opacity: calc(var(--spotlight-haze) * 0.5);
    background: var(--beam);
    will-change: transform, opacity;
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='1600'%3E%3Cfilter id='h'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.0035' numOctaves='4' seed='7'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1.5 0 0 0 -0.45'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23h)'/%3E%3C/svg%3E");
    -webkit-mask-size: 600px 1600px;
    -webkit-mask-repeat: no-repeat;
    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='1600'%3E%3Cfilter id='h'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.0035' numOctaves='4' seed='7'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1.5 0 0 0 -0.45'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23h)'/%3E%3C/svg%3E");
    mask-size: 600px 1600px;
    mask-repeat: no-repeat;
}

/* Where the light lands on the back wall. */
.spotlight__pool {
    --beam: var(--spotlight-key);
    position: absolute;
    left: 0;
    top: 0;
    width: 400px;
    height: 220px;
    border-radius: 50%;
    will-change: transform, opacity;
    mix-blend-mode: screen;
    mix-blend-mode: plus-lighter;
    background: radial-gradient(closest-side,
        color-mix(in srgb, var(--beam) 17%, transparent) 0%,
        color-mix(in srgb, var(--beam) 11%, transparent) 42%,
        color-mix(in srgb, var(--beam) 4%, transparent) 74%,
        transparent 100%);
}

/* The resting frame: both beams on the lower middle of the box, where
   content usually sits. */
.spotlight__beam {
    left: 32%;
    top: -8%;
    transform: translateX(-50%) rotate(-24deg) scale(0.62, 0.48);
}

.spotlight__beam[data-spotlight-beam="gel"] {
    left: 68%;
    transform: translateX(-50%) rotate(24deg) scale(0.62, 0.48);
}

.spotlight__pool {
    left: 41%;
    top: 50%;
    transform: translate(-50%, -50%) scale(0.75);
}

.spotlight__pool[data-spotlight-pool="gel"] {
    left: 59%;
}

[data-spotlight-beams="1"] [data-spotlight-beam="key"] {
    left: 50%;
    transform: translateX(-50%) scale(0.62, 0.48);
}

[data-spotlight-beams="1"] [data-spotlight-pool="key"] {
    left: 50%;
}

[data-spotlight-beams="1"] [data-spotlight-beam="gel"],
[data-spotlight-beams="1"] [data-spotlight-pool="gel"] {
    display: none;
}

@media (max-width: 640px) {
    .spotlight__beam {
        transform: translateX(-50%) rotate(-9deg) scale(0.42, 0.5);
    }

    .spotlight__beam[data-spotlight-beam="gel"] {
        transform: translateX(-50%) rotate(9deg) scale(0.42, 0.5);
    }

    .spotlight__pool {
        left: 40%;
        top: 46%;
        transform: translate(-50%, -50%) scale(0.6);
    }

    .spotlight__pool[data-spotlight-pool="gel"] {
        left: 60%;
    }
}

/* visibility, not display: nothing reflows when the canvas arrives. */
.gl .spotlight__beam,
.gl .spotlight__pool {
    visibility: hidden;
}

.spotlight.is-live .spotlight__beam,
.spotlight.is-live .spotlight__pool {
    display: none;
}

/* The heading the beams land on. The script adds this class and the
   custom properties together and removes them on revert. The base ink is
   the heading's own colour, so the catch only ever brightens the type.

   A background clipped to text only paints inside the element's box, and a
   tight display line-height puts descenders and accents outside it. The
   padding grows the painted box past every glyph; the matching negative
   margin keeps the layout exactly where it was. */
.spotlight-lit {
    padding: 0.08em 0.06em 0.2em;
    margin: -0.08em -0.06em -0.2em;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
    background-image:
        radial-gradient(ellipse var(--spotlight-kr, 1px) var(--spotlight-kry, 1px) at var(--spotlight-kx, -999px) var(--spotlight-ky, -999px),
            color-mix(in srgb, color-mix(in srgb, var(--spotlight-key-c, #fff) 45%, #fff) calc(var(--spotlight-ka, 0) * 100%), transparent) 0%,
            color-mix(in srgb, color-mix(in srgb, var(--spotlight-key-c, #fff) 45%, #fff) calc(var(--spotlight-ka, 0) * 70%), transparent) 45%,
            transparent 100%),
        radial-gradient(ellipse var(--spotlight-gr, 1px) var(--spotlight-gry, 1px) at var(--spotlight-gx, -999px) var(--spotlight-gy, -999px),
            color-mix(in srgb, color-mix(in srgb, var(--spotlight-gel-c, #fff) 20%, #fff) calc(var(--spotlight-ga, 0) * 100%), transparent) 0%,
            color-mix(in srgb, color-mix(in srgb, var(--spotlight-gel-c, #fff) 20%, #fff) calc(var(--spotlight-ga, 0) * 70%), transparent) 45%,
            transparent 100%),
        linear-gradient(var(--spotlight-ink, #fff), var(--spotlight-ink, #fff));
}

/* ============================================
   DEMO: a sample hero. Replace freely.
   ============================================ */
.stage {
    position: relative;
    display: grid;
    height: 100svh;
    min-height: 520px;
    overflow: hidden;
    align-content: end;
    justify-items: center;
    padding: 0 1.5rem clamp(2.5rem, 9vh, 5.5rem);
    /* Vertical scrolling stays with the page on touch screens. */
    touch-action: pan-y;
}

.stage:focus-visible {
    outline: 2px solid #ff2d6f;
    outline-offset: -6px;
}

.stage__content {
    position: relative;
    z-index: 1;
    display: grid;
    justify-items: center;
    gap: 1.25rem;
    max-width: 52rem;
    text-align: center;
}

.stage__title {
    max-width: 16ch;
    color: #e6e2da;  /* 15:1 unlit on the ground; the catch only brightens it */
    font-size: clamp(2.6rem, 6.6vw, 5.25rem);
    font-weight: 800;
    line-height: 0.96;
    letter-spacing: -0.045em;
    text-wrap: balance;
}

.stage__lede {
    max-width: 38ch;
    color: var(--ink-dim);
    font-size: clamp(1rem, 1.4vw, 1.125rem);
    line-height: 1.5;
    text-wrap: pretty;
}

.stage__btn {
    display: inline-flex;
    align-items: center;
    min-height: 2.875rem;
    margin-top: 0.25rem;
    padding: 0 1.4rem;
    border-radius: 999px;
    background: #f1efea;
    color: #0a0a0a;
    font-size: 0.9375rem;
    font-weight: 500;
    text-decoration: none;
    transition: background-color 0.2s cubic-bezier(0.25, 1, 0.5, 1);
}

.stage__btn:hover,
.stage__btn:focus-visible {
    background: #ff2d6f;  /* ink on the gel holds 5.5:1 */
}

.stage__btn:focus-visible {
    outline: 2px solid #ff2d6f;
    outline-offset: 3px;
}

.stage__cue {
    margin-top: 0.75rem;
    color: var(--ink-faint);
    font-size: 0.8125rem;
    line-height: 1.35;
}

.stage__cue--touch {
    display: none;
}

@media (pointer: coarse) {
    .stage__cue--fine { display: none; }
    .stage__cue--touch { display: inline; }
}

@media (max-width: 640px) {
    .stage {
        padding-bottom: 4.5rem;
    }

    .stage__content {
        gap: 1rem;
    }
}

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

Two theatre follow-spots hung at the top of the frame, drawn in WebGL as real light in a hazed room: a small hot lamp at each fixture, a cone of drifting smoke that thins with distance, and a soft pool where each beam lands. On load the beams sweep in from the wings, cross once over your headline and land on it. After that they follow the pointer like spots worked by an operator: heavy, a little behind, with a slight overshoot when you stop. Press and hold to pull both into a tight pin spot. The headline catches the light as each beam crosses it.

GSAP core for every motion, three.js for the canvas. No GSAP plugins.

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="path/to/style.css">
<script>
  (function () {
    try {
      var c = document.createElement('canvas');
      var gl = c.getContext('webgl2') || c.getContext('webgl');
      if (!gl) return;
      var lose = gl.getExtension('WEBGL_lose_context');
      if (lose) lose.loseContext();
      document.documentElement.classList.add('gl');
    } catch (e) {}
  })();
</script>

The inline script is not optional. The layer carries a CSS rendition of the beams for visitors without JavaScript or WebGL, and without this probe it would paint for the half second three.js takes to arrive and then be swapped for the canvas. The probe runs before first paint, stamps html.gl when a canvas is coming, and the stylesheet hides the CSS beams under that class. The effect removes the class again if it cannot build a renderer after all.

2. Put the layer inside any positioned container, before your content:

<section class="hero" style="position: relative;">
  <div class="spotlight" data-spotlight aria-hidden="true">
    <div class="spotlight__beam" data-spotlight-beam="key">
      <div class="spotlight__cone"></div>
      <div class="spotlight__haze"></div>
    </div>
    <div class="spotlight__beam" data-spotlight-beam="gel">
      <div class="spotlight__cone"></div>
      <div class="spotlight__haze"></div>
    </div>
    <div class="spotlight__pool" data-spotlight-pool="key"></div>
    <div class="spotlight__pool" data-spotlight-pool="gel"></div>
  </div>

  <div class="hero__content" style="position: relative;">
    <h1>Your headline</h1>
    <p>Support line.</p>
  </div>
</section>

The beam and pool elements are the CSS rendition only; the WebGL canvas is added next to them at runtime. The beams land on the first h1, h2 or h3 in the container unless you point them elsewhere with data-spotlight-target.

3. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.min.js"
  }
}
</script>
<script type="module">
  const showFallback = () => document.documentElement.classList.remove('gl');
  import('three').then((THREE) => {
    window.THREE = THREE;
    const effect = document.createElement('script');
    effect.src = 'path/to/script.js';
    effect.onerror = showFallback;
    document.body.appendChild(effect);
  }).catch(showFallback);
</script>

Why the import map rather than a plain <script src> for three.js: three ships as ES modules only, and its old UMD build logs a deprecation warning on every page load. The shim imports it as a module (dynamically, so a blocked CDN falls back to the CSS rendition instead of a blank stage), puts it on window, and then loads script.js as an ordinary script, so the effect itself stays a plain file you can drop into any build (or none).

Already using three.js as a module? Skip the shim and make sure window.THREE is set before script.js runs:

import * as THREE from 'three';
window.THREE = THREE;

Using It With Your Own Design

What the effect needs from your markup: a positioned container (relative, absolute or fixed) holding the [data-spotlight] layer and your content. The layer fills the container with position: absolute; inset: 0, at any size, so it works on a full-viewport hero or a single card. Your content needs position: relative (or any z-index context) to sit above it.

What is only the demo's styling: everything under DEMO in style.css: the .stage hero, the type, the button and the cue line. Replace it wholesale.

CSS the effect depends on:

  • The container must have a real size. The canvas is sized from the layer, which takes the container's box. A container with no height gives a canvas of no height.
  • The .spotlight block (overflow: hidden, pointer-events: none) and .spotlight__canvas.
  • The .spotlight__beam / .spotlight__pool rules and the .gl and .is-live rules that hide them. Keep the four beam and pool children in the markup: they are what visitors without WebGL or JavaScript see.
  • The .spotlight-lit rule. The script adds it to the target heading for the catch and removes it on teardown. It pads the heading's painted box so descenders and accents are never clipped by the clipped-to-text highlight, with a matching negative margin so your layout does not move. The heading should be a block element (an inline one splits the catch per line).
  • On touch screens the container needs touch-action: pan-y so vertical swipes still scroll the page. The script sets it inline while it runs if you have not.

Colours are custom properties on .spotlight, read once at start and handed to the shader, so a restyle never touches the script:

.spotlight {
  --spotlight-ground: #0a0a0a; /* the stage */
  --spotlight-key: #fff1dc;    /* warm white key light */
  --spotlight-gel: #ff2d6f;    /* theatre gel, the accent */
  --spotlight-haze: 0.6;       /* how much the smoke varies in the cones, 0 (even) to 1 (rolling) */
}

Any CSS colour works (hex, rgb(), oklch(), color-mix()). The light is additive, so the effect wants a dark ground. On a mid-tone ground lower data-spotlight-intensity and re-check contrast.

Contrast. The heading keeps its own colour when unlit; the catch only brightens it. In the demo the headline holds at least 4:1 against the brightest light behind it, even on its unlit ink (entrance crossing, landed and pin spot, measured at 1200 x 675 and 390 x 844), and the support line holds 7.5:1. If you change the heading colour, the key or gel colour, or the haze, measure again with the pin spot held on your heading.

Options

Attribute Values Default Description
data-spotlight (present) Marks the layer
data-spotlight-target CSS selector first h1, h2, h3 in the container The element the beams land on and light
data-spotlight-beams 1, 2 2 One centred key light, or key plus gel
data-spotlight-follow 0 on 0 stops the beams following the pointer or a dragging finger
data-spotlight-idle 0 on 0 turns off all autonomous motion: the idle sway, the touch auto-sweep and the drifting haze. The canvas then stops drawing once the beams settle
data-spotlight-entrance 0 on 0 skips the entrance; the beams start landed
data-spotlight-intensity 0 to 1 1 Scales brightness and every motion together
data-spotlight-keys (present) On a focusable ancestor: enables the keyboard controls below

Examples

One beam, no idle motion

<div class="spotlight" data-spotlight data-spotlight-beams="1" data-spotlight-idle="0" aria-hidden="true">
  ...
</div>

Lighting a specific element, at half strength

<div class="spotlight" data-spotlight data-spotlight-target=".price" data-spotlight-intensity="0.5" aria-hidden="true">
  ...
</div>

Keyboard control

<section class="hero" tabindex="0" data-spotlight-keys
         aria-label="Spring season" aria-describedby="hero-keys">
  <div class="spotlight" data-spotlight aria-hidden="true">...</div>
  ...
  <p class="sr-only" id="hero-keys">Arrow keys aim the stage lights; hold Space to focus them.</p>
</section>

Behaviour

  • Entrance: the lamps strike and the beams swing in from off-stage, past each other over the target, then back to land, in 1.5 seconds.
  • Pointer: both beams aim at the pointer; the gel beam trails the key. Faster movement widens the cones and thickens the haze.
  • Press and hold: a tight, brighter pin spot with a crisper edge; release springs it open. Presses on links, buttons and form fields are ignored.
  • Idle: 2.5 seconds after the last input the beams drift home to the target and sway about two degrees. The haze keeps rolling the whole time.
  • Touch: after the entrance the beams sweep slowly across the content on their own. Drag to aim, tap for a pin-spot pulse.
  • Crossing: where the two beams or pools overlap, the colours add like real light.

Accessibility

  • Reduced motion: one WebGL frame, drawn once: both beams landed on the target, haze held still. No entrance, sway, sweep or pointer following. The keyboard controls and the API still work and redraw instantly.
  • Without JavaScript, or without WebGL: the CSS rendition, a composed still of both beams resting on the middle of the box. It is never hidden until a canvas actually exists.
  • Keyboard: with data-spotlight-keys on a focusable ancestor, arrow keys jump the key beam a step (the gel beam eases after it), Space or Enter held focuses the pin spot, Escape sends the beams home.
  • Screen readers: the layer and its canvas are aria-hidden; nothing in them is content.

Programmatic Control

// Aim both beams at a point, as fractions of the layer (0 to 1).
SpotlightBackground.aim(0.3, 0.6);
// No arguments: back to the target.
SpotlightBackground.aim();

// Pin spot on and off.
SpotlightBackground.focus(true);
SpotlightBackground.focus(false);

// Re-run the entrance.
SpotlightBackground.replay();

// Freeze and resume.
SpotlightBackground.pause();
SpotlightBackground.play();

// Tear everything down: the canvas and its WebGL context, listeners,
// observers, the loop, and the classes and inline styles it added.
window.gsapContext.revert();   // or SpotlightBackground.revert()

The methods act on every [data-spotlight] layer on the page. Always tear down with revert(), never kill(): kill() skips the cleanup that releases the WebGL context, and browsers cap how many a page may hold.

Dependencies

Dependency Version Required
three.js 0.180.0 Yes, as an ES module (see Quick Start)
GSAP core 3.12+ (the demo is built against 3.15.0) Yes: every motion value (the entrance timeline, the lagged aim, the pin spot and its elastic release, the idle return and sway), the frame loop's ticker, matchMedia branching and teardown

No GSAP plugins. GSAP moves plain numbers (where each beam points, how wide, how bright); the shader reads them each frame and draws the light. Everything used from three.js (WebGLRenderer, ShaderMaterial, PlaneGeometry, OrthographicCamera) is long-stable API, so pinning a different version is a one-line change in the import map.

Browser Support

Anything with WebGL, which is every current browser. Support is probed before three.js is asked for a renderer: three logs its own failure to the console as errors before it throws, so a visitor with WebGL disabled would otherwise get a console full of red on a page that had quietly fallen back. Probed first, that visitor gets the CSS rendition and a clean console. In a browser too old for import maps the module never runs, and the CSS rendition is again what shows.

Performance

One draw call per frame: a single full-screen pass. The expensive part is the haze, three octaves of 3D noise, and it is only computed where a beam actually is. The loop runs only while the layer is on screen and the tab is visible, and with data-spotlight-idle="0" it stops entirely once the beams settle. Pixel ratio is capped at 2 (1.5 on touch devices).

To buy back frames on low-end hardware, set data-spotlight-idle="0": the canvas then draws only while the beams are moving, instead of continuously for the drifting haze.