Back to Templates 020

Coming Soon Template Template

FREE

A free single-screen holding page dressed as a picture house: a projector beam rakes across a dark auditorium, a house light wanders the room, and the countdown is an Academy leader whose sweep hand turns continuously while the day count cuts once a day.

countdownambientcanvasparticle-systemblend-modeload-sequencerevealstaggerquickTomouse-followorganic-motionmicro-interaction beginner

About This Template

A complete coming-soon page that fits on one screen and never scrolls on a desktop. The composition is an independent cinema: a near-black auditorium with a lit bone slide floating in the middle of it, the way a picture house projects a still card before the film starts.

A projector beam rakes down from the top right with dust turning inside it, a soft house light wanders the room on a path that never repeats, and film grain runs over everything at 24 frames a second. On fine pointers the beam leans a degree toward the cursor.

The countdown is an Academy leader, and it counts to a real opening date set by one data attribute. It is split by update rate: the sweep hand turns continuously at one revolution a minute so the page reads as live, the day count cuts once a day, and the hours and minutes refresh once a minute. There is no seconds digit anywhere, because on a launch months out it is the only element that ever visibly moves and it carries the least information.

The leader sits above the card by default so a phone gets it too, and pins into the dark strip beside the slide on wide screens. The whole page runs on GSAP core alone and reskins by repainting seven colour tokens.

What's Included

  • Academy leader countdown to a real date: a sweep hand turning continuously at one revolution a minute, over a day count that cuts instantly rather than tweening
  • No seconds digit anywhere by design, so nothing on the page animates on a one-second beat it can fall behind
  • Opening date set by one data attribute; the leader reads 'Now open' once it passes, and stays an empty ring if the date is missing rather than inventing a number
  • Signature projector beam: a rotated, masked light band with soft cross-section falloff, breathing over a 14-second cycle and pivoting toward the pointer from the projection port
  • A house light that wanders on two sines per axis with no common multiple, so its path never closes and the eye never learns a loop
  • Canvas film grain generated once into a 128px tile and re-offset at 24fps, so a full-screen grain pass costs one fill per frame instead of a per-pixel rebuild
  • Dust motes drifting inside the beam, masked and clipped by the beam itself
  • Orchestrated load sequence: the lamp strikes, the slide lifts, masked headline lines reveal, the rule draws, the leader threads up
  • Email capture form with a morphing success state and a polite screen-reader announcement
  • GSAP core only - no plugins, no Lenis, one CDN script tag
  • Reduced motion keeps a working countdown: the numbers still update, only the sweep is dropped
  • Two-face type system and seven colour tokens with RGB triples, so a reskin is a handful of value edits

Perfect For

  • Venue, cinema or bar launch holding pages
  • Product, store or studio opening announcements
  • Pre-launch email capture for a waitlist
  • Rebrand or relaunch placeholders that need to feel designed
  • Agency 'new site coming' pages that still sell the work

How It Works

The projector beam

The beam is a rotated rectangle rather than a clipped wedge. A clip-path polygon gives light hard geometric edges and no cross-section falloff, so it reads as haze; instead a vertical gradient supplies the soft edges across the band's width, a mask-image supplies the falloff with distance from the lamp, and a CSS rotation sets the angle. It lives inside an otherwise empty pivot element so GSAP's pointer lean writes a transform that never fights the beam's own angle, and so the beam keeps its angle with JavaScript disabled.

The grain

Grain is generated once as a 128px tile of mid-grey noise into an offscreen canvas, then drawn as a repeating pattern at a random integer offset each frame - one fill per frame rather than a per-pixel rebuild - throttled to 24fps because that is what a film gate runs at. It composites in overlay against mid-grey, so it textures the image without shifting any measured contrast pair. Dust is a small particle set on a canvas inside the beam, driven by gsap.ticker and inheriting the beam's mask.

The Academy leader

The Academy leader runs off one ticker callback split by update rate: the sweep rotation comes from the current millisecond within the minute, so it is driven straight from the wall clock and cannot drift, while the day count and the hours-and-minutes line are written only when their values actually change, making the callback a single setter on all but a handful of frames. Under reduced motion the same function runs on a 30-second interval with no sweep, so the countdown still works without anything moving. The wandering house light sums two sines per axis at frequencies with no common multiple, which is what stops the path closing into a loop.

The load sequence

The load sequence is a single timeline that fades the pivot up, lifts the slide, and reveals the headline lines out of overflow masks, setting yPercent and y together so GSAP's parsed pixel offset cannot strand them. Everything is wrapped in gsap.context with a matchMedia reduced-motion branch, and every start state is set from JavaScript so the document is complete without it.

Difficulty Beginner
Includes HTML + JS + CSS source, documentation, lifetime updates
<!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">
    <meta name="description" content="The Lantern - a two-screen repertory picture house opening on Sharrow Vale Road, Sheffield, autumn 2027. Leave your email and we'll hold you a seat.">
    <title>The Lantern - Coming Soon Template | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Oswald:wght@300;400;500;600&family=Cabin:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>

<!-- The auditorium is decoration: a projector beam raking across the
     dark, and dust turning over inside it. Both sit BEHIND the slide,
     so neither can ever wash out the type. -->
<div class="house" aria-hidden="true">
    <!-- A house light wandering the room on its own path. Nothing drives
         it but the clock, and its path never repeats, so it is always
         moving and never on a beat. -->
    <div class="spot" data-spot></div>

    <!-- Two elements on purpose: the pivot carries nothing but the
         transform GSAP leans, so the beam keeps its CSS angle and still
         looks right with JavaScript switched off. -->
    <div class="beam-pivot" data-beam>
        <div class="beam">
            <canvas class="beam-dust" data-dust></canvas>
        </div>
    </div>
</div>

<header class="top" data-fade>
    <p class="wordmark">The Lantern<span class="wordmark-lamp" aria-hidden="true"></span></p>
    <p class="top-place"><span class="top-street">Sharrow Vale Rd &middot; </span>Sheffield</p>
</header>

<!-- The slide. A picture house projects a still card before the film;
     this page is that card, and everything lives on it. -->
<main class="stage">
    <!-- Academy leader. Set the opening date on data-open; there is no
         date logic to edit. It sits in the flow above the card so narrow
         screens get the countdown too, and pins beside the slide on wide
         ones. The numeral and caption are filled by JS, so with scripting
         off this is an empty ring rather than a wrong number - the date
         in the facts row below carries it instead. -->
    <div class="leader-block" data-leader data-open="2027-10-02T19:30:00" aria-hidden="true">
        <div class="leader">
            <span class="leader-sweep" data-leader-sweep></span>
            <span class="leader-ring"></span>
            <span class="leader-cross"></span>
            <span class="leader-num" data-leader-num></span>
        </div>
        <p class="leader-cap">
            <span class="leader-cap-key" data-leader-key></span>
            <span class="leader-cap-sub" data-leader-sub></span>
        </p>
    </div>

    <div class="slide" data-slide>
        <p class="kicker" data-fade>Coming soon</p>

        <h1 class="title">
            <span class="line"><span class="line-inner">The lights go down</span></span>
            <span class="line"><span class="line-inner">on Sharrow Vale</span></span>
            <span class="line"><span class="line-inner">in autumn 2027.</span></span>
        </h1>

        <div class="rule" aria-hidden="true"><span class="rule-bar"></span></div>

        <p class="sub" data-fade>Two screens and a hundred and eighty seats on Sharrow Vale
        Road. Repertory six nights a week, 35mm when the print exists, a bar that stays
        open long after the credits, and not one second of advertising.</p>

        <ul class="facts" data-fade>
            <li class="fact"><span class="fact-key">First screening</span><span class="fact-val">2 October 2027</span></li>
            <li class="fact"><span class="fact-key">Formats</span><span class="fact-val">35mm &amp; 4K digital</span></li>
            <li class="fact"><span class="fact-key">Seats</span><span class="fact-val">180 across two screens</span></li>
        </ul>

        <form class="notify" action="#" method="post" data-notify data-fade>
            <div class="notify-field">
                <label class="sr-only" for="email">Email address</label>
                <input class="notify-input" id="email" type="email" name="email"
                       placeholder="you@example.com" required autocomplete="email">
                <button class="notify-btn" type="submit">Join the queue</button>
            </div>
            <p class="notify-done" hidden>Seat held. We'll be in touch before opening night.</p>
            <p class="notify-status sr-only" role="status" aria-live="polite"></p>
            <p class="notify-note">One email when we open. Nothing else, ever.</p>
        </form>
    </div>
</main>

<footer class="foot" data-fade>
    <div class="foot-links">
        <a class="foot-link" href="#">Instagram</a>
        <a class="foot-link" href="mailto:hello@thelantern.film">hello@thelantern.film</a>
    </div>
    <p class="foot-note">&copy; 2027 The Lantern Picture House</p>
</footer>

<!-- Grain sits on top of everything, the way it sits on a print. -->
<canvas class="grain" data-grain aria-hidden="true"></canvas>

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

</body>
</html>
/* ============================================
   THE LANTERN - COMING SOON TEMPLATE
   Single-screen holding page. Sections are
   independent: delete any block from the HTML
   and the rest keeps working.

   The opening date lives on the markup, not in
   here:
   <div data-leader data-open="2027-10-02T19:30:00">
   See the README for the full contract.
   ============================================ */

/* Runs the init straight away if the DOM is already parsed (a script
   executed late or deferred, e.g. by Cloudflare Rocket Loader), and
   waits for DOMContentLoaded otherwise. */
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initLantern() {

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

        mm.add({
            isActive: '(prefers-reduced-motion: no-preference)',
            isFine: '(pointer: fine) and (prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            const cond = context.conditions;
            const cleanups = [];

            if (cond.isReduced) {
                /* Static page, but still a working countdown: the leader
                   fills its numbers and skips the sweep. */
                initLeader(cleanups, false);
                initForm(false);
                return function cleanup() {
                    cleanups.forEach(function (fn) { fn(); });
                    cleanups.length = 0;
                };
            }

            initBeam(cleanups, cond.isFine);
            initSpot(cleanups);
            initDust(cleanups);
            initLeader(cleanups, true);
            initGrain(cleanups);
            initReveal();
            initForm(true);

            return function cleanup() {
                cleanups.forEach(function (fn) { fn(); });
                cleanups.length = 0;
            };
        });
    });

    window.gsapContext = ctx;

    /* ---------- Beam: the lamp, breathing and leaning ---------- */
    function initBeam(cleanups, isFine) {
        const beam = document.querySelector('[data-beam]');
        if (!beam) return;

        /* The breathe goes on the inner band; the load sequence fades the
           pivot. One element, one owner of a property - both writing
           opacity to the same node leaves the beam dim and arbitrary. */
        const band = beam.querySelector('.beam');
        if (band) {
            /* Fourteen seconds a cycle. Long enough that the eye reads it
               as the room rather than as an animation with a tempo. */
            gsap.to(band, {
                opacity: 0.66,
                duration: 14,
                ease: 'sine.inOut',
                yoyo: true,
                repeat: -1
            });
        }

        if (!isFine) return;

        /* The beam pivots at the projection port, top right, the way a
           real one does. Tiny range on purpose: the room shifting, not a
           searchlight following the cursor. The origin is the pivot's
           own CSS transform-origin - GSAP inherits it, so the angle
           lives in one place. */
        const rotTo = gsap.quickTo(beam, 'rotation', { duration: 1.5, ease: 'power2.out' });
        const yTo = gsap.quickTo(beam, 'yPercent', { duration: 1.5, ease: 'power2.out' });

        function lean(e) {
            rotTo((e.clientY / window.innerHeight - 0.5) * 1.1);
            yTo((e.clientX / window.innerWidth - 0.5) * -2.4);
        }

        window.addEventListener('pointermove', lean);
        cleanups.push(function () { window.removeEventListener('pointermove', lean); });
    }

    /* ---------- Spotlight: a house light wandering the room ---------- */
    function initSpot(cleanups) {
        const spot = document.querySelector('[data-spot]');
        if (!spot) return;

        /* Two sines per axis at frequencies with no common multiple, so
           the path never closes. A single sine is a loop, and the eye
           learns a loop; this has no period to learn, so it reads as
           drift rather than as animation. */
        const setX = gsap.quickSetter(spot, 'x', 'px');
        const setY = gsap.quickSetter(spot, 'y', 'px');

        function drift(time) {
            const w = window.innerWidth;
            const h = window.innerHeight;
            setX(w * (0.5 + 0.30 * Math.sin(time * 0.083) + 0.13 * Math.sin(time * 0.191)));
            setY(h * (0.46 + 0.24 * Math.sin(time * 0.117) + 0.11 * Math.sin(time * 0.237)));
        }

        gsap.ticker.add(drift);
        cleanups.push(function () { gsap.ticker.remove(drift); });
    }

    /* ---------- Academy leader: the countdown ---------- */
    /* Split by how often each part changes: the sweep is continuous (one
       turn a minute, which is what reads as live), the day count changes
       once a day, hours and minutes once a minute. Every value is read
       from the clock rather than accumulated, so nothing drifts or has
       to catch up after a backgrounded tab. */
    function initLeader(cleanups, animate) {
        const block = document.querySelector('[data-leader]');
        if (!block) return;

        const target = new Date(block.getAttribute('data-open')).getTime();
        if (isNaN(target)) return;      /* bad date: leave the chart empty */

        const sweep = block.querySelector('[data-leader-sweep]');
        const num = block.querySelector('[data-leader-num]');
        const key = block.querySelector('[data-leader-key]');
        const sub = block.querySelector('[data-leader-sub]');

        const setRotation = (animate && sweep)
            ? gsap.quickSetter(sweep, 'rotation', 'deg')
            : null;

        let lastDays = -1;
        let lastMins = -1;
        let opened = false;

        function write() {
            const now = Date.now();

            /* One revolution a minute, straight off the wall clock. */
            if (setRotation) setRotation(((now % 60000) / 60000) * 360);

            if (opened) return;

            const ms = target - now;
            if (ms <= 0) {
                opened = true;
                if (num) num.textContent = '0';
                if (key) key.textContent = 'Now open';
                if (sub) sub.textContent = '';
                return;
            }

            const days = Math.floor(ms / 86400000);
            const mins = Math.floor(ms / 60000) % 60;

            /* Written only when the value actually changes, so on all but
               a handful of frames this whole function is one setter. */
            if (days !== lastDays) {
                lastDays = days;
                if (num) num.textContent = String(days);
                if (key) key.textContent = days === 1 ? 'Day to opening' : 'Days to opening';
            }
            if (mins !== lastMins) {
                lastMins = mins;
                const hours = Math.floor(ms / 3600000) % 24;
                if (sub) sub.textContent = hours + ' hr ' + mins + ' min';
            }
        }

        write();
        block.classList.add('is-live');

        if (!animate) {
            /* No sweep to drive, so a slow interval keeps the text honest
               without putting anything on screen in motion. */
            const timer = window.setInterval(write, 30000);
            cleanups.push(function () { window.clearInterval(timer); });
            return;
        }

        gsap.ticker.add(write);
        cleanups.push(function () { gsap.ticker.remove(write); });
    }

    /* ---------- Dust: what the beam is actually showing you ---------- */
    function initDust(cleanups) {
        const canvas = document.querySelector('[data-dust]');
        if (!canvas) return;
        const cx = canvas.getContext('2d');
        if (!cx) return;

        let w = 0;
        let h = 0;
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const specks = [];
        const COUNT = 38;

        function size() {
            w = canvas.offsetWidth;
            h = canvas.offsetHeight;
            if (!w || !h) return;
            canvas.width = Math.round(w * dpr);
            canvas.height = Math.round(h * dpr);
            cx.setTransform(dpr, 0, 0, dpr, 0, 0);
        }
        size();

        for (let i = 0; i < COUNT; i++) {
            specks.push({
                x: Math.random() * (w || 1),
                y: Math.random() * (h || 1),
                r: 0.5 + Math.random() * 1.5,
                v: 3 + Math.random() * 8,            /* px per second, upward */
                sway: 8 + Math.random() * 18,
                phase: Math.random() * Math.PI * 2,
                a: 0.14 + Math.random() * 0.34
            });
        }

        function draw(time, delta) {
            if (!w || !h) return;
            const dt = Math.min(delta, 100) / 1000;
            cx.clearRect(0, 0, w, h);
            cx.fillStyle = '#F0B44E';
            for (let i = 0; i < specks.length; i++) {
                const s = specks[i];
                s.y -= s.v * dt;
                if (s.y < -4) { s.y = h + 4; s.x = Math.random() * w; }
                const x = s.x + Math.sin(time * 0.26 + s.phase) * s.sway;
                cx.globalAlpha = s.a * (0.65 + 0.35 * Math.sin(time * 0.7 + s.phase));
                cx.beginPath();
                cx.arc(x, s.y, s.r, 0, Math.PI * 2);
                cx.fill();
            }
            cx.globalAlpha = 1;
        }

        gsap.ticker.add(draw);
        window.addEventListener('resize', size);
        cleanups.push(function () {
            gsap.ticker.remove(draw);
            window.removeEventListener('resize', size);
        });
    }

    /* ---------- Grain: one tile, moved, at 24fps ---------- */
    function initGrain(cleanups) {
        const canvas = document.querySelector('[data-grain]');
        if (!canvas) return;
        const cx = canvas.getContext('2d');
        if (!cx) return;

        /* Noise is expensive to generate and cheap to move, so the tile is
           built once and re-offset every frame. Rebuilding full-screen
           noise per frame costs more than the rest of the page. */
        const TILE = 128;
        const tile = document.createElement('canvas');
        tile.width = TILE;
        tile.height = TILE;
        const tcx = tile.getContext('2d');
        if (!tcx) return;

        const img = tcx.createImageData(TILE, TILE);
        for (let i = 0; i < img.data.length; i += 4) {
            /* Centred on mid-grey so the overlay blend nudges contrast
               both ways and shifts no measured pair. */
            const v = 128 + (Math.random() - 0.5) * 96;
            img.data[i] = v;
            img.data[i + 1] = v;
            img.data[i + 2] = v;
            img.data[i + 3] = 255;
        }
        tcx.putImageData(img, 0, 0);

        const pattern = cx.createPattern(tile, 'repeat');
        if (!pattern) return;

        let w = 0;
        let h = 0;

        function size() {
            /* Device-pixel-ratio 1 on purpose: grain should be the size of
               grain, not of a pixel, and this is a quarter of the fill
               cost on a retina screen. */
            w = window.innerWidth;
            h = window.innerHeight;
            canvas.width = w;
            canvas.height = h;
            cx.fillStyle = pattern;
        }
        size();

        /* A film gate runs at 24 frames a second and so does this. Fast
           enough to read as grain rather than as a pulse. */
        const STEP = 1 / 24;
        let acc = 0;

        function draw(time, delta) {
            acc += delta / 1000;
            if (acc < STEP) return;
            acc %= STEP;
            const ox = Math.floor(Math.random() * TILE);
            const oy = Math.floor(Math.random() * TILE);
            cx.setTransform(1, 0, 0, 1, -ox, -oy);
            cx.fillRect(ox, oy, w, h);
        }

        gsap.ticker.add(draw);
        window.addEventListener('resize', size);
        cleanups.push(function () {
            gsap.ticker.remove(draw);
            window.removeEventListener('resize', size);
        });
    }

    /* ---------- Reveal: the lamp strikes, the slide comes up ---------- */
    function initReveal() {
        /* Start states are set here, not in the stylesheet, so the page
           is complete with JavaScript disabled. */
        const beam = document.querySelector('[data-beam]');
        const slide = document.querySelector('[data-slide]');
        const lines = gsap.utils.toArray('.title .line-inner');
        const rule = document.querySelector('.rule-bar');
        const leader = document.querySelector('[data-leader]');
        const fades = gsap.utils.toArray('[data-fade]');

        const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });

        if (beam) {
            /* Struck, not flicked. Nothing on this page strobes: a
               flickering light source is a photosensitivity risk. */
            gsap.set(beam, { opacity: 0 });
            tl.to(beam, { opacity: 1, duration: 1.6, ease: 'power2.inOut' }, 0);
        }
        if (slide) {
            gsap.set(slide, { opacity: 0, y: 16, scale: 0.988 });
            tl.to(slide, { opacity: 1, y: 0, scale: 1, duration: 1.1 }, 0.15);
        }
        if (lines.length) {
            /* yPercent and y together: GSAP parses a CSS translate into a
               pixel y, and tweening only yPercent would strand it. */
            gsap.set(lines, { yPercent: 112, y: 0 });
            tl.to(lines, { yPercent: 0, y: 0, duration: 1.05, stagger: 0.1, ease: 'power4.out' }, 0.4);
        }
        if (rule) {
            gsap.set(rule, { scaleX: 0, transformOrigin: 'left center' });
            tl.to(rule, { scaleX: 1, duration: 1.2, ease: 'power2.inOut' }, 0.8);
        }
        if (fades.length) {
            gsap.set(fades, { opacity: 0, y: 14 });
            tl.to(fades, { opacity: 1, y: 0, duration: 0.85, stagger: 0.08 }, 0.6);
        }
        if (leader) {
            /* Threads up last, the way the projectionist would. */
            gsap.set(leader, { opacity: 0, scale: 0.9 });
            tl.to(leader, { opacity: 1, scale: 1, duration: 0.9, ease: 'back.out(1.4)' }, 1.15);
        }
    }

    /* ---------- Notify form ---------- */
    function initForm(animate) {
        const form = document.querySelector('[data-notify]');
        if (!form) return;
        const field = form.querySelector('.notify-field');
        const doneEl = form.querySelector('.notify-done');
        const status = form.querySelector('.notify-status');

        form.addEventListener('submit', function (e) {
            e.preventDefault();               /* wire to your list provider: see README */
            if (!form.checkValidity()) { form.reportValidity(); return; }
            if (!field || !doneEl) return;

            if (status) status.textContent = 'Saved. We will email you once, when we open.';

            if (!animate) {
                field.hidden = true;
                doneEl.hidden = false;
                return;
            }
            gsap.timeline()
                .to(field, { opacity: 0, y: -10, duration: 0.3, ease: 'power2.in' })
                .set(field, { display: 'none' })
                .call(function () { doneEl.hidden = false; })
                .set(doneEl, { opacity: 0, y: 12 })
                .to(doneEl, { opacity: 1, y: 0, duration: 0.5, ease: 'back.out(1.6)' });
        });
    }
});
!function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){const e=gsap.context(function(){gsap.matchMedia().add({isActive:"(prefers-reduced-motion: no-preference)",isFine:"(pointer: fine) and (prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){const o=e.conditions,a=[];return o.isReduced?(t(a,!1),n(!1),function(){a.forEach(function(e){e()}),a.length=0}):(function(e,t){const n=document.querySelector("[data-beam]");if(!n)return;const o=n.querySelector(".beam");o&&gsap.to(o,{opacity:.66,duration:14,ease:"sine.inOut",yoyo:!0,repeat:-1});if(!t)return;const a=gsap.quickTo(n,"rotation",{duration:1.5,ease:"power2.out"}),i=gsap.quickTo(n,"yPercent",{duration:1.5,ease:"power2.out"});function r(e){a(1.1*(e.clientY/window.innerHeight-.5)),i(-2.4*(e.clientX/window.innerWidth-.5))}window.addEventListener("pointermove",r),e.push(function(){window.removeEventListener("pointermove",r)})}(a,o.isFine),function(e){const t=document.querySelector("[data-spot]");if(!t)return;const n=gsap.quickSetter(t,"x","px"),o=gsap.quickSetter(t,"y","px");function a(e){const t=window.innerWidth,a=window.innerHeight;n(t*(.5+.3*Math.sin(.083*e)+.13*Math.sin(.191*e))),o(a*(.46+.24*Math.sin(.117*e)+.11*Math.sin(.237*e)))}gsap.ticker.add(a),e.push(function(){gsap.ticker.remove(a)})}(a),function(e){const t=document.querySelector("[data-dust]");if(!t)return;const n=t.getContext("2d");if(!n)return;let o=0,a=0;const i=Math.min(window.devicePixelRatio||1,2),r=[],c=38;function s(){o=t.offsetWidth,a=t.offsetHeight,o&&a&&(t.width=Math.round(o*i),t.height=Math.round(a*i),n.setTransform(i,0,0,i,0,0))}s();for(let e=0;e<c;e++)r.push({x:Math.random()*(o||1),y:Math.random()*(a||1),r:.5+1.5*Math.random(),v:3+8*Math.random(),sway:8+18*Math.random(),phase:Math.random()*Math.PI*2,a:.14+.34*Math.random()});function d(e,t){if(!o||!a)return;const i=Math.min(t,100)/1e3;n.clearRect(0,0,o,a),n.fillStyle="#F0B44E";for(let t=0;t<r.length;t++){const c=r[t];c.y-=c.v*i,c.y<-4&&(c.y=a+4,c.x=Math.random()*o);const s=c.x+Math.sin(.26*e+c.phase)*c.sway;n.globalAlpha=c.a*(.65+.35*Math.sin(.7*e+c.phase)),n.beginPath(),n.arc(s,c.y,c.r,0,2*Math.PI),n.fill()}n.globalAlpha=1}gsap.ticker.add(d),window.addEventListener("resize",s),e.push(function(){gsap.ticker.remove(d),window.removeEventListener("resize",s)})}(a),t(a,!0),function(e){const t=document.querySelector("[data-grain]");if(!t)return;const n=t.getContext("2d");if(!n)return;const o=128,a=document.createElement("canvas");a.width=o,a.height=o;const i=a.getContext("2d");if(!i)return;const r=i.createImageData(o,o);for(let e=0;e<r.data.length;e+=4){const t=128+96*(Math.random()-.5);r.data[e]=t,r.data[e+1]=t,r.data[e+2]=t,r.data[e+3]=255}i.putImageData(r,0,0);const c=n.createPattern(a,"repeat");if(!c)return;let s=0,d=0;function u(){s=window.innerWidth,d=window.innerHeight,t.width=s,t.height=d,n.fillStyle=c}u();const l=1/24;let f=0;function p(e,t){if(f+=t/1e3,f<l)return;f%=l;const a=Math.floor(Math.random()*o),i=Math.floor(Math.random()*o);n.setTransform(1,0,0,1,-a,-i),n.fillRect(a,i,s,d)}gsap.ticker.add(p),window.addEventListener("resize",u),e.push(function(){gsap.ticker.remove(p),window.removeEventListener("resize",u)})}(a),function(){const e=document.querySelector("[data-beam]"),t=document.querySelector("[data-slide]"),n=gsap.utils.toArray(".title .line-inner"),o=document.querySelector(".rule-bar"),a=document.querySelector("[data-leader]"),i=gsap.utils.toArray("[data-fade]"),r=gsap.timeline({defaults:{ease:"power3.out"}});e&&(gsap.set(e,{opacity:0}),r.to(e,{opacity:1,duration:1.6,ease:"power2.inOut"},0));t&&(gsap.set(t,{opacity:0,y:16,scale:.988}),r.to(t,{opacity:1,y:0,scale:1,duration:1.1},.15));n.length&&(gsap.set(n,{yPercent:112,y:0}),r.to(n,{yPercent:0,y:0,duration:1.05,stagger:.1,ease:"power4.out"},.4));o&&(gsap.set(o,{scaleX:0,transformOrigin:"left center"}),r.to(o,{scaleX:1,duration:1.2,ease:"power2.inOut"},.8));i.length&&(gsap.set(i,{opacity:0,y:14}),r.to(i,{opacity:1,y:0,duration:.85,stagger:.08},.6));a&&(gsap.set(a,{opacity:0,scale:.9}),r.to(a,{opacity:1,scale:1,duration:.9,ease:"back.out(1.4)"},1.15))}(),n(!0),function(){a.forEach(function(e){e()}),a.length=0})})});function t(e,t){const n=document.querySelector("[data-leader]");if(!n)return;const o=new Date(n.getAttribute("data-open")).getTime();if(isNaN(o))return;const a=n.querySelector("[data-leader-sweep]"),i=n.querySelector("[data-leader-num]"),r=n.querySelector("[data-leader-key]"),c=n.querySelector("[data-leader-sub]"),s=t&&a?gsap.quickSetter(a,"rotation","deg"):null;let d=-1,u=-1,l=!1;function f(){const e=Date.now();if(s&&s(e%6e4/6e4*360),l)return;const t=o-e;if(t<=0)return l=!0,i&&(i.textContent="0"),r&&(r.textContent="Now open"),void(c&&(c.textContent=""));const n=Math.floor(t/864e5),a=Math.floor(t/6e4)%60;if(n!==d&&(d=n,i&&(i.textContent=String(n)),r&&(r.textContent=1===n?"Day to opening":"Days to opening")),a!==u){u=a;const e=Math.floor(t/36e5)%24;c&&(c.textContent=e+" hr "+a+" min")}}if(f(),n.classList.add("is-live"),!t){const t=window.setInterval(f,3e4);return void e.push(function(){window.clearInterval(t)})}gsap.ticker.add(f),e.push(function(){gsap.ticker.remove(f)})}function n(e){const t=document.querySelector("[data-notify]");if(!t)return;const n=t.querySelector(".notify-field"),o=t.querySelector(".notify-done"),a=t.querySelector(".notify-status");t.addEventListener("submit",function(i){if(i.preventDefault(),t.checkValidity()){if(n&&o){if(a&&(a.textContent="Saved. We will email you once, when we open."),!e)return n.hidden=!0,void(o.hidden=!1);gsap.timeline().to(n,{opacity:0,y:-10,duration:.3,ease:"power2.in"}).set(n,{display:"none"}).call(function(){o.hidden=!1}).set(o,{opacity:0,y:12}).to(o,{opacity:1,y:0,duration:.5,ease:"back.out(1.6)"})}}else t.reportValidity()})}window.gsapContext=e});
/* ============================================
   THE LANTERN - COMING SOON TEMPLATE
   A holding page shaped like the card a picture
   house projects before the film starts.

   Two grounds rather than one: a near-black
   auditorium, and a lit bone slide floating on
   it. Type lives on the slide, chrome lives on
   the dark. The countdown is an Academy leader.
   ============================================ */

:root {
    /* An auditorium with the lamp on: two surfaces, one accent, and one
       warm reserved for the light. Contrast measured as used, composited:

       ON THE SLIDE (bone #EDE6D8)
         ink headline and fact values          15.17
         ink-dim body copy, sub and note        7.19
         oxblood kicker, fact keys, done state  6.97
         bone on the oxblood button             6.97
       ON THE DARK (house #1A0E11)
         bone wordmark and footer links        15.17
         house-dim place, caption, copyright    6.31

       Three rules the light imposes, worth keeping if you recolour:

       - The beam peaks at bulb 0.30, which would drop house-dim to 3.21
         on bare ground. .top and .foot carry a house scrim at 0.94 that
         holds house-dim at 6.10 and bone at 14.66 wherever the beam
         falls. Keep the scrims if you move the beam.
       - The beam renders behind the slide. In front of it, it lifts the
         bone and pulls every dark-on-light pair down at once.
       - --bulb is light, never type: on the bone slide it is 1.63.
         --ink and --ink-dim belong on the slide, --house-dim on the dark.

       Reskin = repaint the seven values below, keeping the -rgb triples
       in step; beam, spot, dust, leader and scrims resolve from them. */
    --house: #1A0E11;          /* the dark auditorium, page base */
    --screen: #EDE6D8;         /* the lit slide, and type on the dark */
    --ink: #1A0E11;            /* primary type, on the slide only */
    --ink-dim: #574549;        /* body copy, on the slide only */
    --house-dim: #A99089;      /* secondary type, on the dark only */
    --oxblood: #8E2233;        /* kicker, rule, button */
    --bulb: #F0B44E;           /* the lamp - light only, never type */

    --house-rgb: 26, 14, 17;
    --screen-rgb: 237, 230, 216;
    --ink-rgb: 26, 14, 17;
    --oxblood-rgb: 142, 34, 51;
    --bulb-rgb: 240, 180, 78;

    --font-display: 'Oswald', 'Haettenschweiler', 'Arial Narrow', sans-serif;
    --font-body: 'Cabin', system-ui, sans-serif;

    color-scheme: dark;
}

* { box-sizing: border-box; }

html, body { margin: 0; padding: 0; }

body {
    min-height: 100vh;
    min-height: 100svh;
    display: flex;
    flex-direction: column;
    background: var(--house);
    color: var(--screen);
    font-family: var(--font-body);
    font-size: 16px;
    line-height: 1.6;
    overflow-x: clip;
    -webkit-font-smoothing: antialiased;
}

/* ---------- The auditorium ---------- */

.house {
    position: fixed;
    inset: 0;
    z-index: 0;
    overflow: hidden;
    pointer-events: none;
    /* A little lamp spill low and right, so the dark is not flat. */
    background:
        radial-gradient(120vmin 90vmin at 88% 74%,
            rgba(var(--bulb-rgb), 0.09),
            rgba(var(--bulb-rgb), 0) 70%),
        var(--house);
}

/* A soft house light that wanders the room. Kept low: it crosses
   everything, and the scrims are sized for the beam, not for both. */
.spot {
    position: absolute;
    top: 0;
    left: 0;
    width: 62vmin;
    height: 62vmin;
    margin: -31vmin 0 0 -31vmin;
    border-radius: 50%;
    background: radial-gradient(closest-side,
        rgba(var(--bulb-rgb), 0.10),
        rgba(var(--bulb-rgb), 0.04) 48%,
        rgba(var(--bulb-rgb), 0) 76%);
    will-change: transform;
}

/* Carries only the pointer lean, so GSAP's transform never fights the
   beam's own angle - and the angle survives with scripting off. */
.beam-pivot {
    position: absolute;
    inset: 0;
    transform-origin: 88% 14%;
    will-change: transform, opacity;
}

/* A rotated band. The vertical gradient gives the shaft soft edges
   across its width, the mask fades it with distance from the lamp, and
   the rotation sets the angle. A hard-clipped polygon instead reads as
   haze rather than as light. */
.beam {
    position: absolute;
    top: 8%;
    left: -20%;
    width: 142%;
    height: 42%;
    /* Negative so the far end falls away: CSS positive rotation is
       clockwise, which would swing it up instead. */
    transform: rotate(-20deg);
    transform-origin: 86% 40%;
    background: linear-gradient(to bottom,
        rgba(var(--bulb-rgb), 0) 0%,
        rgba(var(--bulb-rgb), 0.10) 22%,
        rgba(var(--bulb-rgb), 0.44) 47%,
        rgba(var(--bulb-rgb), 0.36) 57%,
        rgba(var(--bulb-rgb), 0.09) 80%,
        rgba(var(--bulb-rgb), 0) 100%);
    -webkit-mask-image: linear-gradient(to left, #000 4%, rgba(0, 0, 0, 0.85) 45%, rgba(0, 0, 0, 0.45) 75%, transparent 100%);
    mask-image: linear-gradient(to left, #000 4%, rgba(0, 0, 0, 0.85) 45%, rgba(0, 0, 0, 0.45) 75%, transparent 100%);
    opacity: 0.9;
}

.beam-dust {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
}

header, main, footer { position: relative; z-index: 1; }

/* ---------- Academy leader ---------- */

/* In the flow above the card by default, so a phone gets the countdown
   too; it only pins out into the dark strip beside the slide once there
   is one. See the wide-screen block further down. */
.leader-block {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: clamp(9px, 1.5vh, 15px);
    pointer-events: none;
}

.leader {
    position: relative;
    width: clamp(108px, 26vw, 172px);
    aspect-ratio: 1;
    display: grid;
    place-items: center;
}

.leader > * { grid-area: 1 / 1; }

.leader-ring {
    width: 100%;
    height: 100%;
    border: 1px solid rgba(var(--bulb-rgb), 0.42);
    border-radius: 50%;
}

/* The crosshair: two hairlines through the centre, as on real leader. */
.leader-cross {
    width: 100%;
    height: 100%;
    background:
        linear-gradient(to right, rgba(var(--bulb-rgb), 0.3) 0 100%) center / 100% 1px no-repeat,
        linear-gradient(to bottom, rgba(var(--bulb-rgb), 0.3) 0 100%) center / 1px 100% no-repeat;
}

/* The sweep hand, as a rotating wedge of light rather than a line. */
.leader-sweep {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    background: conic-gradient(
        rgba(var(--bulb-rgb), 0.26),
        rgba(var(--bulb-rgb), 0.10) 32%,
        rgba(var(--bulb-rgb), 0) 62%,
        rgba(var(--bulb-rgb), 0) 100%);
    will-change: transform;
}

/* Sized for four digits: a launch three years out is 1,000+ days. */
.leader-num {
    font-family: var(--font-display);
    font-weight: 500;
    font-size: clamp(1.8rem, 5.6vw, 3rem);
    line-height: 1;
    letter-spacing: 0.01em;
    color: var(--screen);
    text-shadow: 0 0 26px rgba(var(--bulb-rgb), 0.5);
    font-variant-numeric: lining-nums;
    /* The numeral cuts rather than tweening. It changes once a day, so
       there is nothing to ease. */
}

.leader-cap {
    display: flex;
    flex-direction: column;
    gap: 2px;
    margin: 0;
    text-align: center;
}

.leader-cap-key {
    font-size: 0.64rem;
    font-weight: 600;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    color: var(--house-dim);
}

.leader-cap-sub {
    font-family: var(--font-display);
    font-size: 0.88rem;
    letter-spacing: 0.06em;
    color: var(--house-dim);
}

/* Filled by JS. Hidden until then so that with scripting off the leader
   is an empty focus chart rather than a stale or invented number. */
.leader-block:not(.is-live) .leader-cap { display: none; }

/* Grain rides on top of the whole print. Overlay against mid-grey noise
   means the tile only ever nudges contrast either side of what is
   already there, so it textures the image without shifting any pair. */
.grain {
    position: fixed;
    inset: 0;
    z-index: 2;
    width: 100%;
    height: 100%;
    pointer-events: none;
    mix-blend-mode: overlay;
    opacity: 0.55;
}

/* ---------- Top bar ---------- */

/* Scrims put the chrome on near-solid house wherever the beam lands, so
   its contrast does not depend on the beam's geometry missing it. */
.top {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 24px;
    padding: clamp(18px, 2.8vh, 28px) clamp(22px, 5vw, 54px);
    background: linear-gradient(to bottom,
        rgba(var(--house-rgb), 0.94) 0%,
        rgba(var(--house-rgb), 0.94) 58%,
        rgba(var(--house-rgb), 0) 100%);
}

.wordmark {
    display: flex;
    align-items: center;
    margin: 0;
    font-family: var(--font-display);
    font-weight: 500;
    font-size: 1.32rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    white-space: nowrap;
}

/* The lamp: a small lit dot, the only round thing on the page. */
.wordmark-lamp {
    width: 7px;
    height: 7px;
    margin-left: 0.6em;
    border-radius: 50%;
    background: var(--bulb);
    box-shadow: 0 0 12px rgba(var(--bulb-rgb), 0.9);
}

.top-place {
    margin: 0;
    font-size: 0.72rem;
    font-weight: 500;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    color: var(--house-dim);
}

/* ---------- Stage and slide ---------- */

/* Column so the leader can stack above the card on narrow screens. On
   wide screens the leader is pinned out of flow and this centres the
   card alone, as before. */
.stage {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: clamp(20px, 3.4vh, 34px);
    width: 100%;
    padding: clamp(10px, 2vh, 26px) clamp(18px, 5vw, 54px);
}

/* The lit card. The inner shadow is light falling off toward the edges
   the way it does on a real screen; the outer glow is the room picking
   up the spill. */
.slide {
    position: relative;
    /* Narrow enough that the condensed headline fills its own measure,
       and narrow enough to leave dark either side for the beam. */
    width: min(820px, 100%);
    padding: clamp(28px, 4.6vw, 54px) clamp(24px, 4.4vw, 56px) clamp(30px, 4.4vw, 50px);
    background:
        radial-gradient(140% 130% at 72% 18%,
            rgba(255, 255, 255, 0.55),
            rgba(255, 255, 255, 0) 62%),
        var(--screen);
    color: var(--ink);
    border-radius: 3px;
    box-shadow:
        0 0 0 1px rgba(var(--ink-rgb), 0.10),
        0 40px 90px -30px rgba(0, 0, 0, 0.7),
        0 0 120px -20px rgba(var(--bulb-rgb), 0.22);
    will-change: transform, opacity;
}

.kicker {
    margin: 0 0 clamp(14px, 2.2vh, 22px);
    font-size: 0.74rem;
    font-weight: 600;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--oxblood);
}

.title {
    margin: 0;
    font-family: var(--font-display);
    font-weight: 500;
    font-size: clamp(2rem, 5.6vw, 4.5rem);
    line-height: 0.96;
    letter-spacing: -0.005em;
    text-transform: uppercase;
}

.title .line { display: block; overflow: hidden; padding-bottom: 0.06em; margin-bottom: -0.06em; }
.title .line-inner { display: inline-block; will-change: transform; }

.rule { margin: clamp(20px, 3vh, 30px) 0 0; }

.rule-bar {
    display: block;
    height: 2px;
    background: var(--oxblood);
    will-change: transform;
}

.sub {
    margin: clamp(18px, 2.6vh, 26px) 0 0;
    max-width: 58ch;
    font-size: clamp(0.95rem, 1.1vw, 1.05rem);
    color: var(--ink-dim);
}

/* ---------- Facts ---------- */

.facts {
    display: flex;
    flex-wrap: wrap;
    gap: clamp(18px, 3.4vw, 46px);
    margin: clamp(20px, 3vh, 30px) 0 0;
    padding: 0;
    list-style: none;
}

.fact { display: flex; flex-direction: column; gap: 3px; }

.fact-key {
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    color: var(--oxblood);
}

.fact-val {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: 1.1rem;
    letter-spacing: 0.02em;
    color: var(--ink);
}

/* ---------- Notify form ---------- */

.notify {
    margin: clamp(24px, 3.6vh, 38px) 0 0;
    max-width: 540px;
}

.notify-field { display: flex; gap: 10px; }

.notify-input {
    flex: 1;
    min-width: 0;
    padding: 13px 16px;
    background: rgba(var(--ink-rgb), 0.04);
    border: 1px solid rgba(var(--ink-rgb), 0.34);
    border-radius: 4px;
    color: var(--ink);
    font-family: var(--font-body);
    font-size: 1rem;
}

.notify-input::placeholder { color: var(--ink-dim); opacity: 1; }

.notify-input:focus-visible {
    outline: 2px solid var(--oxblood);
    outline-offset: 2px;
    border-color: transparent;
}

.notify-btn {
    padding: 13px 24px;
    border: none;
    border-radius: 4px;
    background: var(--oxblood);
    color: var(--screen);
    font-family: var(--font-body);
    font-size: 0.95rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    cursor: pointer;
    transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.notify-btn:hover {
    transform: translateY(-1px);
    box-shadow: 0 8px 22px -6px rgba(var(--oxblood-rgb), 0.6);
}

.notify-btn:focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
}

.notify-done {
    margin: 0;
    font-family: var(--font-display);
    font-weight: 400;
    font-size: 1.3rem;
    letter-spacing: 0.01em;
    color: var(--oxblood);
}

.notify-note {
    margin: 12px 0 0;
    font-size: 0.8rem;
    color: var(--ink-dim);
}

/* ---------- Footer ---------- */

.foot {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 24px;
    padding: clamp(16px, 2.6vh, 26px) clamp(22px, 5vw, 54px);
    font-size: 0.82rem;
    background: linear-gradient(to top,
        rgba(var(--house-rgb), 0.94) 0%,
        rgba(var(--house-rgb), 0.94) 58%,
        rgba(var(--house-rgb), 0) 100%);
}

.foot-links { display: flex; gap: 22px; }

.foot-link {
    color: var(--screen);
    text-decoration: underline;
    text-decoration-color: rgba(var(--bulb-rgb), 0.55);
    text-underline-offset: 4px;
}

.foot-link:hover { text-decoration-color: var(--bulb); }

.foot-link:focus-visible {
    outline: 2px solid var(--bulb);
    outline-offset: 3px;
    border-radius: 2px;
}

.foot-note { margin: 0; color: var(--house-dim); }

/* ---------- Utilities ---------- */

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

/* ---------- Compact ---------- */

/* Wide enough for a dark strip beside the card: pin the leader into it
   and left-align its caption. Below this it stays in the flow above the
   card, which is where the markup puts it. */
@media (min-width: 1181px) {
    .leader-block {
        position: fixed;
        left: clamp(28px, 4vw, 74px);
        bottom: clamp(96px, 14vh, 150px);
        z-index: 1;
        align-items: flex-start;
    }

    .leader-cap { text-align: left; }
}

@media (max-width: 720px) {
    .facts { gap: 14px 28px; }
    .fact { flex: 1 1 40%; }
}

@media (max-width: 560px) {
    .top { align-items: center; }
    .top-place { letter-spacing: 0.12em; }
    .top-street { display: none; }
    .kicker { letter-spacing: 0.24em; }
    .notify-field { flex-direction: column; }
    .notify-btn { width: 100%; }
    /* Stacked: "First screening" is wider than the other keys, so a
       shared key column either wraps or leaves the values unaligned. */
    .facts { flex-direction: column; gap: 14px; }
    .fact { flex: 1 1 auto; gap: 2px; }
    .foot { flex-direction: column; align-items: flex-start; gap: 10px; }
    /* The beam is a wide-room effect; on a phone it just muddies. */
    .beam { opacity: 0.55; }
}

/* ---------- Reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
    .beam-dust,
    .grain { display: none; }
    /* The leader keeps its ring, crosshair and numbers; only the
       rotating sweep goes. */
    .leader-sweep { display: none; }
    .notify-btn { transition: none; }
    .notify-btn:hover { transform: none; }
}

A complete single-screen holding page shaped like the still card a picture house projects before the film: a dark auditorium, a lit slide floating in it, a projector beam raking down from the top right with dust turning over inside it, and film grain over the whole print. The demo dresses it as The Lantern, a two-screen repertory cinema opening in Sheffield; every word and colour is yours to change.

This template is free. It runs on GSAP core alone — no plugins, no smooth-scroll library, one CDN script tag.

Quick Start

  1. Copy index.html and the assets/ folder to your server. There is no build step; it deploys as static files.
  2. Set your opening date in data-open on the leader (see below).
  3. Replace the wordmark, headline, sub line, facts and footer with your own words.
  4. Wire the email form to your list provider (see below).

Open index.html in a browser. That's the whole install.

The countdown

The countdown is an Academy leader — the focus chart spliced onto the head of a reel — and it is a real countdown to your opening date, not decoration.

Set the date on the markup. There is no date logic to edit:

<div class="leader-block" data-leader data-open="2027-10-02T19:30:00">

data-open is read in the visitor's local time zone. For one global moment, append an offset: 2027-10-02T19:30:00+00:00. Once the date passes, the leader reads Now open. If the attribute is missing or unparseable the leader stays an empty ring rather than showing an invented number.

Why it is split three ways

Each part of the countdown updates at a different rate, and that is the whole design:

Part Changes Why
Sweep hand continuously, one turn a minute proves the page is live
Day count once a day the number people actually want
Hours and minutes once a minute confirms it is ticking

There is no seconds digit anywhere, on purpose. On a launch months out, a seconds digit is the only thing that ever visibly moves and it carries the least information, so the page spends all its motion budget on its least useful element. Worse, a one-second cadence leaves no rest state — by the time a roll has eased and settled, the next one is already due — which reads as restless rather than alive. No easing curve fixes that, because the problem is the beat, not the curve.

The sweep hand does that job instead. It turns continuously, driven straight off the wall clock, so it never starts, never stops, and cannot drift or arrive late. The numerals cut rather than tweening, which is correct for something that changes once a day: there is no settle to overrun.

Every value is read from the clock rather than accumulated, so a backgrounded tab comes back correct with no catch-up.

Placement

It sits in the flow above the card by default, so a phone gets it too. Above 1180px, where there is dark either side of the slide, it pins into the left strip. It carries the countdown, so it is never the piece that gets dropped at small sizes.

The wandering house light

The soft glow drifting around the auditorium is two sines per axis at frequencies with no common multiple:

x = w * (0.5  + 0.30*sin(t*0.083) + 0.13*sin(t*0.191))
y = h * (0.46 + 0.24*sin(t*0.117) + 0.11*sin(t*0.237))

A single sine is a loop, and a loop long enough to notice is still a loop the eye eventually learns. Summing two that never line up gives a path that never closes, so it reads as drift rather than as animation. Change the four frequencies if you want a different wander — just keep them from landing on simple ratios of each other, or the path snaps back into an obvious figure.

The facts row

<li class="fact">
    <span class="fact-key">First screening</span>
    <span class="fact-val">2 October 2027</span>
</li>

Edit it, delete it, or add a fourth fact. The row is a plain <ul> and wraps on its own. Keep the written date here in step with data-open on the leader — this one does not depend on JavaScript, so it is what a visitor with scripting off reads.

Wiring the email form

The form is front-end only: submitting shows the success state and goes no further. Point it at your provider by giving the <form> a real action (Mailchimp, Buttondown, ConvertKit and Formspree all accept a plain HTML form POST) and removing the e.preventDefault() line at the top of the submit handler in initForm. Keep the handler itself: the morphing success state and the screen-reader announcement both live there.

Palette and type

Five colours and two faces run the whole page. In :root:

--house: #1A0E11;      /* the dark auditorium, page base */
--screen: #EDE6D8;     /* the lit slide, and type on the dark */
--ink: #1A0E11;        /* primary type, on the slide only */
--ink-dim: #574549;    /* body copy, on the slide only */
--house-dim: #A99089;  /* secondary type, on the dark only */
--oxblood: #8E2233;    /* kicker, rule, button */
--bulb: #F0B44E;       /* the lamp — light only, never type */

Repaint those and keep the matching --*-rgb triples in step — the beam, dust, grain, scrims and button shadow all resolve from the triples, so a swap that misses them leaves the light the old colour. The faces are Oswald for display and Cabin for UI; swap the two --font-* tokens and the Google Fonts link to change them.

Two measured constraints to respect if you recolour, both explained at length in the comment at the top of style.css:

  • --bulb is never a text colour. It is the lamp. On the bone slide it measures 1.63:1.
  • --ink and --ink-dim belong on the slide; --house-dim belongs on the dark. They are not interchangeable, and the two grounds are far enough apart that swapping them fails immediately.

The beam, mechanically

The beam is a rotated rectangle, not a clipped wedge. That distinction is the whole reason it reads as light: a clip-path polygon gives the shaft hard geometric edges and a uniform cross-section, which looks like a smear of haze. Instead, a vertical gradient supplies the soft falloff across the band, a mask-image supplies the falloff along it (light fading with distance from the lamp), and a CSS rotation sets the angle.

It sits inside an otherwise empty .beam-pivot. That wrapper exists so GSAP's pointer lean writes its transform to a different element than the one carrying the beam's own rotate() — otherwise the two fight, and the beam snaps flat the first time the pointer moves. It also means the angle survives with JavaScript off.

The chrome sits on scrims rather than on bare ground. .top and .foot each carry a --house gradient at 0.94, so their text keeps its contrast wherever the beam happens to land. Keep them if you move the beam: the beam is positioned in percentages and the chrome is not, so routing the geometry around the chrome holds at one width and quietly fails at others.

The grain, mechanically

Noise is expensive to generate and cheap to move. The tile is generated once into a 128px offscreen canvas, then drawn as a repeating pattern at a random integer offset each frame — one fill per frame, rather than rebuilding a full-screen buffer pixel by pixel. It runs at 24fps because that is what a film gate runs at, and it composites in overlay against mid-grey noise, so it textures the image without shifting any measured contrast pair.

It renders at device-pixel-ratio 1 deliberately. Grain should be the size of grain, not the size of a pixel, and this is a quarter of the fill cost on a retina screen.

Deleting pieces

Every block is independent — the script checks for each piece and skips what it doesn't find. Delete the form, the facts, the beam, the grain or the footer freely; nothing else breaks.

Accessibility

  • Without JavaScript the page is complete. Every animation start state is set from JavaScript, and the beam is pure CSS, so the art direction survives with scripting off. The one thing JS gates is the leader's numerals: with scripting off it renders as an empty ring and crosshair rather than an invented or stale number, and the written date in the facts row carries the information instead.
  • With reduced motion, the grain and dust are gone, the beam and house light are still, the leader keeps its ring, crosshair and a static numeral but loses its sweep, and the form works with an instant success state.
  • No flicker anywhere. A strobing lamp was the obvious flourish here and it is deliberately absent: flicker is a photosensitivity risk, and the beam is eased over fourteen seconds instead.
  • The beam, dust, grain, house light and leader are all aria-hidden decoration. The form announces its success politely to screen readers.
  • Focus is visible on the input, button and links throughout.

Browser Support

Modern evergreen browsers. The layout is flexbox and the beam is plain CSS, so the page degrades gracefully; the canvas grain and dust simply do not appear if canvas is unavailable. mask-image ships prefixed for older WebKit.

Dependencies

Your Cart

Your cart is empty

Browse Effects