Library / Tutorial

Delivery tracker,
step by step.

A pizza order you can watch: a glass panel over a toy 3D town where the pizza bakes, flies into its box and rides a scooter to the door, by day, golden hour or night. One number drives all of it. Eight stages, each a live page you can run.

8Stages
1Number
~24Draw calls

This is the whole build of the delivery tracker, in the order it was written. It is the order-status section of a pizza shop, pizzatime (a fictional brand): a glass order panel over a real-time 3D town. The pizza bakes in a brick oven, flies into its box, the lid folds shut, a scooter rides the route to the customer's door and the courier hands the box over under a burst of confetti. The town follows the visitor's clock into golden hour and night. Each stage below is the component as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.

The page is plain HTML, CSS and JavaScript modules with no build step. The one library is three.js (r186, MIT), copied into vendor/three/ and loaded through an import map. js/timeline.js holds the order's timeline, js/tracker.js the state and the UI, js/scene3d.js the town. The whole tracker hangs on one idea: one number, t, the minutes since the order was placed. The status, the progress, the ETA, the timeline list, the camera and every moving thing in the town are worked out from it, so a scrubber, a demo clock or a real order feed can all drive the same page.

1 · The page

The head says the page is light. The meta and the color-scheme token make form controls and scrollbars light too, and the CSS never reads prefers-color-scheme, so a Mac in dark mode still gets the cream page of a pizza shop. Outfit sets the big type, Inter the rest.

index.html — the head, stage 1
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#e5502f">
<title>Track your order · pizzatime — step 1</title>
<meta name="description" content="Live order tracker for pizzatime (a fictional pizza brand): watch your pizza bake, get boxed and ride across the map to your door.">
<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=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/tracker.css">
<link rel="modulepreload" href="js/tracker.js">
<script type="module" src="js/tracker.js"></script>

The nav is a tomato bar with the brand mark (an inline SVG slice), the section links and, for phones, a menu button.

index.html — the nav
<header class="nav" id="top">
  <div class="nav-in">
    <button class="nav-icon nav-menu" type="button" aria-expanded="false" aria-controls="mnav" aria-label="Open menu">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
    </button>
    <a class="brand" href="#top" aria-label="pizzatime, back to top">
      <svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true"><path d="M5 9.5C10.6 4.4 21.4 4.4 27 9.5L16 28Z" fill="#fbf2e3"/><path d="M8.2 11.6c4.5-3 11.1-3 15.6 0L16 24.8Z" fill="#f6c96e"/><circle cx="13" cy="13.6" r="1.9" fill="#e5502f"/><circle cx="18.6" cy="13.2" r="1.7" fill="#e5502f"/><circle cx="16" cy="18.4" r="1.6" fill="#e5502f"/><path d="M4.2 8.6C10.3 2.9 21.7 2.9 27.8 8.6" fill="none" stroke="#fbf2e3" stroke-width="2.4" stroke-linecap="round"/></svg>
      <span>pizzatime</span>
    </a>
    <nav class="nav-links" id="mnav" aria-label="Main">
      <ul>
        <li><a href="#tracker" aria-current="page">Track order</a></li>
        <li><a href="#order">Your order</a></li>
        <li><a href="#help">Help</a></li>
      </ul>
    </nav>
  </div>
</header>

The hero is the stage: a box as tall as the screen under the nav (600 to 980 px). The scene layer fills it; for now it is only a sky, a radial gradient of two custom properties that the 3D town will set later. The title, the ETA chip and the order panel are HTML on top, so every word stays sharp text over the canvas.

index.html — the hero
<section class="hero" id="tracker" aria-labelledby="tracker-title" tabindex="-1" data-hero>
  <div class="scene" data-scene role="img" aria-label="Map of your delivery">
  </div>

  <h1 class="title" id="tracker-title"><span class="t1">Delivery</span> <span class="t2">tracker</span></h1>

  <div class="eta" data-eta>
    <span class="eta-k"><i class="live-dot" aria-hidden="true"></i><span data-eta-k>Live ETA</span></span>
    <b class="eta-v" data-eta-v>12 min</b>
  </div>
  …
</section>
tracker.css — the hero, the title, the glass, the sky
.hero { --panel-w: 400px; --gut: 24px; position: relative; height: clamp(600px, calc(100svh - 64px), 980px); overflow: hidden; outline: none; isolation: isolate; }
.title { position: absolute; z-index: 3; top: 22px; left: 250px; right: calc(var(--panel-w) + var(--gut) * 2); text-align: center; line-height: .9; pointer-events: none; }
.title .t1 { display: block; font: 800 clamp(40px, 4.4vw, 66px)/.92 var(--display); color: var(--title); text-transform: uppercase; letter-spacing: .005em; text-shadow: 0 2px 18px rgba(251, 242, 227, .85); }
.title .t2 { display: block; font: 500 clamp(26px, 2.6vw, 38px)/1 var(--display); color: var(--ink-700); letter-spacing: .01em; margin-top: 2px; text-shadow: 0 2px 14px rgba(251, 242, 227, .9); }
/* glass surfaces */
.glass { background: rgba(255, 250, 241, .74); -webkit-backdrop-filter: blur(18px) saturate(1.35); backdrop-filter: blur(18px) saturate(1.35); box-shadow: 0 18px 50px -24px rgba(60, 30, 10, .45), inset 0 0 0 1px rgba(255, 255, 255, .55); }
/* the scene: 3D canvas (or the SVG fallback) covering the hero */
.scene { position: absolute; inset: 0; overflow: hidden; background: radial-gradient(120% 95% at 40% 25%, var(--sky-a, #fbf1e1), var(--sky-b, #f3e1c6)); contain: layout paint; transition: background .8s; }

The panel is frosted glass: cream at 74 % with an 18 px backdrop blur. It floats in the top right corner, 400 px wide, scrolls inside itself if it has to, and is a size container, so the courier card can reflow by the panel's width instead of the window's.

index.html — the order panel (start)
<aside class="panel glass" data-panel aria-label="Order status">
  <button class="sheet-grab" type="button" aria-expanded="false" aria-controls="panel-more"><span class="grab-bar" aria-hidden="true"></span><span class="grab-l">Order details</span></button>
  <p class="order-line"><span class="pill-live"><i class="live-dot" aria-hidden="true"></i>Live</span> Order <b data-order-no>#PT-48213</b><span class="sheet-eta" aria-hidden="true" data-sheet-eta>12 min</span></p>
  <h2 class="status-h" data-status-h>Marco is on the way</h2>
  <p class="status-sub" data-status-sub>2.4 km away · arriving around 19:01</p>

  <ol class="steps" data-steps aria-label="Order progress">
    <li data-step="baking"><span class="knob" aria-hidden="true"></span><span class="step-l">Baking</span></li>
    <li data-step="boxed"><span class="knob" aria-hidden="true"></span><span class="step-l">Boxing</span></li>
    <li data-step="onway" aria-current="step"><span class="knob" aria-hidden="true"></span><span class="step-l">On the way</span></li>
    <li data-step="delivered"><span class="knob" aria-hidden="true"></span><span class="step-l">Delivered</span></li>
    <li class="track" aria-hidden="true"><span class="fill" data-fill></span></li>
  </ol>
  …
</aside>
tracker.css — the floating panel
.panel { position: absolute; z-index: 4; top: var(--gut); right: var(--gut); max-height: calc(100% - var(--gut) * 2); width: var(--panel-w); display: flex; flex-direction: column; gap: 14px; padding: 22px 22px 20px; border-radius: 24px; overflow: auto; overscroll-behavior: contain; container-type: inline-size; scrollbar-width: thin; }
.panel-more { display: flex; flex-direction: column; gap: 14px; }

The progress is an ordered list of four steps on a four-column grid, with a fifth, hidden item for the track. The fill inside the track is scaled by one custom property, --share, so moving it never lays out the page. Each knob takes the colour --c, and the current one grows a little with a soft halo. In this stage the markup carries the showcase state (On the way is aria-current); stage 2 works it all out from t.

tracker.css — the four-knob progress
/* the four-knob tracker (concept: thick rounded track, round knobs, tomato in the kitchen, basil on the road) */
.steps { --c: var(--tomato-500); list-style: none; margin: 6px 0 0; padding: 0; position: relative; display: grid; grid-template-columns: repeat(4, 1fr); height: 64px; }
.steps .track { position: absolute; left: 11px; right: 11px; top: 11px; height: 12px; border-radius: 6px; background: var(--track); overflow: hidden; }
.steps .fill { position: absolute; inset: 0; border-radius: 6px; background: var(--c); transform-origin: 0 50%; transform: scaleX(var(--share, .6)); transition: background .5s; }
.steps li[data-step] { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.steps li[data-step]:first-child { align-items: flex-start; } .steps li[data-step]:nth-child(4) { align-items: flex-end; }
.knob { position: relative; width: 34px; height: 34px; border-radius: 50%; background: var(--track); transition: background .4s, transform .4s var(--ease); }
.knob::before { content: ''; position: absolute; inset: -5px; border-radius: 50%; background: var(--c); opacity: 0; transform: scale(.7); transition: opacity .4s, transform .4s var(--ease); }
.steps li[aria-current=step] .knob { background: var(--c); transform: scale(1.12); }
.steps li[aria-current=step] .knob::before { opacity: .22; transform: scale(1); }

Colours are tokens: primitives sampled from the concept (tomato, cream, basil, ink) and nudged until text passes 4.5:1, then semantic names the rules use.

tracker.css — tokens
:root {
  color-scheme: light;
  /* primitives (sampled from the concept, nudged for 4.5:1 text contrast) */
  --tomato-400: #e5502f; --tomato-500: #d4432a; --tomato-600: #c2311e; --brick-700: #8a2511;
  --cream-50: #fffaf1; --cream-100: #fbf2e3; --cream-200: #f6e8d2; --cream-300: #ecdcc4; --track: #e6d8be;
  --basil-400: #739855; --basil-500: #5b8a3c; --basil-600: #4e7d32; --basil-700: #46722c;
  --ink-900: #2d211b; --ink-700: #4a3a31; --ink-500: #6f5f52; --white: #fff;
  /* semantic */
  --bg: var(--cream-100); --surface: var(--cream-50); --surface-2: var(--cream-200); --line: #e7d6bd;
  --text: var(--ink-900); --text-2: var(--ink-700); --muted: var(--ink-500);
  --brand: var(--tomato-500); --on-brand: var(--white); --title: var(--brick-700);
  --ok: var(--basil-600); --ok-text: var(--basil-700); --focus: #1c64d8;
  --radius: 14px; --radius-lg: 20px; --shadow: 0 1px 0 rgba(90, 50, 20, .05), 0 10px 30px -14px rgba(90, 50, 20, .28);
  --display: 'Outfit', ui-rounded, system-ui, sans-serif; --ui: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --ease: cubic-bezier(.2, .7, .2, 1);
}

The order below the hero is drawn from data. An order holds its items, fees, courier and address. The page anchors the order's start to the wall clock, rounded to the minute, so every time on the page is a real time of day. Times go through one memoised Intl.DateTimeFormat: on a slow phone toLocaleTimeString cost about a millisecond a call.

tracker.js — the order
const ORDERS = {
  'PT-48213': {
    no: 'PT-48213', t0: SHOWCASE_T,
    items: [['1', 'Margherita Napoletana', '12" · fior di latte, basil', 14.5], ['1', 'Pepperoni Fire', '12" · hot honey', 16.0], ['1', 'Garlic knots', '6 pieces · parsley butter', 5.5], ['2', 'Lemon soda', '330 ml cans', 5.0]],
    fee: 2.49, tip: 3.0, courier: { name: 'Marco R.', first: 'Marco', rating: '4.9', ride: 'Red scooter · PT 12', tel: '+13125550134' },
    addr: 'Flat 3, 27 Basil Lane', note: 'Ring the green door. Buzzer 3.', paid: 'Card ending 4242 · tip included',
  },
};
tracker.js — the order clock
function anchorClock() { const now = Date.now(); S.placedAt = Math.floor((now - S.t * 60000) / 60000) * 60000; }
anchorClock();

// one formatter, one string per wall-clock minute (toLocaleTimeString per call cost ~1 ms on a throttled phone)
const fmt = new Intl.DateTimeFormat([], { hour: 'numeric', minute: '2-digit' }), clockMemo = new Map();
const clock = (min) => {
  const k = Math.floor((S.placedAt + min * 60000) / 60000);
  let v = clockMemo.get(k); if (v === undefined) { v = fmt.format(new Date(k * 60000)); clockMemo.set(k, v); }
  return v;
};
const money = (v) => '$' + v.toFixed(2);

Names and notes are set with textContent, so data can never inject HTML; only the numbers go into a template.

tracker.js — the order, drawn
function renderOrder() {
  const o = S.order;
  $$('[data-order-no]').forEach((n) => { n.textContent = '#' + o.no; });
  $('[data-placed]').textContent = clock(0);
  const items = $('[data-items]'); items.replaceChildren();
  let sub = 0;
  for (const [q, n, d, p] of o.items) {
    sub += p; const li = document.createElement('li');
    li.innerHTML = `<span class="q">${q}×</span><span><span class="n"></span><span class="d"></span></span><span class="p">${money(p)}</span>`;
    li.querySelector('.n').textContent = n; li.querySelector('.d').textContent = d; items.appendChild(li);
  }
  $('[data-totals]').innerHTML = `<dt>Subtotal</dt><dd>${money(sub)}</dd><dt>Delivery</dt><dd>${money(o.fee)}</dd><dt>Tip for ${o.courier.first}</dt><dd>${money(o.tip)}</dd><dt class="grand">Total</dt><dd class="grand">${money(sub + o.fee + o.tip)}</dd>`;
  $('[data-addr]').textContent = o.addr; $('[data-addr-note]').textContent = o.note; $('[data-paid]').textContent = o.paid;
  el.courierN.firstChild.textContent = o.courier.name + ' ';
  const r = el.courierN.querySelector('.rating'); r.textContent = '★ ' + o.courier.rating; r.setAttribute('aria-label', `rated ${o.courier.rating} out of 5`);
  $('[data-call]').href = 'tel:' + o.courier.tel;
  $('[data-call]').setAttribute('aria-label', `Call ${o.courier.first}`);
}

Under 900 px the hero is the whole screen under the nav and the panel becomes a bottom sheet: pinned to the bottom, rounded on top, showing the order line (with the ETA), the progress and, from stage 2, the demo bar. "Order details" is a real button with aria-expanded that opens the status, the timeline and the courier.

tracker.css — the phone: a bottom sheet
/* phone / small tablet: the scene fills the hero, the panel becomes a bottom sheet over it */
.hero { --gut: 12px; height: calc(100svh - 58px); min-height: 0; max-height: none; }
.title { left: 0; right: 0; top: 14px; }
.title .t1 { font-size: clamp(38px, 11vw, 56px); } .title .t2 { font-size: clamp(23px, 6.6vw, 34px); }
.eta { right: var(--gut); bottom: calc(var(--sheet-h, 300px) + 12px); padding: 6px 11px 7px; min-width: 96px; }
.eta-v { font-size: 22px; }
.panel { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 86%; padding: 6px 16px 14px; gap: 12px; border-radius: 22px 22px 0 0; overflow: hidden; transition: max-height .35s var(--ease); }
.panel.is-open { overflow: auto; }
.sheet-grab { display: flex; flex-direction: column; align-items: center; gap: 2px; width: 100%; min-height: 44px; margin: 0 0 -6px; padding: 6px 0 4px; border: 0; background: none; cursor: pointer; color: var(--muted); font: 600 12.5px/1 var(--ui); }
.grab-bar { width: 44px; height: 5px; border-radius: 3px; background: rgba(74, 58, 49, .3); }
.sheet-eta { display: inline; margin-left: auto; font: 700 20px/1 var(--ui); color: var(--ink-900); font-variant-numeric: tabular-nums; }
.eta { display: none; }
.order-line { flex-wrap: nowrap; font-size: 14px; }
.status-h, .status-sub { display: none; }
.panel.is-open .status-h, .panel.is-open .status-sub { display: block; }
.panel-more { display: none; }
.panel.is-open .panel-more { display: flex; }
tracker.js — Order details
function setSheet(open) {
  panel.classList.toggle('is-open', open); grab.setAttribute('aria-expanded', String(open));
  grab.querySelector('.grab-l').textContent = open ? 'Hide details' : 'Order details';
}
grab.addEventListener('click', () => setSheet(!panel.classList.contains('is-open')));
tracker.js — the phone menu
// phone menu
const menuBtn = $('.nav-menu'), menu = $('#mnav');
function setMenu(on) { menu.classList.toggle('is-open', on); menuBtn.setAttribute('aria-expanded', String(on)); menuBtn.setAttribute('aria-label', on ? 'Close menu' : 'Open menu'); }
menuBtn.addEventListener('click', () => { const on = !menu.classList.contains('is-open'); setMenu(on); if (on) menu.querySelector('a').focus(); });
menu.addEventListener('click', (e) => { if (e.target.closest('a')) setMenu(false); });
document.addEventListener('keydown', (e) => {
  if (e.key !== 'Escape') return;
  if (menu.classList.contains('is-open')) { setMenu(false); menuBtn.focus(); }
});
document.addEventListener('pointerdown', (e) => {
  if (menu.classList.contains('is-open') && !e.target.closest('#mnav, .nav-menu')) setMenu(false);
});
Stage 1 · the pageOpen ↗
The pizzatime page: a tomato nav, a cream hero with the title Delivery tracker, a Live ETA chip reading 12 min and a frosted glass panel on the right: Live, order PT-48213, Marco is on the way, the four-step progress at On the way, and Marco's courier card with a Call button.

2 · One number, t

timeline.js is the order's whole schedule in order-minutes: baking from 0, boxing at 9, on the way at 11, delivered at 31. A first visit opens at 19, mid-ride, 12 minutes out. Everything else in the tracker is a function of this file and t.

timeline.js — the schedule
// The order's timeline in order-minutes. Scene and UI derive everything from one number, t.
export const T = { boxAt: 9, rideAt: 11, doneAt: 31, end: 31 };
export const SHOWCASE_T = 19;            // mid-route, 12 min to go

export const STAGES = [
  { id: 'baking', label: 'Baking', short: 'Baking', from: 0, tile: 'the oven glows' },
  { id: 'boxed', label: 'Boxing', short: 'Boxed', from: T.boxAt, tile: 'the lid folds shut' },
  { id: 'onway', label: 'On the way', short: 'On the way', from: T.rideAt, tile: 'scooter rides the map' },
  { id: 'delivered', label: 'Delivered', short: 'Delivered', from: T.doneAt, tile: 'door opens, confetti' },
];

export function stageIndex(t) { let i = 0; for (let k = 0; k < STAGES.length; k++) if (t >= STAGES[k].from - 1e-9) i = k; return i; }

The scooter's share of the ride is linear in time, except for the first and last 3 %, which ease in and out so it pulls away and rolls to a stop. The progress bar puts the knobs at 0, 1/3, 2/3 and 1 and fills linearly inside each stage, so it moves steadily even though the stages are 9, 2 and 20 minutes long.

timeline.js — the ride
/** 0..1 share of the ride done (linear in distance, with a short pull-away and roll-in) */
export function rideShare(t) {
  const x = Math.min(1, Math.max(0, (t - T.rideAt) / (T.doneAt - T.rideAt)));
  const a = 0.03;                                  // ease the first and last 3%
  if (x < a) return (x * x) / (2 * a) / (1 - a);
  if (x > 1 - a) { const y = 1 - x; return 1 - (y * y) / (2 * a) / (1 - a); }
  return (x - a / 2) / (1 - a);
}
timeline.js — the progress bar
/** progress along the four-knob tracker: knobs sit at 0, 1/3, 2/3, 1 */
export function trackShare(t) {
  const i = stageIndex(t);
  if (i >= 3) return 1;
  const s = STAGES[i], n = STAGES[i + 1];
  return (i + (t - s.from) / (n.from - s.from)) / 3;
}

The state starts from the URL when it can (?t=, ?stage=, ?speed=, ?paused), so any moment can be linked. With reduced motion the demo clock does not start by itself.

tracker.js — state
const params = new URLSearchParams(location.search);
const qStage = STAGES.find((s) => s.id === params.get('stage'));
const S = {
  order: ORDERS['PT-48213'],
  t: params.has('t') ? Math.min(T.end, Math.max(0, +params.get('t') || 0)) : qStage ? stageT(qStage.id) : SHOWCASE_T,
  speed: +(params.get('speed') || store.get('speed') || 10),
  playing: !reduced && !params.has('paused'),
  stage: -1,
  placedAt: 0,
};
if (![1, 10, 60].includes(S.speed)) S.speed = 10;

Saved choices live under versioned keys (dv49a.). A store that never throws keeps the page working when storage is blocked, and keys of older versions are wiped on load.

tracker.js — storage
const KEY = 'dv49a.';
const store = {
  get(k) { try { return localStorage.getItem(KEY + k); } catch { return null; } },
  set(k, v) { try { localStorage.setItem(KEY + k, v); } catch { /* blocked storage: the page still works */ } },
};
try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv49') && !k.startsWith(KEY)) localStorage.removeItem(k); } } catch { /* ignore */ }

Each stage has its copy: a headline, a line under it, the sentence a screen reader hears, and the courier's status. The distance comes from the same ride share as the scooter: 4.6 km at the start.

tracker.js — copy per stage
function copy(i) {
  const c = S.order.courier, eta = clock(T.doneAt);
  const left = Math.ceil(minutesLeft(S.t));
  return [
    { h: 'Your pizza is in the oven', sub: `Baking at 430 °C · arriving around ${eta}`, ann: `Baking: your pizza is in the oven. Arriving around ${eta}.`, cs: `Waiting at the shop for your pizza` },
    { h: 'Boxed and ready to go', sub: `${c.first} is picking it up · arriving around ${eta}`, ann: `Boxed: your pizza is boxed and ready for ${c.first}.`, cs: `Picking up your order now` },
    { h: `${c.first} is on the way`, sub: `${kmLeft(S.t).toFixed(1)} km away · arriving around ${eta}`, ann: `On the way: ${c.first} picked up your order, about ${left} minutes away.`, cs: c.ride },
    { h: 'Delivered. Enjoy!', sub: `Handed over at ${eta} by ${c.first}`, ann: `Delivered: ${c.first} is at your door. Enjoy your pizza!`, cs: `Delivered at ${eta}` },
  ][i];
}
tracker.js — kilometres left
// the same line as the SVG scene's (stage 8 imports it from there)
/** km left for the copy (the ride is ~4.6 km) */
const kmLeft = (t) => +(4.6 * (1 - rideShare(t))).toFixed(1);

When the stage changes, the headline and the knobs change at once and the rest follows one frame later, so no single frame carries the whole job. On the road the knobs turn from tomato to basil (is-road).

tracker.js — a stage change
// A stage change is split over two frames: the progress + headline change at once; the secondary copy
// (timeline list, courier line, tiles, the hero's label) follows one frame later.
let later = null;
function renderStage(i, quiet) {
  const c = copy(i);
  el.statusH.textContent = c.h; el.statusSub.textContent = c.sub;
  $$('li[data-step]', el.steps).forEach((li, k) => {
    const done = k < i || (k === i && i === 3);
    if (li.classList.contains('is-done') !== done) li.classList.toggle('is-done', done);
    if (k === i) li.setAttribute('aria-current', 'step'); else if (li.hasAttribute('aria-current')) li.removeAttribute('aria-current');
  });
  if (el.steps.classList.contains('is-road') !== i >= 2) el.steps.classList.toggle('is-road', i >= 2);
  later = () => renderSecondary(i);
  if (!quiet) announce(c.ann);
}
tracker.js — one frame later
function renderSecondary(i) {
  const c = copy(i);
  el.courierS.textContent = c.cs;
  $$('.tile', el.tiles).forEach((b) => { const v = String(b.dataset.goto === STAGES[i].id); if (b.getAttribute('aria-current') !== v) b.setAttribute('aria-current', v); });
  sceneEl.setAttribute('aria-label', `Map of your delivery. ${c.h}. ${i < 3 ? `About ${Math.ceil(minutesLeft(S.t))} minutes to go.` : ''}`);
  renderEvents();
}

Announcements go to a polite live region. It is cleared and filled a frame later, 350 ms after the last call, so a scrub across three stages is read once.

tracker.js — announce
let annTimer = 0;
function announce(text) { clearTimeout(annTimer); annTimer = setTimeout(() => { el.announce.textContent = ''; requestAnimationFrame(() => { el.announce.textContent = text; }); }, 350); }

sync() runs every frame and writes to the page only what changed: the ETA, the line with the kilometres, the fill, the scrubber and its value text ("12:16 PM, On the way, 12 minutes to go"), the demo clock and the play button's three modes. While a thumb is dragged, the text refreshes at 10 Hz. Five minutes out, while playing, it says so once.

tracker.js — sync
function sync(force = false) {
  const i = stageIndex(S.t);
  if (i !== S.stage) { const first = S.stage === -1; S.stage = i; renderStage(i, first || force === 'quiet'); }
  const done = i === 3;
  // while the thumb is dragged, text (ETA, km, clock, value text) refreshes at 10 Hz
  const now = performance.now(), text = force || !scrubbing || now - ui.textAt > 100;
  if (text) ui.textAt = now;
  const etaK = done ? 'Delivered at' : 'Live ETA';
  const left = Math.ceil(minutesLeft(S.t) - 1e-6);
  const etaV = done ? clock(T.doneAt) : `${Math.max(1, left)} min`;
  if (etaK !== ui.etaK) { el.etaK.textContent = etaK; el.eta.classList.toggle('is-done', done); ui.etaK = etaK; }
  if (text && etaV !== ui.etaV) { el.etaV.textContent = etaV; el.sheetEta.textContent = done ? 'Delivered' : etaV; ui.etaV = etaV; }
  if (i === 2 && text) { const sub = copy(2).sub; if (sub !== ui.sub) { el.statusSub.textContent = sub; ui.sub = sub; } }
  // a single "5 minutes away" heads-up while playing forward
  if (i === 2 && left <= 5 && !ui.five && S.playing) { ui.five = true; announce(`${S.order.courier.first} is about 5 minutes away.`); }
  if (left > 5) ui.five = false;
  const share = trackShare(S.t);
  if (Math.abs(share - ui.share) > 0.0015) { el.fill.style.setProperty('--share', share.toFixed(4)); ui.share = share; }
  if (scrubbing) { const pv = (S.t / T.end * 100).toFixed(1) + '%'; if (pv !== ui.p) { el.scrub.style.setProperty('--p', pv); ui.p = pv; } }
  if (text && (Math.abs(S.t - ui.scrub) >= 0.05 || force)) {
    if (!scrubbing) el.scrub.value = S.t.toFixed(2);
    ui.p = (S.t / T.end * 100).toFixed(1) + '%'; el.scrub.style.setProperty('--p', ui.p); ui.scrub = S.t;
    el.scrub.setAttribute('aria-valuetext', `${clock(S.t)}, ${STAGES[i].label}${done ? '' : `, ${Math.max(1, left)} minutes to go`}`);
  }
  const ck = clock(S.t); if (text && ck !== ui.clock) { el.clock.textContent = ck; ui.clock = ck; }
  const mode = S.t >= T.end - 1e-6 && !S.playing ? 'replay' : S.playing ? 'pause' : 'play';
  if (mode !== ui.play) {
    el.play.dataset.mode = mode === 'pause' ? '' : mode;
    el.play.setAttribute('aria-label', mode === 'pause' ? 'Pause the demo' : mode === 'play' ? 'Play the demo' : 'Replay the order from the oven');
    ui.play = mode;
  }
}

The loop moves t by real time times the speed: at 10× ten order-minutes pass in a real minute, at 60× one a second. A frame never counts for more than 0.1 s, so a tab coming back from the background does not jump.

tracker.js — the loop
function frame(now) {
  if (later) { const f = later; later = null; f(); }
  const dt = Math.min(0.1, Math.max(0, (now - last) / 1000)); last = now;
  if (S.playing) {
    S.t = Math.min(T.end, S.t + dt * S.speed / 60);
    if (S.t >= T.end) S.playing = false;
  }
  sync();
  raf = requestAnimationFrame(frame);
}
tracker.js — set t
function setT(t, { keepPlaying = true } = {}) {
  S.t = Math.min(T.end, Math.max(0, t));
  if (!keepPlaying) S.playing = false;
  if (S.t >= T.end) S.playing = false;
}

The demo bar is the clock's remote: play, pause and replay in one button, a range from 0 to 31 with dots at the stage changes, the clock and three speeds as a radio group. The track's filled part is a gradient stop moved by --p.

index.html — the demo bar
<div class="demo glass" role="group" aria-labelledby="demo-label">
  <span class="demo-tag" id="demo-label">Demo time</span>
  <button class="demo-play" type="button" data-action="play" aria-label="Pause the demo">
    <svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="3.6" height="14" rx="1.2"/><rect x="13.9" y="5" width="3.6" height="14" rx="1.2"/></svg>
    <svg class="i-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.2v13.6a.8.8 0 0 0 1.2.7l10.6-6.8a.8.8 0 0 0 0-1.4L9.2 4.5A.8.8 0 0 0 8 5.2Z"/></svg>
    <svg class="i-replay" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3M4.5 4v4.5H9" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
  </button>
  <div class="scrub">
    <label class="sr-only" for="scrub">Order time</label>
    <input id="scrub" type="range" min="0" max="31" step="0.05" value="19">
    <div class="scrub-marks" aria-hidden="true"><i style="--at:0"></i><i style="--at:.2903"></i><i style="--at:.3548"></i><i style="--at:1"></i></div>
  </div>
  <span class="demo-clock" data-clock aria-hidden="true">18:49</span>
  <div class="speed" role="radiogroup" aria-label="Demo speed">
    <label><input type="radio" name="speed" value="1"><span>1×</span></label>
    <label><input type="radio" name="speed" value="10" checked><span>10×</span></label>
    <label><input type="radio" name="speed" value="60"><span>60×</span></label>
  </div>
</div>
tracker.js — the controls
el.play.addEventListener('click', () => {
  if (S.t >= T.end - 1e-6) { setT(0); S.playing = true; announce('Replaying the order from the oven.'); }
  else S.playing = !S.playing;
  sync();
});
el.scrub.addEventListener('input', () => { setT(+el.scrub.value); });
el.scrub.addEventListener('pointerdown', () => { scrubbing = true; });
for (const ev of ['pointerup', 'pointercancel', 'change', 'blur']) el.scrub.addEventListener(ev, () => { scrubbing = false; });
$$('input[name=speed]').forEach((r) => {
  r.checked = +r.value === S.speed;
  r.addEventListener('change', () => { S.speed = +r.value; store.set('speed', String(S.speed)); });
});
tracker.css — the scrubber
.scrub input { width: 100%; height: 44px; margin: 0; background: transparent; -webkit-appearance: none; appearance: none; cursor: pointer; position: relative; z-index: 1; --p: 61%; }
.scrub input::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: linear-gradient(90deg, var(--ink-700) var(--p), var(--track) var(--p)); }
.scrub input::-moz-range-track { height: 6px; border-radius: 3px; background: var(--track); }
.scrub input::-moz-range-progress { height: 6px; border-radius: 3px; background: var(--ink-700); }
tracker.css — the stage marks
.scrub-marks { position: absolute; left: 10px; right: 10px; top: 50%; height: 0; pointer-events: none; z-index: 2; }
.scrub-marks i { position: absolute; left: calc(var(--at) * 100%); top: -2px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--white); }
tracker.css — tomato in the kitchen, basil on the road
.steps { --c: var(--tomato-500); list-style: none; margin: 6px 0 0; padding: 0; position: relative; display: grid; grid-template-columns: repeat(4, 1fr); height: 64px; }
.steps.is-road { --c: var(--basil-500); }
.steps .track { position: absolute; left: 11px; right: 11px; top: 11px; height: 12px; border-radius: 6px; background: var(--track); overflow: hidden; }
.steps .fill { position: absolute; inset: 0; border-radius: 6px; background: var(--c); transform-origin: 0 50%; transform: scaleX(var(--share, .6)); transition: background .5s; }

The stage tiles under the hero jump to their moment. Their pictures are renders of the 3D town from the next stages. A tile reads the hero's position before it changes anything, then scrolls the hero back into view if it is off screen.

tracker.js — the tiles
el.tiles.addEventListener('click', (e) => {
  const b = e.target.closest('[data-goto]'); if (!b) return;
  const r = sceneEl.getBoundingClientRect();            // read layout before anything changes (no forced reflow)
  goto(b.dataset.goto);
  if (r.top < 56 || r.bottom > innerHeight) $('#tracker').scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' });
});
function goto(id) {
  setT(stageT(id));
  if (id !== 'delivered' && !reduced) S.playing = true;
}

On a phone the demo bar moves into the sheet, and the sheet's height is kept in --sheet-h for what sits above it.

tracker.js — the demo bar on a phone
function placeDemo() {
  const phone = phoneMQ.matches;
  if (phone && demoEl.parentElement !== panel) panel.insertBefore(demoEl, $('.panel-more'));
  if (!phone && demoEl.parentElement !== demoHome) demoHome.insertBefore(demoEl, demoNext);
  if (!phone) setSheet(false);
  requestAnimationFrame(() => { hero.style.setProperty('--sheet-h', phone ? panel.getBoundingClientRect().height + 'px' : '0px'); });
}
Stage 2 · one numberOpen ↗
The same hero, still an empty cream sky, now live: the panel reads Marco is on the way, 2.8 km away, with times in the timeline list, the progress knobs in basil green, and a demo bar along the bottom with the play button, a scrubber at On the way, the clock and the 60× speed picked.

3 · The 3D town

three.js comes in through an import map, and only when the browser has WebGL2: the module is imported dynamically, so a browser without it never downloads the library. Until stage 8 such a browser keeps the sky.

index.html — the import map
<script type="importmap">{"imports":{"three":"./vendor/three/build/three.module.js","three/addons/":"./vendor/three/jsm/"}}</script>
tracker.js — load the town
function webgl2() { try { const c = document.createElement('canvas'); return !!c.getContext('webgl2'); } catch { return false; } }
let scene = null, sceneKind = 'none';
if (webgl2()) {
  try { const m = await import('./scene3d.js');
    scene = m.createScene3D(sceneEl, { reduced, phone: matchMedia('(max-width: 900px)').matches }); sceneKind = '3d'; }
  catch (e) { scene = null; sceneEl.querySelector('canvas.gl')?.remove(); }
}
if (!scene) scene = { update() {}, render() {} };   // no WebGL2: the hero keeps its sky (stage 8 draws the SVG town)
document.documentElement.dataset.sceneKind = sceneKind;

The panel covers part of the hero. The tracker tells the scene which part is free (left of the panel on a desk; between the title and the sheet on a phone), and the camera centres every shot there.

tracker.js — the free part of the hero
// the part of the hero the panel does not cover: the 3D camera centres its shot there
sceneEl.__free = () => {
  const h = hero.getBoundingClientRect(), p = panel.getBoundingClientRect();
  if (phoneMQ.matches) { const top = $('.title').getBoundingClientRect().bottom - h.top; return { x: 0, y: top, w: h.width, h: Math.max(120, p.top - h.top - top - 50) }; }
  return { x: 0, y: 0, w: Math.max(200, p.left - h.left - 12), h: h.height };
};

The town is clay: one MeshStandardMaterial with vertex colours for everything. A part is painted (its colour written into every vertex), placed with at() and pushed into a builder, which merges all of them into a single geometry. The whole static town (board, blocks, park, pizzeria, house, twelve homes, lamps, tree trunks) is two meshes: the clay and an unlit one for windows and bulbs. That is two draw calls.

scene3d.js — paint, place, merge
function paint(geo, color) {
  const g = geo.index ? geo.toNonIndexed() : geo;
  const c = col(color), n = g.attributes.position.count, a = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) { a[i * 3] = c.r; a[i * 3 + 1] = c.g; a[i * 3 + 2] = c.b; }
  g.setAttribute('color', new THREE.BufferAttribute(a, 3));
  for (const k of Object.keys(g.attributes)) if (!['position', 'normal', 'color'].includes(k)) g.deleteAttribute(k);
  return g;
}
const M4 = new THREE.Matrix4(), Q = new THREE.Quaternion(), E = new THREE.Euler(), V = new THREE.Vector3(), S1 = new THREE.Vector3(1, 1, 1);
function at(geo, x, y, z, ry = 0, rx = 0, rz = 0, s = 1) {
  E.set(rx, ry, rz); Q.setFromEuler(E); M4.compose(V.set(x, y, z), Q, S1.set(s, s, s)); geo.applyMatrix4(M4); return geo;
}
const rbox = (w, h, d, r = 0.08, seg = 2) => new RoundedBoxGeometry(w, h, d, seg, Math.min(r, w / 2, h / 2, d / 2) * 0.999);
const cyl = (rt, rb, h, seg = 18) => new THREE.CylinderGeometry(rt, rb, h, seg);
const sph = (r, ws = 14, hs = 10, ts = 0, tl = Math.PI) => new THREE.SphereGeometry(r, ws, hs, 0, Math.PI * 2, ts, tl);
class Builder {
  constructor() { this.parts = []; }
  add(geo, color) { this.parts.push(paint(geo, color)); return this; }
  build() { const g = mergeGeometries(this.parts, false); g.computeBoundingSphere(); return g; }
}
scene3d.js — the board and the blocks
// the board: a thick toy slab with a darker rim
B.add(at(rbox(HALF * 2, 1.4, HALF * 2, 0.5, 3), 0, -0.72, 0), P.board);
B.add(at(rbox(HALF * 2 + 0.3, 0.5, HALF * 2 + 0.3, 0.24, 2), 0, -1.25, 0), P.boardEdge);
// blocks (streets are the board top, slightly lighter)
B.add(at(new THREE.BoxGeometry(HALF * 2 - 0.4, 0.02, HALF * 2 - 0.4), 0, 0.0, 0), P.street);
const PARKS = new Set(['2,2', '1,3', '4,3', '0,1']), POND = '2,2';
BX.forEach(([x0, x1], i) => BZ.forEach(([z0, z1], j) => {
  const key = `${i},${j}`, w = x1 - x0, d = z1 - z0, cx = (x0 + x1) / 2, cz = (z0 + z1) / 2;
  const green = PARKS.has(key);
  B.add(at(rbox(w, 0.3, d, 0.12, 2), cx, 0.08, cz), green ? P.grass : ((i + j) % 2 ? P.block : P.block2));
  if (key === POND) { B.add(at(rbox(w * 0.56, 0.06, d * 0.5, 0.03, 2), cx - w * 0.12, 0.22, cz + d * 0.14), '#5fb3d3'); }
  if (!green && (i + j) % 3 === 0) B.add(at(rbox(w * 0.42, 0.04, d - 0.8, 0.02, 1), cx + w * 0.22, 0.24, cz), '#e8d6ba');
}));
scene3d.js — the pizzeria
// ---- pizzeria (block 1,2: x -11..-4, z -1..5)
const pz = new Builder();
B.add(at(rbox(3.6, 3.0, 5.6, 0.14, 2), -9.0, 1.7, 2.0), P.tomato);              // building
B.add(at(rbox(3.9, 0.35, 5.9, 0.12, 2), -9.0, 3.25, 2.0), P.cream);             // roof slab
B.add(at(rbox(0.9, 0.9, 1.6, 0.1, 2), -9.6, 3.8, 0.6), P.brickDark);            // roof unit
B.add(at(cyl(0.22, 0.22, 1.4, 12), -9.4, 4.0, 3.6), '#6e4a3a');                 // flue
for (let i = 0; i < 9; i++) {                                                   // striped awning on the +x face
  const z = -0.3 + i * 0.6;
  B.add(at(new THREE.BoxGeometry(0.9, 0.06, 0.6), -6.85, 2.45, z + 0.3, 0, 0, -0.42), i % 2 ? P.cream : P.tomatoDeep);
}
B.add(at(rbox(0.12, 1.9, 1.1, 0.04), -7.18, 1.15, 4.0), P.wood);                // door
Em.add(at(new THREE.BoxGeometry(0.06, 1.2, 2.4), -7.16, 1.35, 1.6), '#ffd27a');  // window (lit at night)
B.add(at(new THREE.BoxGeometry(0.1, 1.36, 2.56), -7.2, 1.35, 1.6), P.cream);
scene3d.js — the town in two meshes
const town = buildTown();
const townMesh = new THREE.Mesh(town.town, CLAY()); townMesh.castShadow = townMesh.receiveShadow = true; scene.add(townMesh);
const emMat = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false }); const emMesh = new THREE.Mesh(town.emissive, emMat); scene.add(emMesh);

The oven is its own mesh with a brick texture drawn on a canvas. Its mouth is an arch with a radial fire gradient on an unlit material, bright enough to glow through the tone mapping.

scene3d.js — the oven's mouth
// the mouth: an emissive arch (fire glow) behind which the pizza sits
const mouthShape = new THREE.Shape(); mouthShape.moveTo(-0.55, 0); mouthShape.lineTo(0.55, 0); mouthShape.lineTo(0.55, 0.5); mouthShape.absarc(0, 0.5, 0.55, 0, Math.PI, false); mouthShape.lineTo(-0.55, 0);
const fireTex = canvasTex(128, 128, (c) => { const r = c.createRadialGradient(64, 92, 4, 64, 80, 80); r.addColorStop(0, '#fff3c4'); r.addColorStop(0.3, '#ffc65a'); r.addColorStop(0.7, '#f07f2a'); r.addColorStop(1, '#7a2412'); c.fillStyle = r; c.fillRect(0, 0, 128, 128); });
const mouthGeo = new THREE.ShapeGeometry(mouthShape, 14); mouthGeo.translate(0, 1.32, 0.93);
const uvs = mouthGeo.attributes.position; const uv = new Float32Array(uvs.count * 2);
for (let i = 0; i < uvs.count; i++) { uv[i * 2] = (uvs.getX(i) + 0.55) / 1.1; uv[i * 2 + 1] = (uvs.getY(i) - 1.32) / 1.05; }
mouthGeo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
const mouthMat = new THREE.MeshBasicMaterial({ map: fireTex, toneMapped: false, color: new THREE.Color(1.6, 1.25, 1.0) });

The route runs from the pizzeria along two streets to the house, with rounded corners. It is a centripetal Catmull-Rom spline with 600 arc-length divisions, so a point at 40 % of u is 40 % of the way. The road is a ribbon mesh laid along it.

scene3d.js — the route
// the route: pizzeria → along z=6 → north on x=-3 → east on z=-2 → the house; corners rounded
const ROUTE = (() => {
  const R = 2.2, pts = [];
  const line = (a, b, n) => { for (let i = 0; i < n; i++) { const u = i / n; pts.push(new THREE.Vector3(a[0] + (b[0] - a[0]) * u, 0, a[1] + (b[1] - a[1]) * u)); } };
  const arc = (c, a0, a1, n) => { for (let i = 0; i < n; i++) { const a = a0 + (a1 - a0) * i / n; pts.push(new THREE.Vector3(c[0] + Math.cos(a) * R, 0, c[1] + Math.sin(a) * R)); } };
  line([-7.4, 6], [-3 - R, 6], 12);
  arc([-3 - R, 6 - R], Math.PI / 2, 0, 12);
  line([-3, 6 - R], [-3, -2 + R], 14);
  arc([-3 + R, -2 + R], Math.PI, Math.PI * 1.5, 12);
  line([-3 + R, -2], [8.2, -2], 24);
  pts.push(new THREE.Vector3(8.2, 0, -2));
  const curve = new THREE.CatmullRomCurve3(pts, false, 'centripetal');
  curve.arcLengthDivisions = 600;
  return curve;
})();
scene3d.js — a ribbon along the route
const ribbon = (width, y, samples = 260) => {
  const pos = [], uvA = [], idx = [];
  for (let i = 0; i <= samples; i++) {
    const u = i / samples, p = ROUTE.getPointAt(u), tg = ROUTE.getTangentAt(u), nx = -tg.z, nz = tg.x;
    pos.push(p.x + nx * width / 2, y, p.z + nz * width / 2, p.x - nx * width / 2, y, p.z - nz * width / 2);
    uvA.push(u, 0, u, 1);
    if (i < samples) { const a = i * 2; idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); }
  }
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uvA, 2)); g.setIndex(idx); g.computeVertexNormals(); return g;
};
const road = new THREE.Mesh(ribbon(1.7, 0.03), new THREE.MeshStandardMaterial({ color: P.asphalt, roughness: 0.9 })); road.receiveShadow = true; scene.add(road);

The renderer skips its own antialiasing (the composer does it), uses neutral tone mapping, and renders the shadow map only when asked: the sun never moves during a frame, so the shadows are drawn once and again only when the light changes. A 24° lens from far away flattens the perspective like a photo of a model.

scene3d.js — renderer, scene, camera
const canvas = document.createElement('canvas'); canvas.className = 'gl';
canvas.setAttribute('aria-hidden', 'true'); root.prepend(canvas);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' });
const tier = { dprCap: q.dpr ?? 1.5, budget: 2.8e6, adaptCap: 9, shadow: phone ? 1024 : 2048, particles: phone ? 0.5 : 1, tilt: q.tilt ?? true, msaa: q.msaa ?? (phone ? 2 : 4), half: q.half ?? false };
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.shadowMap.autoUpdate = false;
const scene = new THREE.Scene();
const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose();
const camera = new THREE.PerspectiveCamera(24, 1, 0.5, 200);
scene3d.js — sky light and sun
// lights
const hemi = new THREE.HemisphereLight('#dfeeff', '#f0dcc0', 1.1); scene.add(hemi);
const sun = new THREE.DirectionalLight('#fff4e2', 2.6); sun.castShadow = true;
Object.assign(sun.shadow.camera, { left: -20, right: 20, top: 20, bottom: -20, near: 1, far: 70 });
sun.shadow.mapSize.set(tier.shadow, tier.shadow); sun.shadow.radius = 5; sun.shadow.bias = -0.0006; sun.shadow.normalBias = 0.03;
scene.add(sun, sun.target);
scene3d.js — the light, set once
function applyTod(name, instant = true) {
  const p = TOD[name];
  sun.color.set(p.sun); sun.intensity = p.sunI; sun.position.set(...p.sunDir); hemi.color.set(p.hemiSky); hemi.groundColor.set(p.hemiGround); hemi.intensity = p.hemiI;
  scene.environmentIntensity = p.env; renderer.toneMappingExposure = p.exposure;
  emMat.color.setScalar(0.35 + 0.65 * Math.max(p.night, 0.25) + (p.night > 0.5 ? 0.6 : 0));
  root.style.setProperty('--sky-a', p.bg[0]); root.style.setProperty('--sky-b', p.bg[1]);
  renderer.shadowMap.needsUpdate = true;
}

The tree crowns are one instanced mesh, so 25 trees cost one draw call, and swaying them is 25 matrices. Two cars loop the outer streets and shrink to nothing at the ends; soft blobs under them are contact shadows, so moving things never need the shadow map.

scene3d.js — trees
// trees (instanced crowns)
const crownGeo = paint(new THREE.IcosahedronGeometry(0.75, 1), '#ffffff'); crownGeo.computeVertexNormals();
const crowns = new THREE.InstancedMesh(crownGeo, CLAY(), town.trees.length); crowns.castShadow = true; crowns.receiveShadow = true;
const leafA = col(P.leaf), leafB = col(P.leafLight);
town.trees.forEach((tr, i) => crowns.setColorAt(i, leafA.clone().lerp(leafB, (i * 0.37) % 1)));
scene.add(crowns);
const treeM = new THREE.Object3D();
function swayTrees(time) {
  town.trees.forEach(([x, y, z, s], i) => {
    treeM.position.set(x, y, z); treeM.scale.set(s, s * 1.08, s);
    treeM.rotation.set(Math.sin(time * 1.3 + i) * 0.05, i, Math.cos(time * 1.1 + i * 1.7) * 0.05);
    treeM.updateMatrix(); crowns.setMatrixAt(i, treeM.matrix);
  });
  crowns.instanceMatrix.needsUpdate = true;
}
scene3d.js — cars
cars.forEach((c, i) => {
  const cp = CAR_PATHS[i], k = (((clock * 0.045 * (1 + i * 0.2)) + cp.off) % 1), s = -HALF + 0.8 + k * (HALF * 2 - 1.6), sDir = cp.dir > 0 ? s : -s;
  const edge = Math.min(1, Math.min(k, 1 - k) * 14);
  if (cp.axis === 'z') { c.position.set(cp.c, 0.02, sDir); c.rotation.y = cp.dir > 0 ? -Math.PI / 2 : Math.PI / 2; }
  else { c.position.set(sDir, 0.02, cp.c); c.rotation.y = cp.dir > 0 ? 0 : Math.PI; }
  c.scale.setScalar(Math.max(0.001, edge));
  if (i < 2) blob(2 + i, c.position.x, c.position.z, 2.0 * edge, 1.2 * edge, c.rotation.y, true);
});

The frame goes through a composer: a half-float target with 4× MSAA (2× on phones), the scene, two tilt-shift passes and the output pass. Tilt-shift is a separable blur whose radius grows with the distance from a focus band, which is what makes a real town look like a toy.

scene3d.js — tilt-shift
const TiltShift = (horizontal) => ({
  uniforms: { tDiffuse: { value: null }, uDelta: { value: new THREE.Vector2(horizontal ? 1 : 0, horizontal ? 0 : 1) }, uRes: { value: new THREE.Vector2(1, 1) }, uFocus: { value: 0.5 }, uBand: { value: 0.18 }, uAmount: { value: 2.6 } },
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader: `uniform sampler2D tDiffuse; uniform vec2 uDelta, uRes; uniform float uFocus, uBand, uAmount; varying vec2 vUv;
    void main(){
      float d = max(0.0, abs(vUv.y - uFocus) - uBand);
      float r = clamp(d * 4.0, 0.0, 1.0) * uAmount;
      vec2 st = uDelta / uRes * r;
      vec4 c = texture2D(tDiffuse, vUv) * 0.2270;
      c += (texture2D(tDiffuse, vUv + st * 1.384) + texture2D(tDiffuse, vUv - st * 1.384)) * 0.3162;
      c += (texture2D(tDiffuse, vUv + st * 3.230) + texture2D(tDiffuse, vUv - st * 3.230)) * 0.0703;
      gl_FragColor = c;
    }`,
});
scene3d.js — the composer
function makeComposer() {
  const rt = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: tier.msaa });
  composer = new EffectComposer(renderer, rt);
  composer.addPass(new RenderPass(scene, camera));
  tiltH = new ShaderPass(TiltShift(true)); tiltV = new ShaderPass(TiltShift(false));
  composer.addPass(tiltH); composer.addPass(tiltV); composer.addPass(new OutputPass());
}
makeComposer();

A shot is a target, a width of the world that must fit, an angle around and an angle up. The camera backs off until that width fits the free part of the hero, and a view offset moves the picture's centre there.

scene3d.js — place the camera
function placeCamera() {
  // the frustum spans the whole canvas; the shot must fit the free sub-rect (safeW × safeH) of it
  const fov = camera.fov * Math.PI / 180, tv = Math.tan(fov / 2), th = tv * view.w / view.h;
  const dist = Math.max(camState.w / 2 / (th * view.safeW / view.w), camState.w * 0.62 / 2 / (tv * view.safeH / view.h));
  const az = camState.az * Math.PI / 180, el = camState.el * Math.PI / 180;
  camera.position.set(camState.t.x + dist * Math.cos(el) * Math.sin(az), camState.t.y + dist * Math.sin(el), camState.t.z + dist * Math.cos(el) * Math.cos(az));
  camera.lookAt(camState.t);
  camera.near = Math.max(0.5, dist - 40); camera.far = dist + 60; camera.updateProjectionMatrix();
  if (tiltH) { tiltH.uniforms.uFocus.value = tiltV.uniforms.uFocus.value = camState.focus; }
}

The canvas never exceeds about 2.8 megapixels: a pixel ratio of 1.5 at 1440 × 900, about 1.05 on a 2560 screen. The text is HTML, so it stays sharp whatever the canvas does.

scene3d.js — size and pixel budget
function resize() {
  const r = root.getBoundingClientRect(); const w = Math.max(1, r.width), h = Math.max(1, r.height);
  // pixel budget: the canvas never exceeds ~2.8 MP (DPR 1.5 at 1440×900, ~1.05 on a 2560 screen); text is HTML, so it stays sharp
  const pr = Math.min(devicePixelRatio || 1, tier.dprCap, tier.adaptCap, Math.max(1, Math.sqrt(tier.budget / (w * h))));
  if (Math.abs(renderer.getPixelRatio() - pr) > 0.01) { renderer.setPixelRatio(pr); composer.setPixelRatio?.(pr); }
  renderer.setSize(w, h, false); composer.setSize(w, h);
  const px = renderer.getPixelRatio();
  tiltH.uniforms.uRes.value.set(w * px, h * px); tiltV.uniforms.uRes.value.set(w * px, h * px);
  view.w = w; view.h = h; camera.aspect = w / h;
  // the floating panel covers part of the hero: centre the action in the free area with a view offset
  const free = root.__free ? root.__free() : { x: 0, y: 0, w, h };
  view.safeW = free.w; view.safeH = free.h;
  const cx = free.x + free.w / 2, cy = free.y + free.h / 2;
  camera.setViewOffset(w, h, (w / 2 - cx), (h / 2 - cy), w, h);
  placeCamera();
}
new ResizeObserver(resize).observe(root);
Stage 3 · the townOpen ↗
A toy 3D pizza town seen from above at an angle, tilt-shifted: clay blocks, a red-roofed pizzeria with a brick oven, a grey road with streetlights, a pond in a park, houses with red roofs and round trees, the glass panel on the right.

4 · The kitchen

The pizza is one merged mesh (crust, rim, sauce, cheese, pepperoni, basil). The box is a tray and a lid whose hinge is the back edge: the lid hangs on a pivot group, so folding it is one rotation. The sticker is a canvas-drawn roundel on the lid.

scene3d.js — the pizza
function pizzaGeo(r = 0.5) {
  const B = new Builder();
  B.add(at(cyl(r, r * 1.02, 0.07, 28), 0, 0.035, 0), P.crust);
  B.add(at(new THREE.TorusGeometry(r * 0.93, r * 0.09, 8, 28).rotateX(Math.PI / 2), 0, 0.07, 0), '#e8ac62');
  B.add(at(cyl(r * 0.86, r * 0.86, 0.02, 28), 0, 0.075, 0), P.sauce);
  for (let i = 0; i < 9; i++) { const a = i / 9 * Math.PI * 2 + 0.3, d = r * 0.4; B.add(at(cyl(r * 0.3, r * 0.3, 0.02, 14), Math.cos(a) * d, 0.088, Math.sin(a) * d), P.cheese); }
  B.add(at(cyl(r * 0.42, r * 0.42, 0.02, 16), 0, 0.09, 0), P.cheese);
  for (const [u, v] of [[0.5, 0.2], [-0.15, 0.55], [-0.5, -0.1], [0.1, -0.5], [0.2, 0.05], [-0.3, 0.25], [0.48, -0.32], [-0.2, -0.3]]) B.add(at(cyl(r * 0.12, r * 0.12, 0.03, 12), u * r, 0.1, v * r), P.pep);
  for (const [u, v, a] of [[0.3, 0.45, 0.4], [-0.45, 0.35, 1.2], [-0.05, -0.15, 2.1], [0.35, -0.55, 0.9]]) B.add(at(new THREE.BoxGeometry(r * 0.2, 0.012, r * 0.1), u * r, 0.115, v * r, a), '#4b8a37');
  return B.build();
}
scene3d.js — the lid
// lid geometry: hinge along the back edge (local z = 0), the lid extends +z
const lid = new Builder();
lid.add(at(new THREE.BoxGeometry(w + 0.02, 0.03, w + 0.02), 0, 0, w / 2), P.kraft);
lid.add(at(new THREE.BoxGeometry(w + 0.02, 0.18, 0.03), 0, -0.09, w + 0.005), '#b98a54');
return { tray: tray.build(), lid: lid.build(), w, h };
scene3d.js — pizza, box, sticker
const bx = boxParts();
const boxG = new THREE.Group(); boxG.position.copy(TABLE).setY(0.99); boxG.rotation.y = Math.PI / 4; scene.add(boxG);
const trayMesh = new THREE.Mesh(bx.tray, CLAY()); boxG.add(trayMesh);
const lidPivot = new THREE.Group(); lidPivot.position.set(0, bx.h, -bx.w / 2); boxG.add(lidPivot);
const lidMesh = new THREE.Mesh(bx.lid, CLAY()); lidPivot.add(lidMesh);
const sticker = new THREE.Mesh(new THREE.CircleGeometry(0.26, 32), new THREE.MeshStandardMaterial({ map: stickerTex(), roughness: 0.6, transparent: true }));
sticker.rotation.x = -Math.PI / 2; sticker.position.set(0, 0.02, bx.w / 2); lidPivot.add(sticker);

The fire is a point light just inside the mouth, plus flames: 26 additive points rising from the oven's floor.

scene3d.js — the fire
const ovenFwd = new THREE.Vector3(Math.sin(Math.PI / 4), 0, Math.cos(Math.PI / 4));
const mouthPos = OVEN.clone().setY(1.75).addScaledVector(ovenFwd, 1.5);
fire.position.copy(mouthPos).addScaledVector(ovenFwd, 0.6).setY(1.95);

Moments that play out after a stage change are transitions: a value from 0 to 1 that follows its target at its own speed (2.2 s for boxing). It jumps when the order time jumps (a scrub, a link), when asked to snap, and with reduced motion; going backwards always jumps.

scene3d.js — a transition
const A = { boxing: { v: 0, dur: 2.2 } };
scene3d.js — follow t
const big = lastT === null || Math.abs(t - lastT) > 0.75, jump = snap || reduced || big; lastT = t;
const target = { boxing: t >= T.boxAt ? 1 : 0 };
for (const k in A) { const a = A[k]; if (jump || target[k] < a.v) a.v = target[k]; else a.v = Math.min(target[k], a.v + dt / a.dur); }
const b = A.boxing.v;

The boxing transition is cut into windows. From 2 % to 40 % the pizza flies out of the oven into the box on an arc 1.4 high, turning a turn and a half; from 45 % to 82 % the lid folds shut; from 84 % the sticker slaps on from 2.6 times its size. While baking, seven cheese bubbles grow and pop and the fire flickers.

scene3d.js — the kitchen's moments
// oven: fire flicker (stronger while baking), pizza in the oven → peel → box
const hot = t < T.boxAt ? 1 : 0.55;
const flick = 0.8 + 0.2 * Math.sin(clock * 13.1) * Math.sin(clock * 7.3 + 1) + 0.08 * Math.sin(clock * 31);
fire.intensity = 5 * hot * (reduced ? 0.9 : flick);
oven.mouthMat.color.setRGB(1.3 + 0.5 * hot * flick, 1.0 + 0.3 * hot * flick, 0.85);
const inOven = mouthPos.clone().addScaledVector(ovenFwd, -0.25).setY(1.63);
const inBox = TABLE.clone().setY(1.03);
const fly = easeIO(seg(b, 0.02, 0.4));
pizzaMesh.position.lerpVectors(inOven, inBox, fly); pizzaMesh.position.y += Math.sin(fly * Math.PI) * 1.4;
pizzaMesh.rotation.y = fly * Math.PI * 1.5;
// bubbling cheese while baking
bubbles.visible = t < T.boxAt && !reduced;
if (bubbles.visible) for (let i = 0; i < 7; i++) {
  const a = i * 2.4, r = 0.12 + (i % 3) * 0.08, ph = (clock * (0.7 + i * 0.13) + i * 0.37) % 1;
  dummy.position.set(inOven.x + Math.cos(a) * r, inOven.y + 0.1, inOven.z + Math.sin(a) * r); dummy.scale.setScalar(Math.sin(ph * Math.PI) * 1.2 + 0.01); dummy.updateMatrix(); bubbles.setMatrixAt(i, dummy.matrix);
}
bubbles.instanceMatrix.needsUpdate = true;
// lid folds shut, then the sticker slaps on
const lidK = easeIO(seg(b, 0.45, 0.82));
lidPivot.rotation.x = -(1 - lidK) * 1.95;
const st = seg(b, 0.84, 1);
sticker.visible = st > 0;
const slap = st < 1 ? 1 + (1 - easeIO(st)) * 1.6 : 1;
sticker.scale.setScalar(slap); sticker.rotation.z = (1 - st) * 0.8; sticker.position.y = 0.02 + (1 - easeIO(st)) * 0.5;
scene3d.js — flames
const fp = flames.geometry.attributes.position;
for (let i = 0; i < NF; i++) {
  const f = flameS[i]; f.age += dt * (1.6 + (i % 5) * 0.2);
  if (f.age > 1) { f.age -= 1; f.x = (Math.random() - 0.5) * 0.7; f.z = (Math.random() - 0.5) * 0.2; }
  const q = mouthPos.clone().addScaledVector(ovenFwd, -0.42);
  fp.setXYZ(i, q.x + f.x * ovenFwd.z + f.z * ovenFwd.x, 1.66 + f.age * 0.55 * hot, q.z - f.x * ovenFwd.x + f.z * ovenFwd.z);
}
fp.needsUpdate = true; flames.material.size = 0.3 * (0.6 + hot * 0.6);

Each stage has a shot: close on the oven's mouth, the table where the box is, the wide map.

scene3d.js — the shots
const SHOTS = {
  baking: { t: mouthPos.clone().add(new THREE.Vector3(-0.2, -0.2, 0.25)), w: 6.6, az: 40, el: 19, focus: 0.5 },
  boxed: { t: TABLE.clone().lerp(OVEN, 0.3).setY(1.2), w: 5.6, az: 52, el: 30, focus: 0.5 },
  onway: { t: new THREE.Vector3(1.6, 0, 1.0), w: 27, az: 45, el: 40, focus: 0.5 },
};

There are no cuts. When the stage changes, the camera glides from where it is to the new shot over 2.6 s on an ease-in-out curve, and rises 10° in the middle, a crane move instead of a straight dolly. With reduced motion it cuts. Tilt-shift is strong on the wide shot and gentle on the close-ups.

scene3d.js — the glide
const key = stageKey(t);
const live = SHOTS[key];
if (key !== shotKey) {
  from = { t: camState.t.clone(), w: camState.w, az: camState.az, el: camState.el, focus: camState.focus };
  glideT = (shotKey === '' || reduced) ? 1 : 0; shotKey = key;
}
glideT = Math.min(1, glideT + dt / 2.6);
const k = easeIO(glideT);
if (k >= 1) { camState.t.copy(live.t); camState.w = live.w; camState.az = live.az; camState.el = live.el; camState.focus = live.focus; }
else {
  camState.t.lerpVectors(from.t, live.t, k); camState.w = from.w + (live.w - from.w) * k; camState.az = from.az + (live.az - from.az) * k;
  // arc up through a higher elevation mid-glide (a crane move instead of a straight dolly)
  camState.el = from.el + (live.el - from.el) * k + Math.sin(k * Math.PI) * 10; camState.focus = from.focus + (live.focus - from.focus) * k;
}
placeCamera();

A tile replays its moment: on the next frame the tracker snaps the scene to the tile's time, then sets the transition back to 0, so the pizza flies again from the oven.

scene3d.js — replay
function replay(name, t) {
  if (A[name]) A[name].v = 0; lastT = t;
}
tracker.js — a tile, applied next frame
// a stage jump is applied in the next animation frame (one snap, then the entry transition replays from its start)
let pendingReplay = null;
function goto(id) {
  setT(stageT(id));
  pendingReplay = { boxed: 'boxing' }[id] || null;
  if (id !== 'delivered' && !reduced) S.playing = true;
}
tracker.js — the frame
let snap = snapNext; snapNext = false;
if (pendingReplay) { scene.update(S.t, 0, true); scene.replay(pendingReplay, S.t); pendingReplay = null; snap = false; }
scene.update(S.t, dt, snap);
scene.render();
Stage 4 · the pizza fliesOpen ↗
A close view of the brick oven glowing orange, with the pizzeria's striped awning behind it: the pizza is in the air above the oven on its way into an open cardboard box on the table. The panel says Boxed and ready to go.

5 · The ride

The scooter waits at 6 % of the route while the kitchen works, then rides it by the ride share from stage 2. Its heading is the spline's tangent. It is two meshes, with and without the courier, swapped on arrival.

scene3d.js — the ride
// the ride
const u = PARK_U + rideShare(t) * (1 - PARK_U);
const p = ROUTE.getPointAt(u), tg = ROUTE.getTangentAt(u);
scooterG.position.copy(p).setY(0.05);
scooterG.rotation.y = Math.atan2(-tg.z, tg.x);
scooterRider.visible = arrive < 0.04; scooterEmpty.visible = !scooterRider.visible;
lineMat.uniforms.uP.value = t < T.rideAt ? 0 : u; lineMat.uniforms.uT.value = clock;
if (t >= T.rideAt && t < T.doneAt && !reduced) scooterG.position.y += Math.abs(Math.sin(clock * 9)) * 0.02;

The route line is a second, thinner ribbon with its own shader: behind the scooter it is solid tomato, ahead of it a row of white dashes that crawl toward the house. The edges fade, so the line looks painted on the road.

scene3d.js — the route line
const lineMat = new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, toneMapped: false,
  uniforms: { uP: { value: 0 }, uT: { value: 0 }, uLen: { value: routeLen }, uDone: { value: new THREE.Color(P.tomato) }, uAhead: { value: new THREE.Color('#ffffff') } },
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader: `uniform float uP, uT, uLen; uniform vec3 uDone, uAhead; varying vec2 vUv;
    void main(){
      float edge = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.82, vUv.y);
      float s = vUv.x * uLen;
      if (vUv.x <= uP) { gl_FragColor = vec4(uDone * 1.15, edge); return; }
      float dash = step(0.5, fract((s - uT * 1.2) / 1.1));
      gl_FragColor = vec4(uAhead, edge * dash * 0.9);
    }`,
});
const routeLine = new THREE.Mesh(ribbon(0.32, 0.05), lineMat); routeLine.renderOrder = 2; scene.add(routeLine);

Two more transitions: the box leaves the table when the courier picks it up, and the arrival (3.2 s) moves the courier from the scooter to the door, swings the door open onto the lit hall, fires the confetti half way and hands the box over at the end.

scene3d.js — the box leaves
boxG.visible = gone < 0.98; boxG.position.y = 0.99 + gone * 0.4; boxG.scale.setScalar(1 - gone * 0.6);
scene3d.js — the courier
function courierGeo() {
  const B = new Builder();
  for (const s of [-0.12, 0.12]) { B.add(at(rbox(0.18, 0.8, 0.18, 0.06), 0, 0.48, s), P.trousers); B.add(at(rbox(0.28, 0.1, 0.18, 0.04), 0.05, 0.06, s), P.shoe); }
  B.add(at(rbox(0.34, 0.72, 0.56, 0.14, 2), 0, 1.22, 0), P.shirt);
  for (const s of [-0.33, 0.33]) B.add(at(cyl(0.065, 0.065, 0.6, 8).rotateZ(Math.PI / 2 - 0.25), 0.25, 1.2, s), P.shirt);
  B.add(at(sph(0.2, 14, 10), 0, 1.78, 0), P.skin);
  B.add(at(sph(0.21, 14, 8, 0, Math.PI * 0.5), 0, 1.84, 0), '#d63a24');
  B.add(at(rbox(0.26, 0.04, 0.34, 0.02), 0.17, 1.85, 0), '#d63a24');
  return B.build();
}
scene3d.js — the arrival
// arrival: courier walks to the door with the box, door opens, confetti, handover
courierG.visible = arrive > 0.02;
if (courierG.visible) {
  const w = easeIO(seg(arrive, 0.02, 0.45)), a0 = new THREE.Vector3(p.x + 0.2, 0.25, p.z + 0.7), a1 = new THREE.Vector3(DOOR.x + 1.0, 0.25, DOOR.z + 0.62);
  courierG.position.lerpVectors(a0, a1, w); courierG.position.y += (w > 0 && w < 1 ? Math.abs(Math.sin(w * Math.PI * 6)) * 0.06 : 0);
  const face = w < 1 ? Math.atan2(-(a1.z - a0.z), a1.x - a0.x) : -Math.PI * 0.35;
  courierG.rotation.y = face;
  const give = easeIO(seg(arrive, 0.62, 0.85));
  carryBox.position.x = 0.42 + give * 0.25; carryLid.position.x = 0.06 + give * 0.25;
}
doorPivot.rotation.y = -easeIO(seg(arrive, 0.32, 0.62)) * 1.75;
if (arrive >= 0.5 && !confettiFired) { confettiFired = true; burst(reduced); }
if (arrive < 0.5 && confettiFired) { confettiFired = false; confT = -1; for (const c of conf) c.live = false; }

The confetti is 90 instanced paper bits in six colours with a little physics: thrown up and out, slowed by drag, pulled down, and lying flat where they land. With reduced motion they are placed already lying on the path.

scene3d.js — the burst
function burst(staticOnly) {
  confT = 0;
  for (const c of conf) {
    c.p.set(DOOR.x + (Math.random() - 0.5) * 0.4, 1.9, DOOR.z + 0.5);
    const a = Math.random() * Math.PI * 2, s = 1.6 + Math.random() * 2.6;
    c.v.set(Math.cos(a) * s * 0.55, 3.2 + Math.random() * 2.6, Math.sin(a) * s * 0.4 + 0.8);
    c.r.set(Math.random() * 6, Math.random() * 6, Math.random() * 6); c.w.set(Math.random() * 9 - 4.5, Math.random() * 9 - 4.5, Math.random() * 9 - 4.5);
    c.live = true;
    if (staticOnly) { c.p.set(DOOR.x + (Math.random() - 0.5) * 3.4, 0.27, DOOR.z + 0.4 + Math.random() * 2.2); c.v.set(0, 0, 0); c.live = false; }
  }
}
scene3d.js — the fall
confetti.visible = confettiFired;
if (confetti.visible) {
  for (let i = 0; i < NC; i++) {
    const c = conf[i];
    if (c.live) { c.v.y -= 9.8 * dt * 0.55; c.v.multiplyScalar(1 - dt * 1.2); c.p.addScaledVector(c.v, dt); c.r.addScaledVector(c.w, dt); if (c.p.y < 0.27) { c.p.y = 0.27; c.live = false; c.r.x = Math.PI / 2; } }
    dummy.position.copy(c.p); dummy.rotation.set(c.r.x, c.r.y, c.r.z); dummy.scale.setScalar(1); dummy.updateMatrix(); confetti.setMatrixAt(i, dummy.matrix);
  }
  confetti.instanceMatrix.needsUpdate = true;
}

The wide shot drifts a third of the way toward the scooter, so the ride stays in the middle of the free area without the camera chasing it.

scene3d.js — the wide shot follows
function liveShot(key, scooterPos) {
  const s = SHOTS[key];
  if (key !== 'onway') return s;
  // the wide shot drifts gently with the scooter (a third of the way toward it)
  return { ...s, t: s.t.clone().lerp(scooterPos, 0.3) };
}
Stage 5 · at the doorOpen ↗
The door shot: the red scooter parked on the road, the courier in red with the pizza box at the open door of a cream house, confetti in the air. The panel says Delivered. Enjoy! with all four knobs green.

6 · Time of day

The town has three lights: day, golden hour and night. Each sets the sun (colour, strength, direction), the sky light, the reflections, the exposure, how lit the windows and lamps are, and the page's sky. Auto follows the visitor's clock: day from 7:00 to 16:30, golden hour to 19:30 and from 5:30 to 7:00, night otherwise.

scene3d.js — three lights
export const TOD = {
  day: { sun: '#fff1dc', sunI: 2.5, sunDir: [-9, 16, 7], hemiSky: '#d6e6ff', hemiGround: '#e9cfae', hemiI: 0.75, env: 0.5, exposure: 0.95, night: 0, lamps: 0.15, bg: ['#fbf1e1', '#f3e1c6'] },
  golden: { sun: '#ffa65c', sunI: 2.6, sunDir: [-16, 6.5, 10], hemiSky: '#ffd2a6', hemiGround: '#d9a173', hemiI: 0.6, env: 0.32, exposure: 0.98, night: 0.35, lamps: 0.55, bg: ['#fbd9ac', '#f2b989'] },
  night: { sun: '#9fb6ff', sunI: 0.5, sunDir: [10, 16, -6], hemiSky: '#46578f', hemiGround: '#221a2a', hemiI: 0.4, env: 0.08, exposure: 1.0, night: 1, lamps: 1, bg: ['#27305a', '#141a33'] },
};
export function todForHour(h) { return h >= 7 && h < 16.5 ? 'day' : (h >= 16.5 && h < 19.5) || (h >= 5.5 && h < 7) ? 'golden' : 'night'; }
scene3d.js — apply one
function applyTod(name, instant = true) {
  tod = name; const p = TOD[name];
  sun.color.set(p.sun); sun.intensity = p.sunI; sun.position.set(...p.sunDir); hemi.color.set(p.hemiSky); hemi.groundColor.set(p.hemiGround); hemi.intensity = p.hemiI;
  scene.environmentIntensity = p.env; renderer.toneMappingExposure = p.exposure; cur.night = p.night;
  emMat.color.setScalar(0.35 + 0.65 * Math.max(p.night, 0.25) + (p.night > 0.5 ? 0.6 : 0));
  lampGlow.material.opacity = p.lamps * 0.85;
  head.intensity = p.night * 40; cone.material.opacity = p.night * 0.16;
  sign.material.emissiveIntensity = p.night * 0.25; sign.material.emissiveMap = sign.material.map; sign.material.needsUpdate = true;
  root.style.setProperty('--sky-a', p.bg[0]); root.style.setProperty('--sky-b', p.bg[1]); root.dataset.tod = name;
  renderer.shadowMap.needsUpdate = true;
}

At night the scooter's headlight comes on: a spot light that rides with it and a faint additive cone. The streetlights get glow sprites and the sign lights up.

scene3d.js — the headlight
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.9, 3.2, 20, 1, true).rotateZ(Math.PI / 2).translate(2.4, 1.0, 0), new THREE.MeshBasicMaterial({ color: '#fff1c8', transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }));
scooterG.add(cone);
head.position.set(0.85, 1.15, 0); head.target.position.set(4, 0, 0); scooterG.add(head, head.target);

The switch is a radio group of four, Auto first; only the checked one shows its word. The choice is saved under dv49a.tod and announced, and Auto is checked against the clock every minute.

index.html — the switch
<div class="tod glass" role="radiogroup" aria-label="Time of day in the scene">
  <label title="Auto: follows your clock"><input type="radio" name="tod" value="auto" checked aria-label="Auto, follows your clock"><span><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg><b>Auto</b></span></label>
  <label title="Day"><input type="radio" name="tod" value="day" aria-label="Day"><span><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7"/></svg><b>Day</b></span></label>
  <label title="Golden hour"><input type="radio" name="tod" value="golden" aria-label="Golden hour"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 17h18M6.5 17a5.5 5.5 0 0 1 11 0M12 6.5v2M5.2 9.7l1.4 1.4M18.8 9.7l-1.4 1.4M4 21h16"/></svg><b>Golden</b></span></label>
  <label title="Night"><input type="radio" name="tod" value="night" aria-label="Night"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 14.5A8 8 0 0 1 9.5 4.5a8 8 0 1 0 10 10Z"/></svg><b>Night</b></span></label>
</div>
tracker.js — time of day
// ---------------------------------------------------------------- time of day (3D scene): follows the visitor's clock unless they pick one
const todRadios = $$('input[name=tod]');
function todNow() { const d = new Date(); return (window.__todFor || (() => 'day'))(d.getHours() + d.getMinutes() / 60); }
function applyTod() {
  const pick = (todRadios.find((r) => r.checked) || {}).value || 'auto';
  const name = pick === 'auto' ? todNow() : pick;
  if (hero.dataset.tod !== name) { scene.setTimeOfDay?.(name); hero.dataset.tod = name; }
}
{ const saved = params.get('tod') || store.get('tod'); const r = todRadios.find((x) => x.value === saved); if (r) r.checked = true; }
todRadios.forEach((r) => r.addEventListener('change', () => { store.set('tod', r.value); applyTod(); announce(r.value === 'auto' ? `Scene follows your clock: ${hero.dataset.tod === 'golden' ? 'golden hour' : hero.dataset.tod}.` : `Scene set to ${r.value === 'golden' ? 'golden hour' : r.value}.`); }));
if (sceneKind !== '3d') $('.tod')?.remove();
applyTod(); setInterval(applyTod, 60000);

The page stays light at night: only the title recolours and the glass gets a little more opaque. The sky behind the canvas is the same two custom properties as in stage 1, now set by the scene.

tracker.css — the night title
.hero[data-tod=night] .glass { background: rgba(255, 248, 236, .88); }
.hero[data-tod=night] .title .t1 { color: #ffd2b8; text-shadow: 0 2px 18px rgba(10, 14, 36, .7); }
.hero[data-tod=night] .title .t2 { color: #f4e7d2; text-shadow: 0 2px 14px rgba(10, 14, 36, .8); }
tracker.css — the sky
.scene { position: absolute; inset: 0; overflow: hidden; background: radial-gradient(120% 95% at 40% 25%, var(--sky-a, #fbf1e1), var(--sky-b, #f3e1c6)); contain: layout paint; transition: background .8s; }
Stage 6 · nightOpen ↗
The town at night under a deep blue sky: lit windows, glowing streetlights, the oven burning orange, the scooter's headlight on the road and the route line in tomato behind it. The title has turned peach and cream; the switch shows Night.

7 · Talking to the shop

Message, Track another order and Rate are <dialog> sheets. They open with show(), not showModal(): a modal dialog makes the rest of the page inert, and on a throttled phone that cost 10 to 20 ms of style work on every open and close. So the page does it by hand: aria-modal, a scrim, a focus trap for Tab and Shift + Tab, focus pulled back if it escapes, Escape, and the focus returned to the button that opened the sheet.

index.html — the message sheet
<dialog class="sheet" id="msg-dialog" aria-labelledby="msg-h" aria-modal="true">
  <form method="dialog" class="sheet-in" data-msg-form>
    <h2 id="msg-h">Message <span data-courier-first>Marco</span></h2>
    <fieldset class="quick">
      <legend class="sr-only">Quick replies</legend>
      <label><input type="radio" name="quick" value="Please leave it at the door." checked><span>Leave it at the door</span></label>
      <label><input type="radio" name="quick" value="Please ring the bell, I'm in."><span>Ring the bell</span></label>
      <label><input type="radio" name="quick" value="I'm coming down now."><span>I'm coming down</span></label>
    </fieldset>
    <label class="field"><span>Or write your own <span class="muted">(optional)</span></span><textarea name="note" rows="2" maxlength="140" placeholder="Gate code is 2468"></textarea></label>
    <p class="muted small">Demo: nothing is sent anywhere.</p>
    <div class="sheet-a"><button class="btn btn-ghost" value="cancel" type="submit" formnovalidate>Cancel</button><button class="btn btn-basil" value="send" type="submit">Send</button></div>
  </form>
</dialog>
tracker.js — sheets
const msgDlg = $('#msg-dialog'), trackDlg = $('#track-dialog'), rateDlg = $('#rate-dialog'), scrim = $('[data-scrim]');
// Sheets open non-modally (show) with aria-modal, a scrim and a focus trap. showModal() would make the whole
// 2.5k-node page inert, a ~10-20 ms style recalc on a throttled phone: a dropped frame on every open and close.
let returnFocus = null, current = null, warming = false;
const focusables = (d) => [...d.querySelectorAll('button, input:not([type=radio]), input[type=radio]:checked, textarea, a[href]')].filter((e) => !e.disabled && e.offsetParent);
function open(dlg, from) {
  returnFocus = from || document.activeElement; current = dlg;
  dlg.returnValue = ''; dlg.show(); scrim.hidden = false;
  (dlg.querySelector('[autofocus]') || focusables(dlg)[0])?.focus();
}
[msgDlg, trackDlg, rateDlg].forEach((d) => {
  d.addEventListener('close', () => { if (warming) return; current = null; scrim.hidden = true; if (returnFocus && document.contains(returnFocus)) returnFocus.focus(); });
  d.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); d.close('cancel'); return; }
    if (e.key !== 'Tab') return;
    const f = focusables(d), first = f[0], last = f[f.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });
});
scrim.addEventListener('click', () => current?.close('cancel'));
// focus that escapes an open sheet (e.g. a click on the page through a gap) is pulled back in
document.addEventListener('focusin', (e) => { if (current && !current.contains(e.target)) (focusables(current)[0])?.focus(); });
tracker.js — the warm-up
// warm-up: lay each sheet out once while idle, so the first real open is cheap
(window.requestIdleCallback || ((f) => setTimeout(f, 600)))(() => {
  warming = true;
  for (const d of [msgDlg, trackDlg, rateDlg]) { d.classList.add('is-warm'); d.show(); void d.offsetHeight; d.close(); d.classList.remove('is-warm'); }
  warming = false;
});

A sent message becomes a row in the order's timeline, a toast and an announcement. Nothing leaves the page.

tracker.js — a message
msgDlg.addEventListener('close', () => {
  if (msgDlg.returnValue !== 'send') return;
  const f = $('[data-msg-form]'); const note = f.note.value.trim(); const text = note || f.quick.value;
  (S.notes ||= []).push({ at: clock(S.t), text });
  renderEvents();
  const c = S.order.courier.first;
  toast(`Sent to ${c}: “${text}”`); announce(`Message sent to ${c}.`);
});

Track another order validates as you would want a real form to: empty, a wrong format and an unknown number each get their own message under the field, with aria-invalid and role="alert". Spaces and case do not matter and "PT-" is optional. A found order replaces the whole page state.

tracker.js — track another order
$('[data-track-form]').addEventListener('submit', (e) => {
  e.preventDefault();
  const raw = e.currentTarget.no.value.trim().toUpperCase().replace(/\s+/g, '');
  if (!raw) { setErr('Enter your order number, like PT-48257.'); e.currentTarget.no.focus(); return; }
  const m = raw.match(/^(?:PT)?-?(\d{5})$/);
  if (!m) { setErr('Order numbers look like PT-48257: “PT-” and five digits.'); e.currentTarget.no.focus(); return; }
  const no = 'PT-' + m[1], o = ORDERS[no];
  if (!o) { setErr(`We couldn't find ${no}. Check the number in your confirmation email.`); e.currentTarget.no.focus(); return; }
  S.order = o; S.rated = 0; setT(o.t0); anchorClock(); renderOrder();
  if (!reduced && o.t0 < T.end) S.playing = true;
  returnFocus = $('#tracker');
  trackDlg.close('ok');
  sync('quiet');
  const st = STAGES[stageIndex(S.t)].label;
  announce(`Now tracking order ${no}: ${st}.`); toast(`Now tracking #${no}`);
});

On a phone the sheet has one green button, and it changes with the stage: Message Marco, then Rate your pizza, then the rating given.

tracker.js — the green button
el.primary.textContent = i === 3 ? (S.rated ? `You rated it ${'★'.repeat(S.rated)}` : 'Rate your pizza') : `Message ${S.order.courier.first}`;
el.primary.dataset.act = i === 3 ? 'rate' : 'message';
tracker.js — rate
function openRate() { open(rateDlg, el.primary); }
rateDlg.addEventListener('close', () => {
  if (rateDlg.returnValue !== 'rate') return;
  S.rated = +$('[data-rate-form]').stars.value;
  renderStage(stageIndex(S.t), true); renderSecondary(stageIndex(S.t)); later = null;
  toast(`Thanks! You rated this order ${S.rated} out of 5.`); announce(`Thanks, you rated this order ${S.rated} out of 5.`);
});

The stars are five radios in reverse order, so a CSS sibling selector can light every star up to the hovered or checked one.

tracker.css — stars
.stars { border: 0; margin: 0; padding: 0; display: flex; flex-direction: row-reverse; justify-content: center; gap: 4px; }
.stars input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.stars label { font-size: 40px; line-height: 1; color: #e2d3bb; cursor: pointer; width: 48px; height: 48px; display: grid; place-items: center; border-radius: 10px; transition: color .15s, transform .15s; }
.stars input:checked ~ label, .stars label:hover, .stars label:hover ~ label { color: #f0a92a; }
tracker.css — sheets and scrim
dialog.sheet { position: fixed; inset: 0; margin: auto; height: fit-content; max-height: calc(100svh - 32px); overflow: auto; z-index: 61; border: 0; padding: 0; border-radius: 22px; width: min(440px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 60px -20px rgba(40, 20, 10, .5); }
dialog.sheet.is-warm { visibility: hidden; }
.scrim { position: fixed; inset: 0; z-index: 60; background: rgba(45, 33, 27, .45); animation: fadeIn .2s; }
.scrim[hidden] { display: none; }

Order again fills the cart with the same items. The cart button's badge and label count them, and its popover closes with Escape or a click outside.

tracker.js — order again
$$('[data-action=reorder]').forEach((b) => b.addEventListener('click', () => {
  const n = S.order.items.reduce((s, it) => s + +it[0], 0);
  cart += n;
  const badge = $('.cart-n'); badge.hidden = false; badge.textContent = cart;
  cartBtn.setAttribute('aria-label', `Cart, ${cart} items`);
  $('.cart-empty').textContent = `${cart} items in your cart, the same as your last order. Demo: nothing is charged.`;
  toast(`Added ${n} items to your cart (demo)`); announce(`Added ${n} items to your cart.`);
  if (!cartPop.hidden) closeCart();
}));
Stage 7 · message the courierOpen ↗
The Message Marco sheet over the dimmed town: quick replies Leave it at the door, Ring the bell (picked) and I'm coming down, a field for your own note, Cancel and a green Send. The nav now has Order again and a cart.
Stage 7 · the details on a phoneOpen ↗
The tracker on a phone with the sheet open: the order line with 12 min, the progress, the green Message Marco button, the demo bar, then Marco is on the way, the timeline with four times and Marco's card with Call and Message.

8 · Fallbacks and the frame budget

Without WebGL2 (or with ?svg), the hero gets the first version of this town: a flat-shaded isometric street drawn in SVG by scene.js, with the same contract as the 3D scene. The tracker calls update(), then camera() with the focus it returns; the 3D scene answers with a camera that does nothing, because it moves its own. The query string can also set the pixel ratio, the MSAA and the tilt-shift.

tracker.js — the town, with its fallback
function webgl2() { try { const c = document.createElement('canvas'); return !!c.getContext('webgl2'); } catch { return false; } }
let scene = null, sceneKind = 'svg';
if (webgl2() && !params.has('svg')) {
  try { const m = await import('./scene3d.js'); const q = {}; if (params.has('dpr')) q.dpr = +params.get('dpr'); if (params.has('msaa')) q.msaa = +params.get('msaa'); if (params.has('tilt')) q.tilt = params.get('tilt') === '1';
    scene = m.createScene3D(sceneEl, { reduced, phone: matchMedia('(max-width: 900px)').matches, q }); sceneKind = '3d'; window.__todFor = m.todForHour; }
  catch (e) { scene = null; sceneEl.querySelector('canvas.gl')?.remove(); }
}
if (!scene) { sceneEl.classList.add('is-svg'); scene = createScene(sceneEl, { reduced }); }
document.documentElement.dataset.sceneKind = sceneKind;
tracker.js — one contract for both
const r = scene.update(S.t, dt, snap, scrubbing);
scene.camera(r.focus, dt, snap);
scene.render?.();
scene.frameTime?.(dt * 1000);
scene3d.js — the contract
update, replay, camera: () => {}, render: () => composer.render(), resize, frameTime, setTimeOfDay: (n) => applyTod(n), get tod() { return tod; },

The SVG street is drawn by a tiny isometric renderer: a projector from world units to the 2:1 picture, and faces sorted by depth (x + y + z) and written as polygons. Moving things live in their own small layers, so a frame is mostly a transform, and the camera pans the whole street with one damped translate, rounded to device pixels.

iso.js — the projector
export function projector(ox, oy, k) {
  const p = (x, y, z = 0) => [ox + (x - y) * k, oy + (x + y) * k / 2 - z * k];
  p.k = k; p.ox = ox; p.oy = oy;
  return p;
}
iso.js — render a mesh
/** sort + project a mesh into an array of {d, fill} (svg polygon points) */
export function render(mesh, P, { cull = true, sort = true } = {}) {
  let fs = mesh.faces.map(finish);
  if (sort) {
    // part depth = mean depth of all its faces (before culling); faces outside a part are their own part
    // sort === 'insertion': parts keep the order they were added in (hand-ordered models like the oven)
    const pd = new Map();
    fs.forEach((f, i) => { const k = f.part || 'f' + i; const e = pd.get(k) || [0, 0, pd.size]; e[0] += f.depth; e[1]++; pd.set(k, e); f._k = k; });
    for (const f of fs) { const e = pd.get(f._k); f._pd = sort === 'insertion' ? e[2] : e[0] / e[1]; }
    fs.sort((a, b) => (a._pd - b._pd) || (a.depth - b.depth));
  }
  if (cull) fs = fs.filter((f) => f.visible);
  return fs.map((f) => ({ points: f.pts.map((p) => P(p[0], p[1], p[2]).map((v) => v.toFixed(2)).join(',')).join(' '), fill: f.fill, cls: f.cls, stroke: f.stroke }));
}
scene.js — the SVG camera
function camera(focus, dt, snap) {
  const tx = Math.max(0, Math.min(W - vis[0], focus[0] - vis[0] * 0.5)), ty = Math.max(0, Math.min(H - vis[1], focus[1] - vis[1] * 0.55));
  if (snap || reduced) cam = [tx, ty];
  else { const k = 1 - Math.exp(-dt * 3.2); cam = [cam[0] + (tx - cam[0]) * k, cam[1] + (ty - cam[1]) * k]; }
  const dpr = devicePixelRatio || 1, r = (v) => Math.round(v * dpr) / dpr;
  const v = `translate3d(${r(-cam[0] * s)}px, ${r(-cam[1] * s)}px, 0)`;
  if (v !== camKey) { world.style.transform = v; camKey = v; }
}

If a tile's picture fails to load, a small SVG drawing of the same moment, made with the same models, takes its place.

tiles.js — the tiles' fallback
export function buildTiles(root) {
  const fns = { baking, boxed, onway, delivered };
  for (const host of root.querySelectorAll('[data-art]')) {
    // the tiles show renders of the 3D scene; if an image is missing, the SVG illustration stands in
    const img = host.querySelector('img');
    const svg = () => { img?.remove(); fns[host.dataset.art]?.(host); };
    if (!img) svg(); else if (img.complete && !img.naturalWidth) svg(); else img.addEventListener('error', svg, { once: true });
  }
}

Without JavaScript, a one-line script in the head never adds the js class, so everything marked data-js-only (the demo bar, the switch, the buttons that need a script) is hidden, and a line in the hero says the pizza is on its way.

index.html — the js class
<script>document.documentElement.classList.add('js');</script>
tracker.css — JS-only controls
html:not(.js) [data-js-only] { display: none !important; }
index.html — without JavaScript
<noscript><p class="noscript">Your pizza is on the way: about 12 minutes. Turn on JavaScript to watch it on the live map.</p></noscript>

Two last things keep the frame budget. Every shader program is compiled at load, hidden objects included, so the first confetti never stalls a frame. And if more than a third of 90 frames run past 11.5 ms, the pixel ratio steps down by 0.25, and after that the tilt-shift turns off.

scene3d.js — compile everything at load
// compile every program now (hidden things too), so the first delivery or confetti burst never stalls a frame
const hidden = []; scene.traverse((o) => { if (!o.visible) { hidden.push(o); o.visible = true; } });
renderer.compile(scene, camera); composer.render();
for (const o of hidden) o.visible = false;
scene3d.js — adaptive quality
// adaptive quality: if frames run long for a second, step the pixel ratio down (then drop tilt-shift)
const adapt = { n: 0, slow: 0, last: 0 };
function frameTime(ms) {
  adapt.n++; if (ms > 11.5) adapt.slow++;
  if (adapt.n >= 90) {
    if (adapt.slow > 30) {
      const pr = renderer.getPixelRatio();
      if (pr > 1.01) { tier.adaptCap = Math.max(1, pr - 0.25); resize(); }
      else if (tier.tilt) { tier.tilt = false; }
    }
    adapt.n = adapt.slow = 0;
  }
}

Measured at 120 Hz with the page's own scenario (watch, the Boxed and Delivered tiles, a scrub across every stage, 60×, a message opened and closed): no missed frames and no long tasks at 1440 × 900, on a 390 × 844 phone, and on that phone with the CPU slowed 4 ×. The slowest frame took 9.4 ms, and the adaptive step-down never had to start.

Stage 8 · the SVG fallbackOpen ↗
The same tracker with the SVG town: a flat isometric street in clay colours with the pizzeria wall, its awning and oven, a grey road, trees and the red scooter, under the same glass panel and demo bar.
Stage 8 · on a phoneOpen ↗
The finished tracker on a phone: the title over the 3D town in daylight, the time-of-day switch on Day, and the bottom sheet with 12 min, the progress, the green Message Marco button and the demo bar.

Make it yours

  • Your schedule is T in js/timeline.js: when boxing starts, when the ride starts, when it is delivered. Feed t from your order API instead of the demo clock and everything follows.
  • Your orders are ORDERS in js/tracker.js: items, fees, the courier, the address.
  • Your town is buildTown() and ROUTE in js/scene3d.js, and its colours are P; the shots are SHOTS, the lights TOD.
  • Colours of the page are tokens at the top of css/tracker.css, primitive then semantic.
  • Scripts can drive it through window.__dv49: state(), set(t), play(), pause(), goto('boxed').
  • Accessibility is already there: a polite live region for every stage, a labelled scrubber with value text, 44 px targets, sheets with a focus trap, visible focus, text at 4.5:1, and prefers-reduced-motion honoured (no autoplay, cuts instead of glides, no flying pizza, confetti lying still).

The code is under the MIT licence: use it in your own projects, commercial ones included. three.js r186 is MIT too. The fonts are Outfit and Inter (SIL Open Font License, from Google Fonts). pizzatime, its orders, couriers and phone numbers are made up. No AI picture is used: the town, the oven, the scooter and the stage pictures are made in code (the pictures are renders of the same three.js scene), and the SVG fallback is drawn by script.

The one idea worth keeping

Drive the whole tracker from one number. The order's time decides the copy, the progress, the ETA, the camera and every moving thing; transitions only smooth the way between two values of it. A scrubber, a demo clock, a link with ?t= or a live order feed all set the same number, and the page can never disagree with itself.