Library / Tutorial

Cart toss,
step by step.

A product card that is the hero of its page. Pick a colour and the photo slides over on a spring while the whole page re-tints; pick a size and press Add, and the shoe pops out of the card in 3D, arcs into the bag and lands with a burst, while the card flips over to a receipt with a working stepper and a rolling subtotal. We build it in six stages, and every stage runs on its own — try each one before you read the next.

6Stages
15Springs
0Libraries

This is the whole build of Cart toss, in the order it was written. Each stage below is a live page: pick a colour and a size and press Add, with a finger, a mouse or the keyboard. The code under each heading is only what that stage adds; the finished source is at the end.

Three files do the work from the start: index.html for the page and the card, style.css for the colour tokens and the drawing, and app.js for the behaviour. From stage 3 there is a fourth, spring.js. Every stage also has an img/ folder with the three product photos. They were generated with AI: a fictional sneaker with no logos or brand marks.

1 · A card, every colour a token

The card is not a small widget on an empty page; the page around it is part of the effect. So the first stage is a whole shop page in a 432 × 768 frame that app.js scales to fit the screen: a breadcrumb and a bag pill on top, and under them one big product card, 352 × 448. The card has an image well with the photo, a floor shadow and a "New" tag, then the name, the price and the rating, then two option groups and the Add button.

index.html — the card
<article class="card" aria-label="Halo Runner">
  <div class="face front">
    <!-- AI disclosure: the product photos are AI-generated (Higgsfield, GPT Image) of a fictional sneaker with
         no brand marks. See ../BRIEF.md and ../assets/SOURCES.md. -->
    <div class="well">
      <span class="well-bg" aria-hidden="true"></span>
      <span class="floor" aria-hidden="true"></span>
      <div class="shoe-box">
        <img class="shoe is-cur" src="img/shoe-ember.webp" width="1400" height="1052" alt="Halo Runner in Ember" draggable="false">
      </div>
      <span class="tag" aria-hidden="true">New</span>
      <span class="idx" aria-hidden="true"><b class="idx-n">01</b> / 03</span>
    </div>

    <div class="info">
      <h2 class="name">Halo Runner</h2>
      <p class="price"><span class="sr-only">Price: </span><span class="cur" aria-hidden="true">$</span><span class="amt">148</span></p>
      <p class="rating">
        <span class="stars" aria-hidden="true"><span class="stars-off"></span><span class="stars-on"></span></span>
        <span class="sr-only">Rated 4.8 out of 5, 1,200 reviews</span>
        <span class="rating-n" aria-hidden="true">4.8 <i>·</i> 1.2k</span>
      </p>
    </div>

    <div class="opt">
      <span class="opt-label" id="color-label">Colour <b class="color-name">Ember</b></span>
      <div class="swatches" role="radiogroup" aria-labelledby="color-label"><!-- radios from app.js --></div>
    </div>

    <div class="opt opt-size">
      <span class="opt-label" id="size-label">Size <b>EU</b></span>
    </div>
    <div class="chips" role="radiogroup" aria-labelledby="size-label"><!-- radios from app.js --></div>

    <button class="add" type="button">
      <span class="add-text">Add to cart</span>
      <span class="add-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5.6 8.4h12.8l-1 11a1.7 1.7 0 0 1-1.7 1.5H8.3a1.7 1.7 0 0 1-1.7-1.5z"/><path d="M9 8.4V7a3 3 0 0 1 6 0v1.4"/><path d="M12 11.8v5.2M9.4 14.4h5.2"/></svg></span>
    </button>
  </div>
</article>

Every colour comes from a CSS variable, and the variables come in three layers. At the bottom are the primitives: raw values with no meaning, one tonal scale of eleven steps per colourway. Ember runs from cream through amber and ember orange to wine; cobalt and magenta have scales of their own.

style.css — the primitives (the start of them)
/* 1a · primitives */
:root {
  --sans: 'Geist', -apple-system, system-ui, sans-serif; --serif: 'Instrument Serif', Georgia, serif;

  --ember-50: #FFF6EA; --ember-100: #FFE9C7; --ember-200: #FFD08A; --ember-300: #FFB054; --ember-400: #FF8A2C;
  --ember-500: #F5541A; --ember-600: #DE3A16; --ember-700: #AE2415; --ember-800: #74131C; --ember-900: #420A15; --ember-950: #23060C;

In the middle are the colourway aliases. --c-50 to --c-950 point at one scale, chosen by an attribute on <html>: data-color="ember". Nothing above this layer knows which colourway it is drawing.

style.css — the colourway aliases (ember; cobalt and magenta are the same shape)
/* 1b · colourway aliases (with each colourway's swatch, which the radios show in every look) */
:root { --swatch-ember: var(--ember-500); --swatch-cobalt: var(--cobalt-500); --swatch-magenta: var(--magenta-500); }
[data-color="ember"] {
  --c-50: var(--ember-50); --c-100: var(--ember-100); --c-200: var(--ember-200); --c-300: var(--ember-300); --c-400: var(--ember-400);
  --c-500: var(--ember-500); --c-600: var(--ember-600); --c-700: var(--ember-700); --c-800: var(--ember-800); --c-900: var(--ember-900); --c-950: var(--ember-950);
}

On top are the semantic tokens: what a colour is for. The accent, the button, the chip that is checked, the tag. They are declared on .stage for the one look we have for now, a smoked glass card, keyed on a second attribute, data-look="glass".

style.css — part of the glass set of semantic tokens
--accent: var(--c-500); --on-accent: var(--white); --accent-soft: color-mix(in oklab, var(--c-300) 55%, transparent); --accent-text: var(--c-700);
--glow: var(--c-300); --focus: var(--white);
--well-spot: radial-gradient(64% 78% at 50% 58%, var(--c-50), var(--c-100) 42%, var(--c-200) 78%, var(--c-300));
--well-edge: color-mix(in srgb, var(--white) 62%, transparent); --well-ink: var(--c-800);
--floor: color-mix(in oklab, var(--c-900) 50%, transparent);
--btn: var(--white); --on-btn: var(--c-950); --btn-icon: var(--c-600);
--chip-on: var(--white); --on-chip-on: var(--c-700);
--tag: var(--c-700); --on-tag: var(--white); --badge: var(--white); --on-badge: var(--c-700);

The component rules read only semantic tokens. The Add button never names a colour: it is var(--btn) with var(--on-btn) ink, and the glass look only adds its shape.

style.css — a component rule, and the look's shape for it
.add {
  position: relative; margin-top: auto; height: 50px; flex: none; display: flex; align-items: center; justify-content: center; gap: 10px;
  border: 0; overflow: hidden; font-size: 16px; font-weight: 700; letter-spacing: -.01em; will-change: transform;
  color: var(--on-btn); background: var(--btn);
}

[data-look="glass"] .add { border-radius: 999px; box-shadow: 0 14px 28px -12px color-mix(in oklab, var(--shadow-tint) 70%, transparent); }

The colour and size options are native radio buttons, written into the two role="radiogroup" containers from two arrays. A radio group gives the arrow keys and a single Tab stop for free, and a checked radio can already be styled with :checked, so you can pick a size in this stage. Nothing reacts to it yet.

app.js — the frame, the product, the radios
// fit the 432 × 768 composition to the screen
function fit() { html.style.setProperty('--k', Math.min(innerWidth / 432, innerHeight / 768).toFixed(4)); }
fit();
addEventListener('resize', fit);

// the product
const COLORS = [
  { key: 'ember', name: 'Ember', src: 'img/shoe-ember.webp' },             // swatch colours: --swatch-<key> in style.css
  { key: 'cobalt', name: 'Cobalt', src: 'img/shoe-cobalt.webp' },
  { key: 'magenta', name: 'Magenta', src: 'img/shoe-magenta.webp' },
];
const SIZES = [40, 41, 42, 43, 44];
let color = COLORS.find((c) => c.key === html.dataset.color) || COLORS[0];

// the colour chooser and the size chips: native radios (arrow keys, one Tab stop per group)
swatchHost.innerHTML = COLORS.map((c) =>
  `<label class="pick sw"><input type="radio" name="color" value="${c.key}" aria-label="${c.name}"${c === color ? ' checked' : ''}><span class="dot" style="--sw:var(--swatch-${c.key})"></span></label>`).join('');
chipHost.innerHTML = SIZES.map((s) =>
  `<label class="pick chip"><input type="radio" name="size" value="${s}" aria-label="EU ${s}"><span class="face-c">${s}</span></label>`).join('');
Stage 1 · pick a colour and a size; nothing reacts yetOpen ↗

2 · The options work, and Add checks first

A colour change does two things. The photo, the colour's name and the index change, and data-color on <html> switches, which points the aliases at another scale: the whole stage, the card, the well and the button re-tint at once, with no colour named in the script.

app.js — a colour
// a colour: the photo and the whole stage change at once
function setColor(c) {
  if (c === color) return;
  color = c;
  html.dataset.color = c.key;
  meta();
  colorName.textContent = c.name;
  idxN.textContent = String(COLORS.indexOf(c) + 1).padStart(2, '0');
  shoeImg.src = c.src;
  shoeImg.alt = `${PRODUCT.name} in ${c.name}`;
}
swatchHost.addEventListener('change', (e) => setColor(COLORS.find((c) => c.key === e.target.value)));

The browser's own bar follows too: app.js reads one more token, --meta, and writes it into the theme-color meta tag.

app.js — the theme colour comes from a token
// the browser's theme colour follows the colourway: the --meta token (style.css, 1b)
const meta = () => { $('meta[name="theme-color"]').content = getComputedStyle(html).getPropertyValue('--meta').trim(); };
meta();

The size is required. The hint is a real element that the size group points at with aria-describedby, and a polite live region, empty for now, will say what happened.

index.html — the size hint, and the live region
<div class="opt opt-size">
  <span class="opt-label" id="size-label">Size <b>EU</b></span>
  <span class="size-hint" id="size-hint">Choose your size</span>
</div>
<div class="chips" role="radiogroup" aria-labelledby="size-label" aria-describedby="size-hint"><!-- radios from app.js --></div>

<p class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>

Without a size, Add marks the group aria-invalid, shows the hint, says "Choose a size first." and moves the keyboard focus to the first size, where the choice is.

app.js — a size, and the missing size
// a size; without one, Add marks the group invalid and shows the hint
chipHost.addEventListener('change', (e) => {
  size = +e.target.value;
  chipHost.classList.remove('is-bad'); chipHost.removeAttribute('aria-invalid');
  sizeHint.style.opacity = '0';
});
function needSize() {
  chipHost.classList.add('is-bad'); chipHost.setAttribute('aria-invalid', 'true');
  sizeHint.style.opacity = '1';
  say('Choose a size first.');
  sizeInputs[0].focus({ preventScroll: true });              // the keyboard lands where the choice is
}

With a size, the cart changes first. The cart is an array in memory: the same colour and size again adds one to that line, a new pair makes a new line. In a real shop this is where you would call your cart API. Then land() shows it: the badge counts, the bag's accessible name becomes "Cart, 1 item", and the live region announces the line. The announcement is cleared and set again 30 ms later, so the same message is read twice in a row.

app.js — Add, land and say
// Add: the cart changes at once, then the page shows it
function add() {
  if (size == null) { needSize(); return; }
  // the truth changes at once (here: a call to your cart API)
  let l = cart.find((x) => x.color === color && x.size === size);
  if (l) l.qty += 1; else cart.push((l = { color, size, qty: 1 }));
  line = l;
  land();
}
// the cart has the item: the badge, the bag's name, the announcement
function land() {
  const n = itemsIn();
  badgeEl.textContent = String(n);
  badgeEl.style.transform = 'scale(1)';                      // hidden (scale 0) until the first item
  bagBtn.setAttribute('aria-label', `Cart, ${plural(n)}`);
  say(`Added ${PRODUCT.name}, size ${line.size}, ${line.color.name}. ${plural(n)} in cart.`);
}
let sayTimer = 0;
function say(text) {                                          // re-set, so the same message is announced again
  live.textContent = '';
  clearTimeout(sayTimer);
  sayTimer = setTimeout(() => { live.textContent = text; }, 30);
}

The badge sits at scale(0) until the first item, and the missing-size chips take a brighter outline.

style.css — the badge, and the chips with no size chosen
.badge {
  position: absolute; right: -9px; top: -9px; min-width: 24px; height: 24px; padding: 0 6px; display: grid; place-items: center;
  border-radius: 999px; font-size: 12.5px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums;
  color: var(--on-badge); background: var(--badge); box-shadow: 0 4px 10px -2px color-mix(in srgb, var(--shadow-tint) 35%, transparent); transform: scale(0); will-change: transform;
}

[data-look="glass"] .chips.is-bad .face-c { box-shadow: inset 0 0 0 1.5px var(--highlight); background: color-mix(in srgb, var(--highlight) 20%, transparent); }
Stage 2 · press Add before you pick a sizeOpen ↗

3 · Springs: the photo slides, the sizes shake

Every motion in this component comes from one small class. A spring has a value, a target and a velocity; each step it accelerates towards the target and a damper slows it down. Low damping overshoots, high damping creeps in. Unlike a CSS transition, a spring can change its target in the middle of a move without a jump, and it can be kicked: adding to its velocity makes it move and come back.

spring.js — 30 lines
// A spring, written from scratch: a mass on a spring with a damper.
//   acceleration = stiffness × (target − value) − damping × velocity
// It is stepped in small fixed slices (1/240 s), so it moves the same on a 60 Hz and a 120 Hz screen.
// Changing the target never moves the value: an interrupted animation just bends towards the new target.
class Spring {
  constructor(value = 0, { stiffness = 170, damping = 26 } = {}) {
    this.value = value;
    this.target = value;
    this.velocity = 0;
    this.stiffness = stiffness;
    this.damping = damping;
  }

  set(options) { Object.assign(this, options); return this; }

  step(dt) {
    const slice = 1 / 240;
    for (let t = dt; t > 0; t -= slice) {
      const h = Math.min(slice, t);
      const acceleration = this.stiffness * (this.target - this.value) - this.damping * this.velocity;
      this.velocity += acceleration * h;
      this.value += this.velocity * h;
    }
    return this.value;
  }

  resting(epsilon = 0.01) {
    return Math.abs(this.target - this.value) < epsilon && Math.abs(this.velocity) < epsilon * 10;
  }
}

Five springs for now. swap carries a colour change from the old photo (0) to the new one (1). shake is stiff and barely damped, so a kick makes it ring. hint slides the hint in, press squashes the button under the finger, and flash is a critically damped bloom in the well: kicked, it rises and fades without bouncing.

app.js — the springs
// the springs (stiffness, damping; ζ = damping / 2√stiffness)
const sp = (stiffness, damping) => ({ stiffness, damping });
const crit = (k) => sp(k, 2 * Math.sqrt(k));                 // critically damped: no overshoot
const swap = new Spring(1, sp(170, 18));                     // a colour change, 0 old photo → 1 new
const shake = new Spring(0, sp(900, 10));                    // the size chips' shake, px
const hint = new Spring(0, sp(320, 22));                     // the size hint
const press = new Spring(0, sp(520, 22));                    // a button under the finger
const flash = new Spring(0, crit(90));                       // the well's bloom
const springs = [swap, shake, hint, press, flash];

The page needs a spare photo for the swap: a second <img class="shoe"> in the well, and the two trade places. The old one slides out sideways, turning and shrinking; the new one comes in from the other side with a small overshoot. Which side follows the swatch order. When the spring rests, the two swap roles.

app.js — a colour, on a spring
// a colour: the photo slides out, the new one slides in on a spring; the stage re-tints through CSS (0.7 s)
let imgs = [...shoeBox.querySelectorAll('.shoe')];          // [the one on show, the spare]
let swapDir = 1, swapping = false;
function finishSwap() {
  if (!swapping) return;
  swapping = false;
  imgs = [imgs[1], imgs[0]];
  imgs[0].classList.add('is-cur'); imgs[1].classList.remove('is-cur');
  imgs[1].style.opacity = '0'; imgs[0].style.opacity = '1'; imgs[0].style.transform = imgs[1].style.transform = '';
  swap.value = swap.target = 1; swap.velocity = 0;
}
function setColor(c) {
  if (c === color) return;
  const dir = COLORS.indexOf(c) > COLORS.indexOf(color) ? 1 : -1;
  color = c;
  html.dataset.color = c.key;
  meta();
  colorName.textContent = c.name;
  idxN.textContent = String(COLORS.indexOf(c) + 1).padStart(2, '0');
  finishSwap();
  imgs[1].src = c.src;
  imgs[1].alt = `${PRODUCT.name} in ${c.name}`; imgs[1].removeAttribute('aria-hidden');
  imgs[0].alt = ''; imgs[0].setAttribute('aria-hidden', 'true');
  swapping = true; swapDir = dir;
  swap.value = 0; swap.velocity = 0; swap.target = 1;
  flash.velocity += 5;
  wake();
}
swatchHost.addEventListener('change', (e) => setColor(COLORS.find((c) => c.key === e.target.value)));
app.js — drawing the well
function drawWell() {
  wellFlashEl.style.opacity = clamp(flash.value, 0.002, 1).toFixed(3);   // never 0: kept rasterized
  wellFlashEl.style.transform = `scale(${(0.7 + 0.5 * clamp(flash.value, 0, 1.4)).toFixed(3)})`;
  if (swapping) {
    const s = swap.value, d = swapDir, [o, n] = imgs;
    o.style.opacity = clamp(1 - 1.7 * s, 0, 1).toFixed(3);
    o.style.transform = `translateX(${(-d * 190 * s).toFixed(2)}px) rotate(${(-d * 16 * s).toFixed(2)}deg) scale(${(1 - 0.22 * clamp(s, 0, 1)).toFixed(3)})`;
    n.style.opacity = clamp(1.8 * s - 0.2, 0, 1).toFixed(3);
    n.style.transform = `translateX(${(d * 190 * (1 - s)).toFixed(2)}px) rotate(${(d * 14 * (1 - s)).toFixed(2)}deg) scale(${(0.84 + 0.16 * s).toFixed(3)})`;
    if (swap.resting(0.002)) finishSwap();
  }
}

The stage should glide to the new colours too, but a CSS transition cannot animate a custom property: to the browser, an unregistered --c-500 is just text. Registered with @property as a <color>, it interpolates, so every gradient built from the aliases glides with it.

style.css — the aliases registered, and their transition
@property --c-50  { syntax: '<color>'; inherits: true; initial-value: #FFF6EA; }
@property --c-100 { syntax: '<color>'; inherits: true; initial-value: #FFE9C7; }
@property --c-200 { syntax: '<color>'; inherits: true; initial-value: #FFD08A; }
@property --c-300 { syntax: '<color>'; inherits: true; initial-value: #FFB054; }
@property --c-400 { syntax: '<color>'; inherits: true; initial-value: #FF8A2C; }
@property --c-500 { syntax: '<color>'; inherits: true; initial-value: #F5541A; }
@property --c-600 { syntax: '<color>'; inherits: true; initial-value: #DE3A16; }
@property --c-700 { syntax: '<color>'; inherits: true; initial-value: #AE2415; }
@property --c-800 { syntax: '<color>'; inherits: true; initial-value: #74131C; }
@property --c-900 { syntax: '<color>'; inherits: true; initial-value: #420A15; }
@property --c-950 { syntax: '<color>'; inherits: true; initial-value: #23060C; }

.stage {
  position: relative; height: 100dvh; display: grid; place-items: center; overflow: hidden; color: var(--on-field);
  transition: --c-50 .7s, --c-100 .7s, --c-200 .7s, --c-300 .7s, --c-400 .7s, --c-500 .7s, --c-600 .7s, --c-700 .7s, --c-800 .7s, --c-900 .7s, --c-950 .7s;

The semantic tokens are declared on .stage, the element that runs the transition, and not on <html>. An unregistered variable is resolved where it is declared: on <html> it would take the new colour at once and skip the glide.

No size? The chips get a kick of −420 px/s on that stiff spring and shake, and the hint slides in.

app.js — the missing size, now with a shake
function needSize() {
  chipHost.classList.add('is-bad'); chipHost.setAttribute('aria-invalid', 'true');
  hint.target = 1;
  shake.velocity -= 420; press.velocity -= 6;
  say('Choose a size first.');
  sizeInputs[0].focus({ preventScroll: true });              // the keyboard lands where the choice is
  wake();
}

The loop runs only while something moves. Its time step is capped at 32 ms, so a frame after a stall does not throw the springs across the screen.

app.js — the loop
// the loop: it runs only while something moves
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000)); last = now;
  springs.forEach((s) => s.step(dt));
  draw();
  const still = springs.every((s) => s.resting(0.002));
  if (!still) requestAnimationFrame(frame); else running = false;
}
function draw() {
  drawWell();
  drawControls();
}
Stage 3 · change the colour; press Add with no sizeOpen ↗

4 · The pop-out, and the flight into the bag

The shoe cannot leave the card: the image well clips it. So it hands over. A copy of the photo, the .flyer, waits on its own layer over the card, and at the tap it takes the shoe's exact place while the shoe in the well is hidden. Its shadow is a second element, so it can fall away from the shoe.

index.html — the flyer
<!-- the pop-out: the flying shoe and its shadow. Over the card. -->
<div class="fx" aria-hidden="true">
  <i class="fly-shadow"></i>
  <div class="flyer"><img src="img/shoe-ember.webp" alt="" draggable="false"></div>
</div>
style.css — the flyer's layer, and the bag in front of it
/* ---- 6. the pop-out and the impact ---- */
.fx { position: absolute; inset: 0; z-index: 5; pointer-events: none; }
.flyer { position: absolute; left: 0; top: 0; width: 300px; height: 225px; opacity: 0; will-change: transform, opacity; }
.flyer img { width: 100%; height: 100%; display: block; }
.fly-shadow { position: absolute; left: 0; top: 0; width: 300px; height: 60px; border-radius: 50%; opacity: 0; will-change: transform, opacity; background: radial-gradient(closest-side, color-mix(in oklab, var(--shadow-tint) 55%, transparent), color-mix(in oklab, var(--shadow-tint) 22%, transparent) 50%, transparent); }

.bag.is-front { z-index: 6; }                                  /* the landing shoe passes behind the bag */

Add now changes the cart and launches. Two timings shape the move: the shoe hovers, popped out, for 0.34 s, and then flies for 0.42 s.

app.js — the timings, and Add
const POP = 0.34;              // s from the tap until the flight starts (the shoe hovers, popped out)
const FLY = 0.42;              // s of flight
const fly = { on: false, phase: '', t0: 0, t1: 0, ox: 0, oy: 0, P2: null };
const queue = [];
let clock = 0;
function later(s, fn) { queue.push({ at: clock + s, fn }); }

function add() {
  if (fly.on) return;
  if (size == null) { needSize(); return; }
  // the truth changes at once (here: a call to your cart API)
  let l = cart.find((x) => x.color === color && x.size === size);
  if (l) l.qty += 1; else cart.push((l = { color, size, qty: 1 }));
  line = l;
  sweepT = clock;
  launch();
}
app.js — the hand-over
function launch() {
  const o = inFrame(shoeBox);
  fly.on = true; fly.phase = 'pop'; fly.t0 = clock;
  fly.ox = o.x + o.w / 2; fly.oy = o.y + o.h / 2;           // the shoe's centre
  flyer.style.left = `${fly.ox - o.w / 2}px`; flyer.style.top = `${fly.oy - o.h / 2}px`;
  flyShadow.style.left = `${fly.ox - 150}px`; flyShadow.style.top = `${o.y + o.h * 0.84 - 30}px`;   // the sole: the photo has a clear margin
  pop.value = 0; pop.velocity = 0; pop.target = 1;
  flash.velocity += 14;
  later(POP, () => {
    fly.P2 = bagCentre();
    fly.phase = 'fly'; fly.t1 = clock;
  });
  wake();
}

The pop-out is a spring, pop, going from 0 to 1 with one overshoot. At 1 the shoe has risen 96 px, over the card's top edge, grown 1.34 times and tilted towards you in 3D. Its shadow on the card drops 26 px and widens, so the shoe clearly floats in front of the card. Then the flight takes over: a short quadratic arc, up and over into the bag pill, that accelerates as if sucked in (its progress is raised to the power 1.7), shrinks the shoe to 0.09 and turns it. For the last part the bag is lifted above the flyer's layer, so the shoe goes in behind the pill.

app.js — the pose, and the flight
// the shoe's pose at a pop value p: risen out of the well, bigger, tilted towards the viewer
function popPose(p) {
  return { x: -10 * p, y: -96 * p, s: 1 + 0.34 * p, rx: 20 * p, ry: -26 * p, rz: -11 * clamp(p, 0, 1.2) };
}
function drawFlight() {
  // at rest the flyer stays on screen at opacity .002 (not 0), so it is already rasterized when it pops out
  if (!fly.on) { flyer.style.opacity = flyShadow.style.opacity = '0.002'; return; }
  const p = pop.value;
  let x, y, s, rx, ry, rz, o = 1, sh = 1;
  const a = popPose(p);
  if (fly.phase === 'pop') {
    ({ x, y, s, rx, ry, rz } = a);
  } else {
    const u = clamp((clock - fly.t1) / FLY, 0, 1), e = Math.pow(u, 1.7);          // it accelerates into the cart: sucked in
    const P0 = { x: fly.ox + a.x, y: fly.oy + a.y }, P2 = fly.P2;
    const P1 = { x: lerp(P0.x, P2.x, 0.2), y: Math.min(P0.y, P2.y) - 56 };   // a short arc, up and over
    const k = 1 - e;
    x = k * k * P0.x + 2 * k * e * P1.x + e * e * P2.x - fly.ox;
    y = k * k * P0.y + 2 * k * e * P1.y + e * e * P2.y - fly.oy;
    s = lerp(a.s, 0.09, Math.pow(e, 0.8));
    rx = lerp(a.rx, 4, e); ry = lerp(a.ry, -58, e); rz = lerp(a.rz, 24, e);
    o = u < 0.84 ? 1 : 1 - (u - 0.84) / 0.16;
    sh = clamp(1 - u * 3, 0, 1);
    bagBtn.classList.toggle('is-front', u > 0.62);           // the last bit goes in behind the pill
    if (u >= 1) { fly.on = false; impact(); }
  }
  flyer.style.opacity = o.toFixed(3);
  flyer.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) perspective(820px) rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg) rotateZ(${rz.toFixed(2)}deg) scale(${s.toFixed(4)})`;
  // its cast shadow on the card: further below and wider the higher it rises — clearly in front of the card
  const q = clamp(p, 0, 1.2);
  flyShadow.style.opacity = Math.max(0.002, sh * (0.25 + 0.55 * clamp(p, 0, 1))).toFixed(3);
  flyShadow.style.transform = `translate(${(a.x * 0.4).toFixed(2)}px, ${(26 * q).toFixed(2)}px) scale(${(1 + 0.32 * q).toFixed(3)}, ${(1 + 0.7 * q).toFixed(3)})`;
}

The badge's count now rolls like an odometer. Every character is a small window holding the old character and the new one; a character that changes rolls on its own spring, up for more and down for less.

app.js — the odometer
// the odometer: every character is a window; a character that changes rolls (up for more, down for less)
const odos = [];
class Odo {
  constructor(el) { this.el = el; this.text = ''; this.cols = []; this.dir = 1; this.roll = new Spring(1, sp(240, 20)); odos.push(this); }
  set(text, { dir = 1, animate = true } = {}) {
    if (text === this.text) return;
    const n = Math.max(this.text.length, text.length), A = this.text.padStart(n), B = text.padStart(n);
    this.el.innerHTML = [...B].map((ch, i) => `<span class="col"><span>${A[i] === ch ? '' : A[i].trim()}</span><span>${ch.trim()}</span></span>`).join('');
    this.cols = [...this.el.children].map((c, i) => ({ o: c.children[0], n: c.children[1], mv: A[i] !== B[i] }));
    this.text = text; this.dir = dir;
    this.roll.velocity = 0; this.roll.value = animate ? 0 : 1; this.roll.target = 1;
    this.done = false; this.render();
  }
  render() {
    if (this.done) return;
    const r = this.roll.value, d = this.dir;
    for (const c of this.cols) {
      if (!c.mv) continue;
      c.o.style.transform = `translateY(${(-d * r * 110).toFixed(2)}%)`;
      c.n.style.transform = `translateY(${(d * (1 - r) * 110).toFixed(2)}%)`;
    }
    if (this.roll.resting(0.002)) { this.cols.forEach((c) => { c.o.remove(); c.n.style.transform = ''; }); this.done = true; }
  }
}
const badgeOdo = new Odo($('.badge .odo'));
style.css — the odometer
/* the odometer: every character is a window; a character that changes rolls (old out, new in) */
.odo { display: inline-flex; overflow: hidden; vertical-align: top; line-height: 1.25; padding: 0 .02em; }
.odo .col { position: relative; display: inline-grid; }
.odo .col > span { grid-area: 1 / 1; display: block; text-align: center; will-change: transform; }

At the impact the pill squashes and glows, and the badge pops: three springs, each given a kick. Only then does land() show the new count. The cart changed at the tap; the page shows it when the shoe arrives.

app.js — the impact
function impact() {
  later(0.32, () => bagBtn.classList.remove('is-front'));
  squash.velocity += 15; glow.velocity += 16; badgePop.velocity += 9;
  land();
}
// the cart has the item: the badge, the announcement
function land() {
  const n = itemsIn();
  if (badgePop.target === 0) badgePop.target = 1;
  badgeOdo.set(String(n));
  bagBtn.setAttribute('aria-label', `Cart, ${plural(n)}`);
  say(`Added ${PRODUCT.name}, size ${line.size}, ${line.color.name}. ${plural(n)} in cart.`);
  wake();
}
Stage 4 · pick a size and press AddOpen ↗

5 · The card turns over to a receipt

The card gets a back: a mini-cart receipt. A tick and "Added to bag", a receipt number, the heading "Your bag", the line just added with a thumbnail in its colour, a − 1 + stepper, a dashed tear line, the shipping rows, the subtotal, Checkout and "Keep shopping". The back starts inert and aria-hidden; the heading has tabindex="-1" so focus can be moved to it.

index.html — the back (the top of it)
<div class="face back" inert aria-hidden="true">
  <div class="back-top">
    <span class="ok"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5.5 12.6l4.2 4.1 8.8-9.4"/></svg>Added to bag</span>
    <span class="ref" aria-hidden="true">Nº <b class="ref-n">0001</b></span>
  </div>
  <h2 class="back-title" tabindex="-1">Your bag</h2>

  <div class="line">
    <div class="thumb" aria-hidden="true"><img src="img/shoe-ember.webp" width="1400" height="1052" alt="" draggable="false"></div>
    <div class="line-info">
      <p class="line-name">Halo Runner</p>
      <p class="line-var">Ember · EU 42</p>
      <p class="line-price">$148.00</p>
    </div>
    <div class="stepper" role="group" aria-label="Quantity">
      <button class="dec" type="button" aria-label="Decrease quantity"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12"/></svg></button>
      <span class="qty" aria-hidden="true"><span class="odo qty-n"></span></span>
      <span class="sr-only qty-sr">Quantity 1</span>
      <button class="inc" type="button" aria-label="Increase quantity"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12M12 6v12"/></svg></button>
    </div>
  </div>

  <div class="tear" aria-hidden="true"></div>

The two faces sit in a .card-3d that turns. Each face hides its own back and sits 1 px off the middle, and the face turned away is also set to opacity 0.002, so the two never fight for the same pixels.

style.css — the card in 3D
.card { position: absolute; left: 40px; top: 186px; width: 352px; height: 448px; perspective: 1800px; z-index: 2; }
.card-shadow {
  position: absolute; left: 6%; right: 6%; bottom: -26px; height: 60px; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in oklab, var(--shadow-tint) 55%, transparent), transparent); will-change: transform, opacity;
}
.card-3d { position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; }
.face {
  position: absolute; inset: 0; display: flex; flex-direction: column; border-radius: var(--card-r, 30px); color: var(--ink);
  -webkit-backface-visibility: hidden; backface-visibility: hidden;
}
.front { transform: translateZ(1px); padding: 12px; }
.back { transform: rotateY(180deg) translateZ(1px); padding: 18px 20px 16px; }

The tear line has a punched notch at each edge: two half circles cut out of the card with a mask. The script measures the tear line, and again once the fonts have loaded, and puts the notches level with it.

style.css — the notches
/* the receipt's punched notches: two half-circles cut out of the card's edges, level with the tear line */
.back { -webkit-mask: radial-gradient(circle 11px at 0 var(--notch, 262px), transparent 10.5px, #000 11px) left / 51% 100% no-repeat, radial-gradient(circle 11px at 100% var(--notch, 262px), transparent 10.5px, #000 11px) right / 51% 100% no-repeat;
        mask: radial-gradient(circle 11px at 0 var(--notch, 262px), transparent 10.5px, #000 11px) left / 51% 100% no-repeat, radial-gradient(circle 11px at 100% var(--notch, 262px), transparent 10.5px, #000 11px) right / 51% 100% no-repeat; }
app.js — level with the tear line
// the receipt's punched notches sit level with its tear line
function notch() { const tr = $('.tear'); back.style.setProperty('--notch', `${tr.offsetTop + tr.offsetHeight / 2}px`); }
notch();

Turning over moves the focus too. On the back, the front becomes inert and focus goes to "Your bag"; on the way back, to Add. The flip starts 0.28 s after the tap, while the shoe is still popping out. During the flight the receipt shows the numbers as they were, and they roll when the shoe lands.

app.js — the two sides
// the flip. The hidden face is inert and aria-hidden; focus goes to the back's heading, and back to Add on return.
function showBack() {
  if (!line) return;
  side = 'back';
  fillReceipt();
  if (fly.on) {                                               // the numbers as they were; they roll when it lands
    qtyOdo.set(String(Math.max(1, line.qty - 1)), { animate: false });
    subOdo.set(money.format(subtotal() - PRODUCT.price), { animate: false });
  }
  front.inert = true; front.setAttribute('aria-hidden', 'true');
  back.inert = false; back.removeAttribute('aria-hidden');
  flip.target = 180;
  backTitle.focus({ preventScroll: true });
  wake();
}
function showFront() {
  side = 'front';
  back.inert = true; back.setAttribute('aria-hidden', 'true');
  front.inert = false; front.removeAttribute('aria-hidden');
  flip.target = 0;
  addBtn.focus({ preventScroll: true });
  wake();
}

The turn is one spring, flip, in degrees: 0 to 180 with a damping ratio of 0.64, about 9° past and back. While it turns, the card lifts up to 40 px towards you and tilts 3°, each face darkens as it turns away, and the card's shadow narrows with the cosine of the angle.

app.js — drawing the card
// the card: its turn, its lift, the shading on each face, which face is shown
let shownSide = 'front';
function drawCard() {
  const th = flip.value, s = Math.sin(th * RAD), c = Math.cos(th * RAD);
  // a lift towards the viewer and a slight tilt while it turns
  card3d.style.transform = `translateZ(${(40 * Math.abs(s)).toFixed(2)}px) rotateX(${(-3 * s).toFixed(2)}deg) rotateY(${th.toFixed(2)}deg)`;
  const now = c >= 0 ? 'front' : 'back';
  if (now !== shownSide) { shownSide = now; front.style.opacity = now === 'front' ? '1' : '0.002'; back.style.opacity = now === 'back' ? '1' : '0.002'; }
  shades[0].style.opacity = (0.55 * clamp(s, 0, 1)).toFixed(3);
  shades[1].style.opacity = (0.55 * clamp(Math.abs(s), 0, 1)).toFixed(3);
  cardShadow.style.transform = `scaleX(${(0.3 + 0.7 * Math.abs(c)).toFixed(3)})`;
}

The stepper changes the line between 1 and 10, rolls the quantity, the subtotal (formatted with Intl.NumberFormat) and the badge, and says "Quantity 2. Subtotal $296.00." Its buttons get aria-disabled at the ends.

app.js — the numbers, and the stepper
function setNumbers(dir) {
  qtyOdo.set(String(line.qty), { dir });
  subOdo.set(money.format(subtotal()), { dir });
  subSr.textContent = money.format(subtotal());
  qtySr.textContent = `Quantity ${line.qty}`;
  itemsEl.textContent = plural(itemsIn());
  decBtn.setAttribute('aria-disabled', String(line.qty <= 1));
  incBtn.setAttribute('aria-disabled', String(line.qty >= 10));
}

function step(d) {
  if (!line) return;
  const q = clamp(line.qty + d, 1, 10);
  if (q === line.qty) return;
  line.qty = q;
  setNumbers(d);
  badgeOdo.set(String(itemsIn()), { dir: d });
  badgePop.velocity += 5; squash.velocity += 6 * d;
  bagBtn.setAttribute('aria-label', `Cart, ${plural(itemsIn())}`);
  say(`Quantity ${q}. Subtotal ${money.format(subtotal())}.`);
  wake();
}

"Keep shopping", Escape on the back and the bag pill all turn the card back; the bag pill on the front opens the receipt when the cart has something in it, and says "Your cart is empty." when it has not. Checkout is simulated: its label changes to "Demo — nothing sent".

app.js — the input
addBtn.addEventListener('click', add);
decBtn.addEventListener('click', () => step(-1));
incBtn.addEventListener('click', () => step(1));
keepBtn.addEventListener('click', showFront);
checkoutBtn.addEventListener('click', checkout);
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') showFront(); });
bagBtn.addEventListener('click', () => {
  if (fly.on) return;
  if (side === 'back') { showFront(); return; }
  if (!cart.length) { say('Your cart is empty.'); squash.velocity -= 8; wake(); return; }
  line = line || cart[cart.length - 1];
  showBack(); setNumbers(1);
});
Stage 5 · add, then change the quantity; Escape turns it backOpen ↗

6 · The finish

The impact gets its burst: the .fx layer gains two rings, twenty particles and a "+1".

index.html — the pop-out and the impact
<!-- the pop-out: the flying shoe, its shadow, and the impact at the bag. Over the card, under the footer. -->
<div class="fx" aria-hidden="true">
  <i class="fly-shadow"></i>
  <div class="flyer"><img src="img/shoe-ember.webp" alt="" draggable="false"></div>
  <div class="fx-rings"><i></i><i></i></div>
  <div class="fx-parts"></div>
  <b class="fx-plus">+1</b>
</div>

The particles are made once: dots and short streaks, every third one a streak, each with its own angle, distance and size, and coloured by five tokens of the look. A seeded random keeps them the same on every load.

app.js — the particles
// the impact's burst: dots and short streaks in the colourway's colours (the --burst-1…5 tokens of each look)
const PART_COLORS = [1, 2, 3, 4, 5].map((i) => `var(--burst-${i})`);
const PART_N = 20;
const parts = [];
for (let i = 0; i < PART_N; i++) {
  const el = document.createElement('i');
  if (i % 3 === 1) el.className = 'streak';
  if (LOOK === 'swiss') el.style.borderRadius = '0';
  el.style.setProperty('--c', PART_COLORS[i % PART_COLORS.length]);
  partHost.append(el);
  parts.push({ el, a: (i / PART_N) * TAU + (rand() - 0.5) * 0.35, d: 44 + rand() * 50, z: 0.7 + rand() * 0.8 });
}

They fly out from behind the bag pill over 0.62 s, slowing as they go and falling a little.

app.js — the burst (in drawImpact)
const up = u0 / 0.62;
parts.forEach((p) => {
  if (up < 0 || up > 1) { p.el.style.opacity = '0'; return; }
  const e = 1 - Math.pow(1 - up, 3), x = fxAt.x + Math.cos(p.a) * p.d * e, y = fxAt.y + Math.sin(p.a) * p.d * e + 22 * up * up;
  p.el.style.opacity = (1 - up * up).toFixed(3);
  const grow = up < 0.12 ? up / 0.12 : 1 - (up - 0.12) / 0.88;
  p.el.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) rotate(${(p.a / RAD).toFixed(1)}deg) scale(${(grow * p.z).toFixed(3)})`;
});

At the impact the whole scene bumps: one more spring, bump, kicked with the others and fed into the frame's scale.

app.js — the impact, now
function impact() {
  later(0.32, () => bagBtn.classList.remove('is-front'));   // the burst comes out from behind the pill
  squash.velocity += 15; glow.velocity += 16; bump.velocity += 5; badgePop.velocity += 9;
  fxT = clock;
  fxAt = bagCentre();
  land();
}
app.js — the scene bump
const bump = new Spring(0, sp(300, 11));                     // the whole scene's jolt at impact

html.style.setProperty('--bump', (1 + 0.04 * bump.value).toFixed(4));
style.css — the frame
.frame { position: relative; width: 432px; height: 768px; flex: none; transform: scale(calc(var(--k, 1) * var(--bump, 1))); }

The shoe in the well now floats, a slow bob and sway, and the flyer starts from where it is at that moment, at its height and its angle. The float is a pure function of the clock, like the ambient life behind the card: the glass look's drifting lights, and the luxe look's beam, pool and dust.

app.js — the float, and the hand-over from it
// the photo in the well: a slow float; during a colour change, the slide
let floatY = 0, floatR = 0;
function drawWell(t) {
  floatY = calm ? 0 : -3 + 4 * Math.sin(t * 1.5);
  floatR = calm ? -1 : -1.2 + 1.1 * Math.sin(t * 0.95 + 0.6);
  const out = fly.on ? 0 : 1;                                // the shoe is out of the card
  shoeBox.style.opacity = String(out);
  shoeBox.style.transform = `translateY(${floatY.toFixed(2)}px) rotate(${floatR.toFixed(2)}deg)`;

fly.ox = o.x + o.w / 2; fly.oy = o.y + o.h / 2 + floatY;  // the shoe's centre, where it floats right now
fly.fr = floatR;

With prefers-reduced-motion there is no pop-out, no flight and no turn. The faces cross-fade in 0.16 s, a colour change happens at once, the numbers change without rolling, and nothing drifts, so the loop again runs only while something moves.

app.js — reduced motion
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (calm) html.classList.add('calm');

if (calm) { showBack(); land(); return; }

if (!calm || !still) requestAnimationFrame(frame); else running = false;
style.css — reduced motion
/* ---- 11. reduced motion: no flight, no turn. The two faces cross-fade, the colour changes at once. ---- */
.calm .stage { transition: none; }
.calm .face { transition: opacity .16s linear; }
.face[inert] { pointer-events: none; }
.calm .back { transform: translateZ(1px); }                     /* no turn: the back simply fades in, facing us */
@media (prefers-reduced-motion: reduce) {
  .touch, .sw .dot, .chip .face-c { transition: none; }
}

The page also gets its credits and a head script that picks the look and the colourway from the address before the first paint, so the page never flashes the wrong one.

index.html — in &lt;head&gt;, before the first paint
<!-- before the first paint: the look (?look=glass|swiss|luxe, or a|b|c) and the colourway (?color=ember|cobalt|magenta),
     so the page never flashes the wrong one -->
<script>
  (() => {
    const html = document.documentElement, q = new URLSearchParams(location.search);
    const look = { a: 'glass', b: 'swiss', c: 'luxe' }[q.get('look')] || q.get('look');
    html.dataset.look = ['glass', 'swiss', 'luxe'].includes(look) ? look : 'glass';
    html.dataset.color = ['ember', 'cobalt', 'magenta'].includes(q.get('color')) ? q.get('color') : 'ember';
  })();
</script>
Stage 6 · the finished cardOpen ↗

Three looks, one token system

The finished file has three looks: glass, swiss and luxe. They share the markup, the script and the first two layers of tokens. Each look is one more block of semantic tokens on [data-look] .stage, plus the shapes it draws differently: a smoked glass card on a mesh of the colourway, a paper card on a flat field of print colour, a matte black card with a champagne-gold edge.

style.css — its header: the sections, and the three layers of tokens
/* Cart Toss v3 — a product card as the hero of a page. Add a size: the shoe pops out of the card in 3D, flies into
   the bag, and the card flips over to its back: a mini-cart receipt. Three looks (?look=glass|swiss|luxe), each a
   different card style; the stage in every look takes its colour from the chosen colourway (ember|cobalt|magenta).

   1 tokens: primitive → colourway alias → semantic   2 stage, frame, ambient   3 the frame's words and the shop bar
   4 the card: 3D, faces, shading  5 the front   6 the back (receipt)   7 the pop-out and the impact   8 footer
   9 look B (swiss)   10 look C (luxe)   11 reduced motion

   ---- 1. TOKENS: three layers ----------------------------------------------------------------------------------
   1a PRIMITIVES (:root). Raw, hand-tuned values with no meaning. One tonal scale per colourway, each an analogous run
      with depth rather than a mix with white or black:
        ember    50–200 cream and amber · 300–400 amber–tangerine · 500 ember orange · 600–700 vermilion–crimson
                 · 800–950 wine
        cobalt   50–200 ice and sky · 300–400 cyan–azure · 500 cobalt · 600–700 ultramarine · 800–950 indigo night
        magenta  50–200 blush and peach · 300–400 coral-rose · 500 magenta · 600–700 berry · 800–950 plum
      Plus the champagne gold scale (--gold-50…900), the neutrals (--ink-990 … --paper-50) and the
      white/black/"on" pairs.
   1b COLOURWAY ALIASES ([data-color]). --c-50 … --c-950 point at the chosen scale. --c-on is the ink that reads on
      --c-500, and --c-alt is its counterpart. All of them are registered (@property), so a colour change glides
      (0.7 s) through every gradient that uses them.
   1c SEMANTIC TOKENS (per look, on .stage, so they resolve against the gliding aliases):
        stage    --stage-base --stage-1/2/3/4 --vignette --scrim --on-field --on-field-muted --grid-line
        card     --surface --surface-2 --surface-edge --surface-sheen --ink --ink-muted --ink-faint --control-edge
        accent   --accent --on-accent --accent-soft --accent-text --glow --focus
        well     --well-spot --well-edge --well-ink --floor
        actions  --btn --on-btn --btn-icon --chip-on --on-chip-on --tag --on-tag --badge --on-badge
        frame    --pill --pill-edge --on-pill --on-pill-muted --pill-icon --on-pill-icon --bag --on-bag --bag-edge --ring
        light    --highlight --lowlight --shadow-tint --shade --sweep --flash --flash-core --spark --plus-ink --star
                 --star-off --em --burst-1…5 (the impact's particles, read by app.js)
        per look glass --stage-halo --sheen-blob --well-depth · swiss --grid-base --well-grid --on-field-alt
                 · luxe --beam --pool --mote --label --metal --metal-soft --metal-hi --metal-glint --metal-line --metal-glow
                 --well-cone · glass and luxe --swatch-gap --swatch-ring
      On <html>: --page (the body behind the stage) and --meta (the browser's theme colour, read by app.js).
   COMPONENT RULES (sections 2–10) use only semantic tokens. The exceptions are transparency helpers:
   color-mix(… N%, transparent) and the `transparent` keyword. */

Even the page behind the stage and the browser's theme colour are tokens of the look.

style.css — the page and the theme colour
/* the page behind the stage and the browser's theme colour (app.js reads --meta) */
[data-look="glass"] { --page: var(--c-800); --meta: var(--c-800); }
[data-look="swiss"] { --page: var(--c-500); --meta: var(--c-500); }
[data-look="luxe"]  { --page: var(--ink-990); --meta: var(--ink-990); }

Here is one complete semantic set. In swiss the ink on the field follows the colourway: --c-on is black on vermilion and rhodamine and paper on ultramarine, so the words stay readable on all three.

style.css — the swiss set of semantic tokens
/* B · SWISS INDEX: a paper card on a flat field of the colourway's print colour, an ink hairline grid, one accent */
[data-look="swiss"] .stage {
  --flash-core: var(--white); --spark: var(--white);
  --stage-base: var(--c-500); --vignette: transparent; --scrim: transparent;
  --on-field: var(--c-on); --on-field-muted: var(--c-on); --on-field-alt: var(--c-alt);
  --grid-line: color-mix(in srgb, var(--c-on) 10%, transparent); --grid-base: color-mix(in srgb, var(--c-on) 7%, transparent);
  --surface: var(--paper-50); --surface-sheen: transparent; --surface-2: var(--paper-100); --surface-edge: transparent; --surface-top: transparent;
  --ink: var(--ink-950); --ink-muted: var(--ink-600); --ink-faint: var(--ink-500); --control-edge: var(--ink-950);
  --accent: var(--c-500); --on-accent: var(--c-on); --accent-soft: color-mix(in srgb, var(--c-500) 14%, transparent); --accent-text: var(--c-700);
  --glow: var(--white); --focus: var(--ink-950);
  --well-spot: var(--paper-100); --well-grid: color-mix(in srgb, var(--ink-950) 7%, transparent); --well-edge: transparent; --well-ink: var(--ink-500);
  --floor: color-mix(in srgb, var(--black) 36%, transparent);
  --btn: var(--ink-950); --on-btn: var(--paper-50); --btn-icon: var(--on-accent);
  --chip-on: var(--ink-950); --on-chip-on: var(--paper-50);
  --tag: var(--c-500); --on-tag: var(--c-on); --badge: var(--ink-950); --on-badge: var(--paper-50);
  --pill: var(--ink-950); --pill-edge: var(--ink-950); --on-pill: var(--paper-50); --on-pill-muted: color-mix(in srgb, var(--paper-50) 72%, transparent);
  --pill-icon: var(--c-500); --on-pill-icon: var(--c-on);
  --bag: var(--paper-50); --on-bag: var(--ink-950); --bag-edge: transparent;
  --ring: var(--c-on);
  --shadow-tint: var(--black); --shade: var(--black);
  --sweep: color-mix(in srgb, var(--white) 28%, transparent); --flash: var(--white);
  --star: var(--ink-950); --star-off: color-mix(in srgb, var(--ink-950) 18%, transparent);
  --em: linear-gradient(90deg, var(--c-alt), var(--c-alt));
  --burst-1: var(--paper-50); --burst-2: var(--ink-950); --burst-3: var(--paper-50); --burst-4: var(--c-200); --burst-5: var(--ink-950);
}

A few things are behaviour, and app.js reads the look for them: square particles in swiss, the dust in the luxe spotlight, and the light that runs round the luxe card's metal edge on hover and during the flip.

app.js — the look, where behaviour differs
const LOOK = html.dataset.look;                              // set before the first paint (index.html)

if (LOOK === 'swiss') el.style.borderRadius = '0';
Look · glassOpen ↗
Look · swissOpen ↗
Look · luxeOpen ↗

Make it yours

  • Colours are tokens: pick a look with ?look=glass, swiss or luxe and a starting colourway with &color=ember, cobalt or magenta. A new colourway is a primitive scale, a [data-color] block of aliases, a --swatch- token, an entry in COLORS with its photo, and its name in the head script's list.
  • The product is PRODUCT, COLORS and SIZES in app.js. The name and the price are also written in index.html, so change them there too. The photos are 4:3 with a clear margin above and below the shoe, and the layout expects that framing.
  • The cart is an array in memory, and nothing is sent anywhere. add() is where you would call your cart API (and roll back if it failed), and Checkout is where you would go to your checkout page.
  • The feel is in the springs: pop for the pop-out's overshoot, flip for the turn, shake for the missing size, and the timings POP, FLY and FLIP_AT.
  • Accessibility is already there: native radio groups with visible labels, the missing size marked aria-invalid and described by the hint, a polite live region for every change, real buttons, the turned-away face inert and aria-hidden, focus moved with the turn, Escape to turn back, and a visible focus ring on every control. prefers-reduced-motion removes the pop-out, the flight, the turn and the ambient life.
The one idea worth keeping

Change the truth at the tap, and show it at the impact. add() changes the cart the moment you press, which is where a real cart API call goes; land() shows it when the shoe reaches the bag: the badge, the bag's name, the numbers on the receipt and the announcement. Everything you watch — the pop-out, the flight, the turn — lives in the gap between those two functions.