Library / Tutorial

Price roll,
step by step.

A pricing calculator that is the hero of its page. Drag your team from 1 to 100 people and the price rolls on odometer wheels while the avatars pop in; at each plan boundary (5 | 6 and 25 | 26 people) the slider holds for a moment, then snaps into the next plan, the plan name rolls letter by letter, the card springs open and the new features slide in. Switch to yearly and a "Save 20%" stamp slams down. We build it in six stages, and every stage runs on its own — try each one before you read the next.

6Stages
3Looks
0Libraries

This is the whole build of Price roll, in the order it was written. Each stage below is a live page: drag the slider with a finger or a mouse, or focus it and use the arrow keys, and switch between Monthly and Yearly. 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 are two more: spring.js and odometer.js. The prices are simulated in the page; nothing is sent anywhere.

1 · A pricing card, every colour a token

The first stage is the whole page in a 432 × 768 frame that app.js scales to fit the screen: the title on top, and under it one calculator card, 392 wide. Its top is fixed, so the slider never moves under the finger, and later it grows downwards. From the top: the plan name and a Monthly | Yearly switch, the price, a line that says what you pay per person, the slider with its bubble and tick labels, and the features.

index.html — the card
<article class="card" aria-labelledby="plan-label">
          <span class="card-bg" aria-hidden="true"><i class="cb cb1"></i></span>

          <!-- 1 · the plan and the billing period -->
          <div class="head">
            <p class="plan" id="plan-label">
              <span class="plan-name">Starter</span>
            </p>
            <!-- two named, mutually exclusive options = native radios in a radiogroup (one Tab stop; arrows choose) -->
            <div class="billing" role="radiogroup" aria-label="Billing period">
              <span class="seg-thumb" aria-hidden="true"></span>
              <label class="seg"><input type="radio" name="billing" value="monthly" checked><span class="seg-t">Monthly</span></label>
              <label class="seg"><input type="radio" name="billing" value="yearly"><span class="seg-t">Yearly</span></label>
            </div>
          </div>

          <!-- 2 · the price (aria-hidden); the real price is the sr-only text -->
          <div class="price">
            <p class="sr-only price-sr">Starter plan for 3 people: $36 per month, $12 per person, billed monthly.</p>
            <span class="cur" aria-hidden="true">$</span>
            <span class="odo dollars" aria-hidden="true">36</span>
            <span class="unit" aria-hidden="true">
              <span class="cents" style="display:none"><i class="dot">.</i><span class="odo cents-odo">00</span></span>
              <span class="per">/mo</span>
            </span>
          </div>
          <p class="meta" aria-hidden="true"><span class="pp">$12 per person</span><i class="sep">·</i><span class="billed">billed monthly</span></p>

          <!-- 4 · THE SLIDER: drawn at 3 people; step 2 makes it a working role="slider" -->
          <div class="slider" role="slider" tabindex="0" aria-label="Team size" aria-orientation="horizontal"
               aria-valuemin="1" aria-valuemax="100" aria-valuenow="3" aria-valuetext="3 people, Starter plan">
            <span class="track" aria-hidden="true">
              <span class="zones"><i class="zone z1"></i><i class="zone z2"></i><i class="zone z3"></i></span>
              <span class="rail"></span>
              <span class="fill-clip"><span class="fill"></span></span>
              <span class="notches"></span>
            </span>
            <span class="thumb" aria-hidden="true"><span class="thumb-body"><i class="th-a"></i><i class="th-b"></i><i class="th-c"></i></span></span>
          </div>
          <!-- the bubble over the thumb: the count -->
          <div class="bubble">
            <span class="bub-body">
              <span class="odo seats-odo" aria-hidden="true">3</span><span class="bub-unit" aria-hidden="true">people</span>
            </span>
            <i class="bub-tail" aria-hidden="true"></i>
          </div>
          <p class="tick-labels" aria-hidden="true"></p>

          <span class="rule" aria-hidden="true"></span>
          <!-- 5 · what the plan includes -->
          <ul class="feats" aria-label="Included in the plan"></ul>
        </article>

The billing switch is two native radio buttons in a role="radiogroup": one Tab stop, and the arrow keys choose. The slider is markup only for now; the next stage makes it a real control.

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. The synth look has a night (--sy-*), a sunset (--sun-*) and one tonal scale of eleven steps per plan: cyan, pink and orange.

style.css — the primitives (the start of them)
/* 1a · primitives */
:root {
  --sans: 'Geist', -apple-system, system-ui, sans-serif; --serif: 'Instrument Serif', Georgia, serif;
  --mono: 'Space Mono', ui-monospace, monospace; --round: 'Fredoka', 'Geist', system-ui, sans-serif; --neon: 'Tilt Neon', 'Geist', system-ui, sans-serif;
  --white: #FFFFFF; --black: #000000;

  /* synth: the night, the sunset, three neon hues */
  --sy-950: #07010F; --sy-900: #0E0322; --sy-850: #150530; --sy-800: #1D0840; --sy-700: #2A0B57; --sy-600: #3C1072;
  --sy-ink: #FFF4FB; --sy-ink-2: #E6D4F7; --sy-ink-3: #BFA6DE;
  --sun-1: #FFE66B; --sun-2: #FFA03C; --sun-3: #FF3D8B; --sun-4: #B81EFF; --neon-yellow: #FFE14D;
  --syc-50: #E9FEFF; --syc-100: #C8FBFC; --syc-200: #8FF4F6; --syc-300: #55EAEE; --syc-400: #2DE2E6; --syc-500: #14BFC4;
  --syc-600: #0C8E93; --syc-700: #086467; --syc-800: #053F42; --syc-900: #032426; --syc-950: #021617;

In the middle are the tier aliases. --t-50 to --t-950 point at one scale, chosen by data-tier on the stage. Nothing above this layer knows which plan it is drawing.

style.css — the tier aliases
/* 1b · tier aliases */
[data-look="synth"] :is(.stage[data-tier="starter"], .cb1) { --t-50: var(--syc-50); --t-100: var(--syc-100); --t-200: var(--syc-200); --t-300: var(--syc-300); --t-400: var(--syc-400); --t-500: var(--syc-500); --t-600: var(--syc-600); --t-700: var(--syc-700); --t-800: var(--syc-800); --t-900: var(--syc-900); --t-950: var(--syc-950); }
[data-look="synth"] :is(.stage[data-tier="pro"], .cb2) { --t-50: var(--syp-50); --t-100: var(--syp-100); --t-200: var(--syp-200); --t-300: var(--syp-300); --t-400: var(--syp-400); --t-500: var(--syp-500); --t-600: var(--syp-600); --t-700: var(--syp-700); --t-800: var(--syp-800); --t-900: var(--syp-900); --t-950: var(--syp-950); }
[data-look="synth"] :is(.stage[data-tier="team"], .cb3) { --t-50: var(--syo-50); --t-100: var(--syo-100); --t-200: var(--syo-200); --t-300: var(--syo-300); --t-400: var(--syo-400); --t-500: var(--syo-500); --t-600: var(--syo-600); --t-700: var(--syo-700); --t-800: var(--syo-800); --t-900: var(--syo-900); --t-950: var(--syo-950); }

On top are the semantic tokens: what a colour is for. Here are the slider's: the rail, the lit fill, the three tier zones, the ticks, the thumb (a striped sunset) and the bubble.

style.css — part of the synth set of semantic tokens
--rail: var(--sy-700); --fill: var(--t-50); --zone-1: var(--syc-600); --zone-2: var(--syp-500); --zone-3: var(--syo-600);
  --tick: color-mix(in oklab, var(--t-300) 70%, transparent); --tick-ink: var(--sy-ink-3);
  --thumb: linear-gradient(180deg, var(--sun-1) 0%, var(--sun-2) 45%, var(--sun-3) 100%); --thumb-edge: var(--sun-1); --thumb-core: var(--sun-3);
  --bubble: var(--sy-950); --on-bubble: var(--t-100); --bubble-edge: var(--t-400);

Each component then names the tokens it uses, and its rules read only those names.

style.css — the slider's component tokens
.slider   { --sl-rail: var(--rail); --sl-fill: var(--fill); --sl-zone-1: var(--zone-1); --sl-zone-2: var(--zone-2); --sl-zone-3: var(--zone-3);
            --sl-tick: var(--tick); --sl-thumb: var(--thumb); --sl-thumb-edge: var(--thumb-edge); --sl-thumb-core: var(--thumb-core); --sl-focus: var(--focus); }
style.css — component rules: the zones and the fill
.zone { position: absolute; top: 0; bottom: 0; }
.z1 { background: var(--sl-zone-1); } .z2 { background: var(--sl-zone-2); } .z3 { background: var(--sl-zone-3); }
.fill-clip { overflow: hidden; }
.fill { position: absolute; inset: 0; background: var(--sl-fill); will-change: transform; }   /* it only slides */

The slider's scale is not linear. Ticks at 1, 10, 25, 50 and 100 are evenly spaced and each stretch between them is linear, so the Starter plan (1 to 5 people) gets a usable 38 px of the track. posOf() turns a number of people into a position from 0 to 1, and the zones, the boundary notches and the tick labels are all drawn from it.

app.js — the slider's scale and the track's parts
// the slider's scale: ticks evenly spaced at 1, 10, 25, 50, 100 (each stretch linear), so every tier has room
const STOPS = [[1, 0], [10, 0.25], [25, 0.5], [50, 0.75], [100, 1]];
function posOf(v) {
  for (let i = 1; i < STOPS.length; i++) {
    const [v0, p0] = STOPS[i - 1], [v1, p1] = STOPS[i];
    if (v <= v1) return p0 + ((v - v0) / (v1 - v0)) * (p1 - p0);
  }
  return 1;
}
const xOf = (v) => INSET + posOf(v) * TRACK;                     // thumb centre for a value, in card-inner px
const BOUNDS = [5.5, 25.5];                                      // tier boundaries, between whole seats

// the track's parts: tier zones, boundary notches and the tick labels
const slider = $('.slider');
const zones = $$('.zone');
const zoneEdges = [0, posOf(BOUNDS[0]), posOf(BOUNDS[1]), 1];
zones.forEach((z, i) => { z.style.left = `${(zoneEdges[i] * 100).toFixed(3)}%`; z.style.width = `${((zoneEdges[i + 1] - zoneEdges[i]) * 100).toFixed(3)}%`; });
$('.notches').innerHTML = BOUNDS.map((b) => `<i style="left:${(posOf(b) * 100).toFixed(3)}%"></i>`).join('');
$('.tick-labels').innerHTML = STOPS.map(([v, p]) => `<span style="left:${(INSET + p * TRACK).toFixed(1)}px">${v}</span>`).join('');
Stage 1 · the card at 3 people; nothing reacts yetOpen ↗

2 · The slider works, and the price is computed

The price model is a list of plans, each with a range and a rate in cents per seat per month: Starter $12 (1–5), Pro $10 (6–25), Team $8 (26–100). Yearly is 20% off. quote() works out everything about one state in whole cents, and every amount the reader sees goes through Intl.NumberFormat.

app.js — the money, in whole cents
const usd0 = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 });
const usd2 = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
const money = (cents) => (cents % 100 === 0 ? usd0 : usd2).format(cents / 100);
// everything about one state, in whole cents
function quote(seats, yearly) {
  const tier = TIERS[tierOf(seats)];
  const perSeat = Math.round(tier.rate * (yearly ? 1 - YEARLY_OFF : 1));   // 960 · 800 · 640 yearly
  const perMonth = perSeat * seats;
  const year = perMonth * 12;
  return {
    tier, perSeat, perMonth, year,
    cents: yearly && perSeat % 100 !== 0,              // this tier's yearly prices have cents (Starter, Team)
    perPerson: `${money(perSeat)} per person`,
    billed: yearly ? `billed ${money(year)} yearly` : 'billed monthly',
    text: `${tier.name} plan for ${seats} ${seats === 1 ? 'person' : 'people'}: ${money(perMonth)} per month, ${money(perSeat)} per person, `
      + (yearly ? `billed ${money(year)} yearly. You save 20%.` : 'billed monthly.'),
  };
}

The slider is a div with role="slider", a tabindex, a label, a range and a value, and a value text that a screen reader says instead of the bare number: "12 people, Pro plan".

index.html — the slider
<div class="slider" role="slider" tabindex="0" aria-label="Team size" aria-orientation="horizontal"
               aria-valuemin="1" aria-valuemax="100" aria-valuenow="3" aria-valuetext="3 people, Starter plan">
            <span class="track" aria-hidden="true">
              <span class="zones"><i class="zone z1"></i><i class="zone z2"></i><i class="zone z3"></i></span>
              <span class="rail"></span>
              <span class="fill-clip"><span class="fill"></span></span>
              <span class="notches"></span>
            </span>
            <span class="thumb" aria-hidden="true"><span class="thumb-body"><i class="th-a"></i><i class="th-b"></i><i class="th-c"></i></span></span>
          </div>

A pointer drag works the same for mouse, pen and touch. setPointerCapture keeps the drag going when the finger leaves the slider, and touch-action: none in the CSS stops the page from scrolling. If you press on the thumb, the grip offset is kept, so the thumb does not jump to centre under your finger.

app.js — pointer down
slider.addEventListener('pointerdown', (e) => {
  if (e.button > 0) return;
  slider.setPointerCapture(e.pointerId);
  slider.dataset.pointer = '';                                    // focused by a press: no focus ring (keys bring it back)
  dragging = true;
  const x = pointerX(e);
  grab = Math.abs(x - xOf(seats)) <= THUMB_R + 4 ? x - xOf(seats) : 0;   // on the thumb: keep the grip
  follow(x - grab);
});
slider.addEventListener('pointermove', (e) => { if (dragging) follow(pointerX(e) - grab); });
app.js — from a position to a number of people
// the pointer's position, as a whole number of people
function follow(x) {
  setSeats(Math.round(clamp(valueAt(clamp((x - INSET) / TRACK, 0, 1)), MIN, MAX)));
}

The keyboard follows the slider pattern: arrows ±1, Page Up and Page Down ±10, Home and End.

app.js — the keyboard
// the keyboard: the slider pattern (arrows ±1, PageUp/PageDown ±10, Home/End)
slider.addEventListener('keydown', (e) => {
  delete slider.dataset.pointer;
  const step = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1, PageUp: 10, PageDown: -10 }[e.key];
  let next = null;
  if (step) next = seats + step;
  else if (e.key === 'Home') next = MIN;
  else if (e.key === 'End') next = MAX;
  if (next === null) return;
  e.preventDefault();
  setSeats(clamp(next, MIN, MAX));
  announceSoon();
});

A change sets the number, keeps aria-valuenow and aria-valuetext true, and draws.

app.js — one number changes, the page shows it
function setSeats(n) {
  n = clamp(Math.round(n), MIN, MAX);
  if (n === seats) return;
  seats = n;
  tier = tierOf(n);
  slider.setAttribute('aria-valuenow', String(n));
  slider.setAttribute('aria-valuetext', `${n} ${n === 1 ? 'person' : 'people'}, ${TIERS[tier].name} plan`);
  draw();
}

A polite live region says the plan and the price, once you stop, not once per seat while you drag.

app.js — the announcement
// the live region: one polite sentence per change, not one per seat while dragging
let sayTimer = 0;
function say(text) { live.textContent = ''; setTimeout(() => { live.textContent = text; }, 30); }
function announceSoon(ms = 450) {
  clearTimeout(sayTimer);
  sayTimer = setTimeout(() => { if (!dragging) say(quote(seats, yearly).text); }, ms);
}
Stage 2 · drag it or use the keys; everything changes at onceOpen ↗

3 · Springs, and the price on odometer wheels

From here on every motion is a spring: a mass on a spring with a damper, stepped in small fixed slices so it moves the same on a 60 Hz and a 120 Hz screen. Changing the target never moves the value, so an interrupted motion just bends towards the new target.

spring.js
// 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;
  }
}

The price becomes an odometer. Like a car's, column i is a wheel whose goal is floor(value / 10^i), each on its own spring. From $12 to $800 the units wheel really turns 788 digits, the tens 79 and the hundreds 8, so a fast drag makes the wheels spin. A wheel never trails its goal by more than 24 digits: past that it skips whole turns, which leaves the digit the same.

odometer.js — set(): only the targets move
// value: a whole number ≥ 0.  min: how many columns always show (2 for cents: "05")
  set(value, { min = 1, instant = this.calm } = {}) {
    for (const col of this.cols) {
      const goal = Math.floor(value / 10 ** col.r);
      const present = col.r < min || value >= 10 ** col.r;
      if (instant) {
        col.pos.value = col.pos.target = goal; col.pos.velocity = 0;
        col.shown.value = col.shown.target = present ? 1 : 0; col.shown.velocity = 0;
        continue;
      }
      const d = goal - col.pos.value;
      if (Math.abs(d) > this.lead) {                              // skip whole turns, keep the digit
        const turns = Math.ceil((Math.abs(d) - this.lead) / 10);
        col.pos.value += Math.sign(d) * 10 * turns;
      }
      col.pos.target = goal;
      col.shown.target = present ? 1 : 0;
    }
  }

Drawing a wheel is one transform: a strip printed 0…9,0 shifted by the wheel's position mod 10. While it is fast, a ghost copy trails it.

odometer.js — the roll
// the roll: shift the 11-cell strip by (position mod 10) cells; a ghost trails by the speed
      const m = ((p % 10) + 10) % 10;
      col.strip.style.transform = `translateY(${(-m * 100 / 11).toFixed(3)}%)`;
      const lag = Math.min(0.6, speed * 0.01) * Math.sign(col.pos.velocity);
      const g = (((m - lag) % 10) + 10) % 10;
      col.ghost.style.transform = `translateY(${(-g * 100 / 11).toFixed(3)}%)`;

The thumb is on springs too. While you drag it follows the finger stiffly; when you let go it settles onto the whole number with a small overshoot; keys move it in between. It stretches with its speed and leans into the drag, and the bubble trails it a little late.

app.js — the springs
// the springs (stiffness, damping; ζ = damping / 2√stiffness)
const spring = (stiffness, damping) => ({ stiffness, damping });
const DRAG = spring(560, 36), SNAP = spring(300, 19), KEYS = spring(340, 24);
const thumbX = new Spring(xOf(seats), SNAP);                   // the thumb's centre, card-inner px
const stretch = new Spring(0, spring(380, 20));                 // stretch along the drag
const lean = new Spring(0, spring(260, 20));                    // leans into the drag
const press = new Spring(0, spring(600, 30));                   // the squash under the finger
const bubX = new Spring(xOf(seats), spring(240, 17));           // the bubble trails the thumb, a little late
const centsShown = new Spring(0, spring(240, 22));
app.js — the thumb follows the finger, the number follows the thumb
// the pointer's position, as a whole number of people; the thumb follows the finger itself, not the number
function follow(x) {
  thumbX.target = clamp(x, INSET, INSET + TRACK);
  setSeats(Math.round(clamp(valueAt(clamp((x - INSET) / TRACK, 0, 1)), MIN, MAX)));
}
app.js — the thumb, drawn from its springs (never stretched past the card's edge)
function renderSlider() {
  const x = thumbX.value;
  // stretch and lean, but never past the card's inner edge
  const room = Math.min(x, INNER - x) / THUMB_R - 1;
  const s = clamp(stretch.value + 0.06 * press.value, -0.2, Math.max(0, Math.min(0.42, room)));
  const l = clamp(lean.value, -1, 1);
  thumbEl.style.transform = `translateX(${(x - THUMB_R).toFixed(2)}px)`;
  thumbBody.style.transform = `rotate(${(l * 9).toFixed(2)}deg) scale(${(1 + s).toFixed(3)}, ${(1 - 0.45 * s - 0.05 * press.value).toFixed(3)}) skewX(${(-l * 8).toFixed(2)}deg)`;
  fillEl.style.transform = `translateX(${(x - INSET - TRACK).toFixed(2)}px)`;

One loop steps every spring and every wheel, draws, and stops when everything is at rest.

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); } }
const springs = () => [thumbX, stretch, lean, press, bubX, centsShown];
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000)); last = now;
  // the thumb's stretch and lean follow its speed; the bubble trails it
  const v = thumbX.velocity;
  stretch.target = clamp(Math.abs(v) / 2600, 0, 0.42);
  lean.target = clamp(v / 1800, -1, 1);
  bubX.target = thumbX.value;
  springs().forEach((s) => s.step(dt));
  const odoMoving = dollars.step(dt) | centsOdo.step(dt) | seatsOdo.step(dt);
  draw();
  const still = !odoMoving && springs().every((s) => s.resting(0.002));
  if (!still) requestAnimationFrame(frame); else running = false;
}
Stage 3 · the thumb stretches, the wheels rollOpen ↗

4 · Three plans: the detent, the roll, the card that grows

Now the tier changes the whole card. The tier aliases are registered with @property as colours, so when data-tier changes, the browser can glide them: every gradient and glow that reads --t-* moves from cyan to pink to orange in 0.45 s.

style.css — the aliases, registered
@property --t-50  { syntax: '<color>'; inherits: true; initial-value: #EEFCFF; }
@property --t-100 { syntax: '<color>'; inherits: true; initial-value: #D8F7FF; }
@property --t-200 { syntax: '<color>'; inherits: true; initial-value: #A8EEFF; }
@property --t-300 { syntax: '<color>'; inherits: true; initial-value: #6FE2FF; }
@property --t-400 { syntax: '<color>'; inherits: true; initial-value: #3FD3FA; }
@property --t-500 { syntax: '<color>'; inherits: true; initial-value: #18B8E6; }
@property --t-600 { syntax: '<color>'; inherits: true; initial-value: #0E8FBA; }
@property --t-700 { syntax: '<color>'; inherits: true; initial-value: #0A6A8C; }
@property --t-800 { syntax: '<color>'; inherits: true; initial-value: #064A63; }
@property --t-900 { syntax: '<color>'; inherits: true; initial-value: #032C3B; }
@property --t-950 { syntax: '<color>'; inherits: true; initial-value: #021A24; }
style.css — the pink and orange aliases, and the glide
[data-look="synth"] :is(.stage[data-tier="starter"], .cb1) { --t-50: var(--syc-50); --t-100: var(--syc-100); --t-200: var(--syc-200); --t-300: var(--syc-300); --t-400: var(--syc-400); --t-500: var(--syc-500); --t-600: var(--syc-600); --t-700: var(--syc-700); --t-800: var(--syc-800); --t-900: var(--syc-900); --t-950: var(--syc-950); }
[data-look="synth"] :is(.stage[data-tier="pro"], .cb2) { --t-50: var(--syp-50); --t-100: var(--syp-100); --t-200: var(--syp-200); --t-300: var(--syp-300); --t-400: var(--syp-400); --t-500: var(--syp-500); --t-600: var(--syp-600); --t-700: var(--syp-700); --t-800: var(--syp-800); --t-900: var(--syp-900); --t-950: var(--syp-950); }
[data-look="synth"] :is(.stage[data-tier="team"], .cb3) { --t-50: var(--syo-50); --t-100: var(--syo-100); --t-200: var(--syo-200); --t-300: var(--syo-300); --t-400: var(--syo-400); --t-500: var(--syo-500); --t-600: var(--syo-600); --t-700: var(--syo-700); --t-800: var(--syo-800); --t-900: var(--syo-900); --t-950: var(--syo-950); }
.stage { transition: --t-50 .45s, --t-100 .45s, --t-200 .45s, --t-300 .45s, --t-400 .45s, --t-500 .45s, --t-600 .45s, --t-700 .45s, --t-800 .45s, --t-900 .45s, --t-950 .45s; }

The card's surface does not glide. There are three surface layers, one per tier, each resolved against its own tier's colours, and a tier change cross-fades their opacity, which the compositor does without repainting.

style.css — one surface per tier
/* one surface per tier, each resolved against its own tier's colours; a tier change cross-fades them */
.cb { position: absolute; inset: 0; border-radius: inherit; background: var(--surface); box-shadow: var(--surface-shadow); border: 1.5px solid var(--surface-edge);
      opacity: 0; visibility: hidden; transition: opacity .45s, visibility 0s .45s; will-change: opacity; }
.stage[data-tier="starter"] .cb1, .stage[data-tier="pro"] .cb2, .stage[data-tier="team"] .cb3 { opacity: 1; visibility: visible; transition: opacity .45s, visibility 0s; }

Past a tier boundary the slider holds: the value stays at the last seat of the current tier until the pointer is 12 px beyond the line, and meanwhile the thumb creeps after the pointer at a quarter of its pace. Then it lets go.

app.js — the soft detent
// The soft detent: past a tier boundary the value holds at the last seat of the current tier until the pointer is
// DETENT px beyond it; meanwhile the thumb only creeps a quarter of the way after the pointer (it resists). Then it
// lets go: the value jumps into the new tier and the thumb snaps after the pointer.
const DETENT = 12;
function follow(x) {
  const raw = Math.round(clamp(valueAt(clamp((x - INSET) / TRACK, 0, 1)), MIN, MAX));
  let value = raw, tx = clamp(x, INSET, INSET + TRACK);
  held = false;
  const dir = Math.sign(raw - seats);
  if (dir && tierOf(raw) !== tier) {
    const b = dir > 0 ? BOUNDS[tier] : BOUNDS[tier - 1];           // the first boundary in the way
    const bx = xOf(b);
    if ((x - bx) * dir < DETENT) {
      held = true;
      value = dir > 0 ? Math.floor(b) : Math.ceil(b);               // 5 | 6 · 25 | 26: the edge of this tier
      tx = bx - dir * 2 + (x - bx) * 0.25;
    }
  }
  thumbX.target = tx;
  setSeats(value);
}

The plan name rolls letter by letter, 45 ms apart. Each letter slot holds the letter going out and the one coming in, on a small drum; up when the plan grows, down when it shrinks. The slot widths blend between the measured widths of the two letters, so "Starter" narrows into "Pro" without a jump.

app.js — the name
// the plan name: each letter slot rolls (up when the plan grows, down when it shrinks), 45 ms apart
function setName(name, dir, instant) {
  letters.forEach((L, i) => {
    const ch = name[i] || '';
    if (!L.p) L.p = new Spring(0, spring(330, 20));
    if (instant) { L.from = L.to = ch; L.p.value = L.p.target = 0; L.p.velocity = 0; L.at = null; return; }
    if (ch === L.to) return;
    if (L.p.value >= 0.5) { L.from = L.to; L.p.value = 0; L.p.velocity = 0; }
    L.to = ch; L.dir = dir;
    L.at = clock + 0.04 + i * 0.045;
  });
}
app.js — one letter on its drum
const d = L.dir, kick = 1 + 0.16 * Math.sin(Math.PI * c);
    L.a.style.transform = `rotateX(${(d * p * 96).toFixed(2)}deg) scale(${kick.toFixed(3)})`;
    L.b.style.transform = `rotateX(${(d * (p - 1) * 96).toFixed(2)}deg) scale(${kick.toFixed(3)})`;
    L.a.style.opacity = clamp(1 - 2.4 * (p - 0.12), 0, 1).toFixed(3);
    L.b.style.opacity = clamp(2.4 * (p - 0.42), 0, 1).toFixed(3);

And the tier sets it all off: the colours, the name, the feature rows (in one by one, 0.11 s apart; out from the bottom up), and the card's height on a spring. Going up the card opens at once; going down it waits for the rows to leave.

app.js — a tier change
// the tier: colours glide (registered CSS colours), the name rolls, rows slide, the card springs
function setTier(was, instant) {
  const t = TIERS[tier];
  stage.dataset.tier = t.key;
  planSr.textContent = `${t.name} plan`;
  const up = tier > was;
  setName(t.name, up ? 1 : -1, instant);
  // rows: in, one after another, after the snap; out from the bottom up, then the card closes
  let order = 0;
  rows.forEach((r, i) => {
    const w = r.tier <= tier ? 1 : 0;
    if (!r.s) { r.s = new Spring(w, spring(320, 21)); r.c = new Spring(w, spring(520, 15)); }
    r.el.setAttribute('aria-hidden', String(!w));
    if (instant) { r.want = w; r.s.value = r.s.target = w; r.c.value = r.c.target = w; r.s.velocity = r.c.velocity = 0; r.at = null; return; }
    if (w === r.want) return;
    r.want = w;
    r.at = w ? clock + 0.1 + order++ * 0.11 : clock + (rows.length - 1 - i) * 0.05;
  });
  const n = rowsFor(tier);
  if (instant) { height.value = height.target = cardH(n); height.velocity = 0; }
  else if (up) height.target = cardH(n);
  else later(0.12, () => { height.target = cardH(rowsFor(tier)); });
  if (!instant) announceSoon();
}
Stage 4 · cross 5 | 6 and 25 | 26Open ↗

5 · The team, and the yearly stamp

One avatar per person: abstract initials, no real people. They pop in on a spring, 26 ms apart, nearest to the change first, and pop out the same way. Past twelve a "+N" chip counts the rest, and the row slides to stay centred.

app.js — the team
// the team: avatars pop in left to right (and out right to left), 26 ms apart; past twelve, "+N"
function setTeam(instant) {
  const want = Math.min(seats, SHOWN);
  const shownNow = avatars.filter((a) => a.want).length;
  avatars.forEach((a, i) => {
    const w = i < want ? 1 : 0;
    if (w === a.want && !instant) return;
    a.want = w;
    const order = w ? i - shownNow : shownNow - 1 - i;              // the nearest to the change goes first
    a.at = instant ? null : clock + Math.max(0, order) * 0.026;
    if (!a.s) a.s = new Spring(w, spring(430, 15));
    if (instant) { a.s.value = a.s.target = w; a.s.velocity = 0; } else if (a.at === null) a.s.target = w;
  });
  const extra = seats - SHOWN;
  const was = moreT.textContent;
  moreT.textContent = `+${Math.max(1, extra)}`;
  moreEl.dataset.on = extra > 0 ? '1' : '';
  if (was !== moreT.textContent && extra > 0) { pop.value = 0.82; pop.velocity = 4; }
  const rowW = (want - 1) * AV_STEP + AV + (extra > 0 ? CHIP_GAP + CHIP_W : 0);
  teamOff.target = (INNER - rowW) / 2;
  if (instant) { teamOff.value = teamOff.target; teamOff.velocity = 0; }
}

The Monthly | Yearly switch is still two native radios. Its thumb now runs across on a spring and stretches as it goes.

app.js — the switch's thumb
// the billing switch's thumb: it runs across, stretching (two segments of 74 px)
const segThumb = $('.seg-thumb');
function renderBilling() {
  const b = bill.value, st = clamp(billStretch.value, 0, 0.35);
  segThumb.style.transform = `translateX(${(b * 74).toFixed(2)}px) scaleX(${(1 + st).toFixed(3)})`;
  anim(segThumb, busy(bill) || busy(billStretch));
}
app.js — yearly
function setYearly(on, { instant = false } = {}) {
  if (on === yearly && !instant) return;
  const changed = on !== yearly;
  yearly = on;
  radios[on ? 1 : 0].checked = true;
  segs.forEach((s, i) => s.classList.toggle('is-on', i === (on ? 1 : 0)));
  bill.target = on ? 1 : 0;
  if (changed) billStretch.velocity += 5;
  stamp.set(on ? spring(620, 21) : spring(160, 2 * Math.sqrt(160)));
  stamp.target = on ? 1 : 0;
  stampLanded = !on;
  if (instant) { stamp.value = bill.value = on ? 1 : 0; stamp.velocity = 0; }
  showPrice(instant);
  if (changed && !instant) say(`${on ? 'Yearly billing, save 20%.' : 'Monthly billing.'} ${quote(seats, yearly).text}`);
}

Choosing Yearly brings the stamp. It is decorative, so it is aria-hidden; the live region says "Yearly billing, save 20%." with the new price.

index.html — the stamp
<span class="stamp" aria-hidden="true"><span class="stamp-in"><b>Save 20%</b><small>billed yearly</small></span></span>

It slams down from 2.2 times its size and −34°, and squashes on contact; lifting off, it rises and fades.

app.js — the stamp
// the stamp: it slams down from ×2.2 and −34°, squashes on contact; lifting off, it rises and fades
  const p = stamp.value, st = stampEl.style;
  if (yearly) {
    const q = stampHit.value;
    st.opacity = clamp(p * 3, 0, 1).toFixed(3);
    st.transform = `rotate(${(-8 - 26 * (1 - p)).toFixed(2)}deg) scale(${((1 + 1.2 * (1 - p)) * (1 + 0.16 * q)).toFixed(3)}, ${((1 + 1.2 * (1 - p)) * (1 - 0.24 * q)).toFixed(3)})`;
    if (!stampLanded && p >= 0.98) {                              // contact
      stampLanded = true;
      stampHit.velocity += 9;
    }
  } else {
    const c = clamp(p, 0, 1);
    st.opacity = c.toFixed(3);
    st.transform = `translateY(${(-18 * (1 - c)).toFixed(2)}px) rotate(${(-8 - 10 * (1 - c)).toFixed(2)}deg) scale(${(1 + 0.25 * (1 - c)).toFixed(3)})`;
  }
  st.display = p < 0.01 ? 'none' : 'block';
Stage 5 · the team pops in; choose YearlyOpen ↗

6 · The finish

A tier crossing gets an impact. Two rings expand out of the thumb, sparks are thrown up and fall under gravity, the card flashes and the thumb squashes. Going down a tier it is the same moment, quieter.

app.js — the impact
function impact(up) {
  const r = card.getBoundingClientRect(), k = scaleNow();
  const fr = frameEl.getBoundingClientRect();
  impactX = (r.left - fr.left) / k + 18 + thumbX.target;
  impactY = (slider.getBoundingClientRect().top - fr.top) / k + 22;
  impactPow = up ? 1 : 0.6;
  ringAge = 0; sparkAge = 0;
  ring.value = 0; ring.velocity = 0; ring.target = 1;
  bump.velocity += up ? 6 : 3.5;
  flash.velocity += up ? 9 : 5;
  stretch.velocity += up ? -7 : -4;                             // the thumb squashes as it lands
  sparks.forEach((p) => { p.v0 = p.v * (up ? 1 : 0.7); });
}
app.js — the sparks fall
const g = 520;                                                // they fall
  sparks.forEach((p) => {
    const t = sparkAge, v = p.v0 || 0;
    const life = clamp(1 - t / 0.95, 0, 1);
    if (!life || !v) { p.el.style.opacity = '0'; return; }
    const x = impactX + Math.cos(p.a) * v * t, y = impactY + Math.sin(p.a) * v * t + 0.5 * g * t * t;
    const vy = Math.sin(p.a) * v + g * t, vx = Math.cos(p.a) * v;
    const ang = Math.atan2(vy, vx);
    p.el.style.opacity = Math.min(1, life * 1.6).toFixed(3);
    p.el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${(ang + (LOOK === 'clay' ? p.spin * t : 0)).toFixed(3)}rad) scale(${(p.z * (0.4 + 0.6 * life)).toFixed(3)})`;
  });

The whole scene jolts too: one more spring, written into a CSS variable that scales the frame.

app.js — draw(), with the jolt
function draw(dt = 0) {
  const t = calm ? 0 : studio.now() / 1000;
  html.style.setProperty('--bump', (1 + 0.035 * bump.value).toFixed(4));
  renderSlider(); renderBilling(); renderTeam(); renderName(); renderRows(); renderCard();
  dollars.render(); centsOdo.render(); seatsOdo.render();
  if (!calm) { renderFx(dt); renderAmbient(t); }
}
style.css — the frame reads it
.frame { position: relative; width: 432px; height: 768px; flex: none; transform: scale(calc(var(--k, 1) * var(--bump, 1))); }

Each look has ambient life, drawn from a clock. In synth the grid floor runs towards you, and runs faster while the slider moves.

app.js — synth's floor
// the floor runs forward on the clock, and faster while the slider moves (one 32 px cell = one loop)
    const run = t * 18 + floorRun;
    amb.plane.style.transform = `translateY(${((run % 32) - 32).toFixed(2)}px)`;
    amb.horizon.style.opacity = (0.8 + 0.2 * Math.sin(t * 0.8)).toFixed(3);
    amb.stars.style.opacity = (0.75 + 0.25 * Math.sin(t * 1.7)).toFixed(3);

With prefers-reduced-motion every spring is critically damped: no overshoot, no squash. The wheels change at once, and there are no sparks, no glide and no ambient life.

app.js — reduced motion
// With reduced motion every spring is critically damped: no overshoot, no squash, no sparks.
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
style.css — reduced motion
/* ---- 14. reduced motion: no glide, no ambient (app.js also critically damps every spring) ---- */
@media (prefers-reduced-motion: reduce) {
  .stage, .cb, [data-look] .amb .field, .stage[data-tier] .cb { transition: none; }
  .seg { transition: none; }
  .touch { transition: none; }
  .fx { display: none; }
}

At 100 people the bubble opens into a "Need more? Talk to us" button; in this demo it only announces that talking to sales is simulated. The frame gets its footer, the tutorial pill and the credits, and the look is chosen before the first paint, so the page never flashes the wrong one.

index.html — the look, before the first paint
<!-- before the first paint: the look (?look=blueprint|clay|synth, or a|b|c), so the page never flashes the wrong one.
       Optional ?seats=1…100 and ?billing=yearly set the starting state (for links and for the preview scripts). -->
  <script>
    (() => {
      const html = document.documentElement, q = new URLSearchParams(location.search);
      const look = { a: 'blueprint', b: 'clay', c: 'synth' }[q.get('look')] || q.get('look');
      html.dataset.look = ['blueprint', 'clay', 'synth'].includes(look) ? look : 'synth';   // the default: the strongest look
    })();
  </script>
Stage 6 · the finished calculatorOpen ↗

Three looks, one token system

The finished file has three looks: blueprint, clay and synth. They share the markup, the script and the token layers. Each look is its own primitives, three tier aliases, one block of semantic tokens on [data-look] .stage, plus the shapes it draws differently: a technical drawing that measures its own height, a puffy clay card in a world that takes the plan's colour, and a neon card over a running grid.

style.css — its header: the sections, and the token layers
/* Price Roll v4 — a team-size pricing calculator as the hero of a page. Drag the slider: the price rolls on odometer
   wheels, the avatars pop, and at each tier the card changes plan, colour and size. Three looks
   (?look=blueprint|clay|synth), each a whole world; inside each look the card takes its tier's colour.

   1 tokens: primitive → tier alias → semantic → component     2 stage, frame, ambient     3 the frame's words
   4 the card and its rows   5 the price (odometer)   6 the team   7 the slider and its bubble   8 the features
   9 the impact   10 footer   11 look A · blueprint   12 look B · clay   13 look C · synth   14 reduced motion

   ---- 1. TOKENS: three layers (plus a component layer) ----------------------------------------------------------
   1a PRIMITIVES (:root). Raw, hand-tuned values with no meaning: one tonal scale 50…950 per hue, three hues per look
        blueprint  --bpc (cyan) --bpm (mint) --bpa (amber) and the paper blues --bp-*, ink --bp-ink*, redline --bp-red
        clay       --clm (mint) --cls (sun) --clb (berry) and the candy avatar colours --candy-*
        synth      --syc (cyan) --syp (pink) --syo (orange), the night --sy-*, the sunset --sun-*
   1b TIER ALIASES ([data-look] .stage[data-tier]). --t-50 … --t-950 point at the tier's scale in this look
        (blueprint: starter cyan · pro mint · team amber; clay: mint · sun · berry; synth: cyan · pink · orange).
        They are registered (@property), so crossing a tier glides every colour that uses them in 0.6 s.
   1c SEMANTIC TOKENS (per look, on .stage, so they resolve against the gliding aliases):
        field    --field-bg --grid-major --grid-minor --glow --on-field --on-field-muted --chip --on-chip --em
        card     --surface --surface-edge --surface-shadow --ink --ink-muted --ink-faint --rule --accent --on-accent
                 --accent-ink --focus
        price    --digit --digit-stroke --digit-glow --well --well-edge --cur-ink --stamp --on-stamp
        slider   --rail --fill --zone-1/2/3 --tick --tick-ink --thumb --thumb-edge --thumb-core --bubble --on-bubble
                 --bubble-edge
        controls --seg --seg-edge --seg-ink --seg-on --on-seg-on --check --on-check --tag-1/2 --on-tag-1/2
        team     --av-1…6 --on-av --more --on-more
        frame    --pill --pill-edge --on-pill --on-pill-muted --pill-icon --on-pill-icon --ring --lockup
        impact   --spark-1…4 --fx-ring
      On <html>: --page (behind the stage; app.js also gives it to <meta name="theme-color">).
   1d COMPONENT TOKENS: each component maps the semantic tokens it uses to its own names (.card { --card-bg … }).
   Component rules (sections 2–10) use only component or semantic tokens. The exceptions are transparency helpers:
   color-mix(… N%, transparent) and the `transparent` keyword. Look sections (11–13) shape parts, still with tokens. */

Even the page behind the stage is a token of the look; app.js also gives it to the browser's theme colour.

style.css — the page behind the stage
[data-look="blueprint"] { --page: var(--bp-950); }
[data-look="clay"] { --page: var(--clm-800); }
[data-look="synth"] { --page: var(--sy-950); }

Here is one complete semantic set, clay's. Its world is the tier's saturated colour, so the card's ink, the wells and the slider all read from --t-*.

style.css — the clay set of semantic tokens
/* B · CLAY: soft 3D clay. The world takes the tier's saturated colour; a puffy pastel card, wells, a squishy ball */
[data-look="clay"] .stage {
  --field-bg: none;
  --field-starter: radial-gradient(389px 353px at 916px 930px, color-mix(in oklab, var(--clm-500) 70%, var(--clm-600)), transparent 72%),
                   linear-gradient(180deg, var(--clm-600) 0, var(--clm-600) 730px, var(--clm-700) 1053px, var(--clm-800) 1268px);
  --field-pro: radial-gradient(389px 353px at 916px 930px, color-mix(in oklab, var(--cls-500) 70%, var(--cls-600)), transparent 72%),
               linear-gradient(180deg, var(--cls-600) 0, var(--cls-600) 730px, var(--cls-700) 1053px, var(--cls-800) 1268px);
  --field-team: radial-gradient(389px 353px at 916px 930px, color-mix(in oklab, var(--clb-500) 70%, var(--clb-600)), transparent 72%),
                linear-gradient(180deg, var(--clb-600) 0, var(--clb-600) 730px, var(--clb-700) 1053px, var(--clb-800) 1268px);
  --grid-major: transparent; --grid-minor: transparent; --glow: color-mix(in oklab, var(--t-300) 40%, transparent);
  --on-field: var(--white); --on-field-muted: var(--t-50); --chip: var(--white); --on-chip: var(--t-800);
  --em: linear-gradient(180deg, var(--t-100), var(--t-200));
  --ink: var(--t-950); --ink-muted: var(--t-800); --ink-faint: var(--t-700); --rule: color-mix(in oklab, var(--t-400) 55%, transparent);
  --accent: var(--t-400); --on-accent: var(--t-950); --accent-ink: var(--t-800); --focus: var(--t-900);
  --digit: var(--white); --digit-stroke: transparent; --digit-glow: var(--t-800);
  --well: linear-gradient(180deg, var(--t-700), var(--t-600) 55%, var(--t-500)); --well-edge: var(--t-800); --cur-ink: var(--t-700);
  --stamp: var(--tomato); --on-stamp: var(--white);
  --rail: color-mix(in oklab, var(--t-700) 62%, var(--t-400)); --fill: linear-gradient(180deg, var(--white), var(--t-100) 55%, var(--t-200)); --zone-1: var(--clm-300); --zone-2: var(--cls-300); --zone-3: var(--clb-300);
  --tick: var(--t-500); --tick-ink: var(--t-800);
  --thumb: radial-gradient(circle at 34% 28%, var(--t-100) 0%, var(--t-400) 26%, var(--t-600) 62%, var(--t-800) 100%);
  --thumb-edge: var(--t-600); --thumb-core: var(--t-500);
  --bubble: var(--t-700); --on-bubble: var(--white); --bubble-edge: var(--t-800);
  --seg: var(--t-100); --seg-edge: var(--t-600); --seg-ink: var(--t-800); --seg-on: var(--t-700); --on-seg-on: var(--white);
  --check: var(--t-600); --on-check: var(--white);
  --tag-1: var(--cls-300); --on-tag-1: var(--cls-950); --tag-2: var(--clb-300); --on-tag-2: var(--clb-950);
  --av-1: var(--candy-orange); --av-2: var(--candy-sky); --av-3: var(--candy-lilac); --av-4: var(--candy-yellow); --av-5: var(--candy-mint); --av-6: var(--candy-pink);
  --on-av: var(--candy-ink); --more: var(--t-700); --on-more: var(--white);
  --pill: var(--white); --pill-edge: var(--white); --on-pill: var(--t-950); --on-pill-muted: var(--t-700);
  --pill-icon: var(--t-600); --on-pill-icon: var(--white); --ring: var(--white); --lockup: none;
  --spark-1: var(--white); --spark-2: var(--t-200); --spark-3: var(--cls-300); --spark-4: var(--clb-300); --fx-ring: var(--white);

A few things are behaviour, and app.js reads the look for them: clay's thumb is jelly (less damping on its stretch and lean), the number of sparks (and clay's spin as they fall), blueprint's live height dimension, and synth's floor.

app.js — the look, where behaviour differs
const JELLY = LOOK === 'clay';
const DRAG = spring(560, 36), SNAP = spring(300, 19), KEYS = spring(340, 24);
const thumbX = new Spring(xOf(seats), SNAP);                   // the thumb's centre, card-inner px
const stretch = new Spring(0, spring(380, JELLY ? 9 : 20));     // stretch along the drag (clay: a jelly wobble)
const lean = new Spring(0, spring(260, JELLY ? 11 : 20));       // leans into the drag
Look · blueprintOpen ↗
Look · clayOpen ↗
Look · synthOpen ↗

Make it yours

  • Looks are tokens: pick one with ?look=blueprint, clay or synth (or a, b, c). Set the starting state with &seats=1…100 and &billing=yearly.
  • The prices are TIERS in app.js: a name, a range and a rate in cents per seat per month, with YEARLY_OFF for the discount. The boundaries are also written in tierOf() and BOUNDS, so change them together. Money is formatted by Intl.NumberFormat('en-US', USD); the "$" in front of the wheels is written in index.html.
  • The features are FEATURES: a label and the tier that unlocks it. Each row adds 22 px to the card.
  • Nothing is sent anywhere. quote() is where a real site would read its prices from its billing API, and the "Talk to us" button is where you would open your contact form.
  • The feel is in the springs: DRAG, SNAP and KEYS for the thumb, DETENT for how far past a boundary the slider lets go, and the odometers' stiffness and damping.
  • Accessibility is already there: a real role="slider" with the arrow, Page and Home/End keys and a spoken value text, native radios for the billing period, the price as text for screen readers while the wheels are aria-hidden, one polite announcement per change, and a visible focus ring. prefers-reduced-motion removes the overshoot, the rolling, the impact and the ambient life.
The one idea worth keeping

Keep the number exact and let the motion be free. seats changes once per whole person and quote() prices it in whole cents at that moment; the screen-reader text, the meta line and the announcement read those numbers. The thumb, the wheels, the letters and the card only chase them on springs, so they can overshoot, lag and spin, and still come to rest on the right total.