Library / Tutorial

Cards carousel,
step by step.

A carousel of picture cards that never runs out. Every card is a window whose picture moves inside it; one position on a spring that you drag, throw, snap, or step with the arrows, the dots, the keys and the wheel; three looks that move in three ways; and a detail that flies out of the card. Six stages, each a live page you can use.

6Stages
3Looks
0Libraries

This is the whole build of Cards carousel, in the order it was written: the showcase section of a website, where a row of picture cards never runs out. It comes in three looks, and each one moves in its own way. Films (Aurora Cinemas) line the inside of a curved wide screen. Icons (Objet & Co.) are framed products hanging on threads in a gallery window, and they swing. Cars (the Redline Collection) drift in sideways on tyre smoke and flash their own headlights when they land. Each stage below is a live page you can use. 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 section, style.css for the tokens and the looks, and app.js for the carousel. From stage 3 there are two more: spring.js and mechanics.js. There is no library and no build step. The film posters are © their studios and shown for illustration in the live demo only (the download has plain placeholders in their place); the products and the cars are pictures generated with Google Gemini, not official photos.

1 · The look: a section of a real site

The carousel lives in a page, so the first stage is the page: a site header (brand, links from a tablet up, a call to action), the showcase's words, and the place the carousel will stand. Every element is there, but the words are empty, because app.js fills them for the look.

index.html — the header and the showcase
<header class="site">
  <a class="brand" href="#"><span class="mark" aria-hidden="true"></span><span class="brand-name"></span></a>
  <nav class="links" aria-label="Main"></nav>
  <a class="cta" href="#"></a>
</header>

<main>
  <section class="showcase" aria-labelledby="sc-title">
    <div class="intro">
      <p class="eyebrow"></p>
      <h2 id="sc-title"></h2>
      <p class="lede"></p>
    </div>

    <!-- ============================== THE COMPONENT ==============================
         A carousel of picture cards. Each card is a window: the picture inside moves on its own as the card moves
         and as you tilt it, so it reads as depth. The three looks move in three different ways — Films line the
         inside of a curved wide screen, Icons hang on threads in a shop window and swing, Cars drift in sideways with tyre
         smoke and flash their headlights when they land. Drag (with a throw), the arrows, the
         dots, the arrow keys or the wheel all move it; it never runs out. -->
    <div class="carousel" aria-roledescription="carousel">
      <div class="rig"><div class="rig-in"></div></div>   <!-- stage 2 puts the cards in here -->
    </div>

    <div class="after">
      <a class="more" href="#"></a>
      <p class="credit-line"></p>
    </div>
  </section>

A script in the page's head picks the look from the address before the first paint, so the page never flashes the wrong one: ?look=films, icons or cars (or a, b, c).

index.html — the look, before the first paint
(() => {
  const html = document.documentElement, q = new URLSearchParams(location.search);
  const look = { a: 'films', b: 'icons', c: 'cars' }[q.get('look')] || q.get('look');
  html.dataset.look = ['films', 'icons', 'cars'].includes(look) ? look : 'films';
})();

Every colour, face and radius comes from a CSS variable, in three layers. At the bottom are the primitives: values with no meaning, one ramp per look.

style.css — the primitives
:root {
  --n-0: #fff; --b-40: rgb(0 0 0 / .4);
  /* films: a dark screening room lit by the poster in front of you, one gold for the ratings */
  --fi-950: #06050A; --fi-900: #0E0C14; --fi-800: #18151F; --fi-500: #625C70; --fi-300: #A8A2B8; --fi-100: #F3F1F8; --fi-red: #FF3D5A; --fi-lamp: #F5C451; --fi-gold: #F5C451;
  /* icons: a gallery window by day — warm white wall, a glossy floor, black ink, a brass hand */
  --ic-50: #FBF9F6; --ic-100: #F1ECE4; --ic-200: #E2DACE; --ic-ink: #141210; --ic-ink-2: #6B655C; --ic-brass: #B4884A;
  /* cars: graphite night, headlight amber, chalk */
  --ca-950: #07080A; --ca-900: #0D0F12; --ca-800: #161A1F; --ca-500: #59616B; --ca-300: #9AA3AD; --ca-100: #EEF1F4; --ca-amber: #FFB020; --ca-red: #FF4D3A;

  --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
  --anton: 'Anton', 'Archivo', sans-serif;
  --wide: 'Archivo', 'Geist', sans-serif;
  --serif: 'Fraunces', 'Times New Roman', serif;
  --mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

On top are the semantic tokens: what a value is for. The page, the words, the accent, the cards' corners and shadow, the buttons and the dots get their own names, and a whole set is written once per look on [data-look]. The title even changes face: a geometric sans with a serif italic for Films, a serif for Icons, a wide italic in capitals for Cars. Here is Films.

style.css — the semantic tokens of Films
[data-look="films"] {
  --page: var(--fi-950); --on-field: var(--fi-100); --on-field-muted: var(--fi-300); --chip: var(--fi-red); --on-chip: var(--fi-100);
  --accent: var(--fi-red); --on-accent: var(--fi-100); --light: rgb(255 200 140 / .08); --avatar-ring: var(--fi-100); --lockup: none;
  --title-face: var(--sans); --title-w: 700; --title-size: 58px; --title-track: -.045em; --title-case: none; --em-face: var(--serif); --em-style: italic; --em-w: 300; --em: var(--fi-gold);
  --card-r: 16px; --card-edge: rgb(255 255 255 / .1); --card-drop: 0 30px 50px -24px rgb(0 0 0 / .9);
  --btn: rgb(255 255 255 / .06); --btn-edge: rgb(255 255 255 / .14); --on-btn: var(--fi-100); --dot: rgb(255 255 255 / .22); --dot-on: var(--fi-lamp);
  --pill: var(--fi-900); --pill-edge: rgb(255 255 255 / .12); --on-pill: var(--fi-100); --on-pill-muted: var(--fi-300); --pill-icon: var(--fi-red); --on-pill-icon: var(--fi-100);
}

The words live in one table in app.js, one entry per look: the site's brand, links and call to action, the showcase's eyebrow, title and lede, what the carousel is called (its aria-label), and the line that credits the pictures. Here is Films' entry as it stands in stage 1; stage 2 adds its cards.

app.js — Films, in the look table
films: {
  site: { brand: 'Aurora Cinemas', links: ['Films', 'Cinemas', 'Offers'], cta: 'Book tickets',
    eyebrow: 'Now showing · Laser 4K', h2: 'Tonight on the <em>big screen</em>', lede: 'Six films, one wide screen. Drag to choose yours, then watch the trailer.', more: 'All films and showtimes →' },
  label: 'Now showing',
  credit: 'Posters © their studios · shown for illustration',
},
app.js — filling the words for the look
const carousel = $('.carousel');
// the site around it: the header, the section's words
const SITE = LOOK.site;
$('.brand-name').textContent = SITE.brand;
$('.links').innerHTML = SITE.links.map((l) => `<a href="#">${l}</a>`).join('');
$('.cta').textContent = SITE.cta;
$('.eyebrow').textContent = SITE.eyebrow;
$('#sc-title').innerHTML = SITE.h2;
$('.lede').textContent = SITE.lede;
$('.more').textContent = SITE.more;
carousel.setAttribute('aria-label', LOOK.label);
$('.credit-line').textContent = LOOK.credit;

The carousel's place is a box with a fixed height that grows with the screen. Inside it, .rig is the stage, with the perspective the cards will need from stage 3, and .rig-in holds the cards. On a screen 900 px wide or more the showcase becomes a grid: the words on the left, the link and the credit on the right, the carousel under both.

style.css — the carousel's empty stage
.carousel { position: relative; height: clamp(460px, 64vh, 560px); margin: 8px calc(clamp(16px, 4vw, 40px) * -1) 0; outline: none; }
.rig { position: absolute; left: 0; right: 0; top: 0; bottom: 60px; perspective: 1100px; overflow: hidden; }
.rig-in { position: absolute; inset: 0; transform-style: preserve-3d; transform-origin: 50% 50%; }
style.css — from 900 px up
@media (min-width: 900px) {
  .showcase { display: grid; grid-template-columns: 1fr auto; align-items: end; column-gap: 40px; }
  .intro { grid-column: 1; } .after { grid-column: 2; grid-row: 1; align-items: flex-end; margin: 0 0 6px; }
  .carousel { grid-column: 1 / -1; height: clamp(560px, 66vh, 700px); }
}
Stage 1 · Films: the page and an empty stageOpen ↗
Stage 1 · the same markup, the Icons tokensOpen ↗

2 · A card: the window and its picture

A card's markup goes into a <template>: markup the browser parses but never shows, ready to be cloned. A card is a labelled slide (role="group" with aria-roledescription="slide") with three parts: the window with its picture, a glare for stage 6, and the words.

index.html — one card, as a template
<!-- one card: a window with its picture, a glare, and the words that float in front (app.js fills them per look) -->
<template id="card">
  <article class="card" role="group" aria-roledescription="slide">
    <div class="win"><img class="pic" alt="" draggable="false"></div>
    <i class="glare" aria-hidden="true"></i>
    <div class="info"></div>
  </article>
</template>

The idea of the whole component is in two CSS rules. The window clips (overflow: hidden), and the picture inside it is bigger than the window: 128% wide, 116% tall, pulled up and to the left. That leaves room for the picture to slide inside its frame while the card moves, which is what makes a flat photo read as depth.

style.css — the card, the window, the picture
.card { --cw: 200px; --ch: 300px; position: absolute; left: 50%; top: 50%; width: var(--cw); height: var(--ch); margin: calc(var(--ch) / -2) 0 0 calc(var(--cw) / -2);
  transform-style: preserve-3d; will-change: transform; }
.win { position: absolute; inset: 0; overflow: hidden; border-radius: var(--card-r); background: #222; box-shadow: inset 0 0 0 1px var(--card-edge), var(--card-drop); }
.pic { position: absolute; left: -14%; top: -8%; width: 128%; height: 116%; object-fit: cover; pointer-events: none; }
.glare { position: absolute; inset: 0; border-radius: var(--card-r); pointer-events: none; opacity: 0; mix-blend-mode: soft-light;
  background: radial-gradient(60% 50% at var(--gx, 50%) var(--gy, 30%), rgb(255 255 255 / .9), transparent 70%); }
.info { position: absolute; left: 0; right: 0; bottom: 0; padding: 16px; pointer-events: none; opacity: 0; }

Each look's entry in the table now gets its cards: six real things and a seventh card that leads to all of them. Each card has a name (used for its label), a picture and the words it carries, as a little HTML. The stage pages share one folder of pictures one level above them, so their paths start ../../img/; in the finished source the pictures sit next to the page, in img/.

app.js — Films' cards
cards: [
  { name: 'Dune', img: '../../img/film-dune.jpg', html: '<span class="f-rate" style="--r:80"><b>8.0</b><i>IMDb</i></span><p class="f-tags"><span>Sci-fi</span><span>Epic</span></p><p class="f-meta">2021 · 2h 35m · Denis Villeneuve</p><p class="f-acts"><span class="f-play">▶&nbsp; Trailer</span><span class="f-add">+</span></p>' },
  { name: 'Interstellar', img: '../../img/film-interstellar.jpg', html: '<span class="f-rate" style="--r:87"><b>8.7</b><i>IMDb</i></span><p class="f-tags"><span>Sci-fi</span><span>Drama</span></p><p class="f-meta">2014 · 2h 49m · Christopher Nolan</p><p class="f-acts"><span class="f-play">▶&nbsp; Trailer</span><span class="f-add">+</span></p>' },
  { name: 'Blade Runner 2049', img: '../../img/film-blade-runner-2049.jpg', html: '<span class="f-rate" style="--r:80"><b>8.0</b><i>IMDb</i></span><p class="f-tags"><span>Neo-noir</span><span>Sci-fi</span></p><p class="f-meta">2017 · 2h 44m · Denis Villeneuve</p><p class="f-acts"><span class="f-play">▶&nbsp; Trailer</span><span class="f-add">+</span></p>' },
  { name: 'Oppenheimer', img: '../../img/film-oppenheimer.jpg', html: '<span class="f-rate" style="--r:83"><b>8.3</b><i>IMDb</i></span><p class="f-tags"><span>Drama</span><span>History</span></p><p class="f-meta">2023 · 3h 0m · Christopher Nolan</p><p class="f-acts"><span class="f-play">▶&nbsp; Trailer</span><span class="f-add">+</span></p>' },
  { name: 'Top Gun: Maverick', img: '../../img/film-top-gun-maverick.jpg', html: '<span class="f-rate" style="--r:82"><b>8.2</b><i>IMDb</i></span><p class="f-tags"><span>Action</span><span>Drama</span></p><p class="f-meta">2022 · 2h 10m · Joseph Kosinski</p><p class="f-acts"><span class="f-play">▶&nbsp; Trailer</span><span class="f-add">+</span></p>' },
  { name: 'Mad Max: Fury Road', img: '../../img/film-mad-max-fury-road.jpg', html: '<span class="f-rate" style="--r:81"><b>8.1</b><i>IMDb</i></span><p class="f-tags"><span>Action</span><span>Road</span></p><p class="f-meta">2015 · 2h 0m · George Miller</p><p class="f-acts"><span class="f-play">▶&nbsp; Trailer</span><span class="f-add">+</span></p>' },
  { name: 'All films', all: true, html: '<p class="all-k">This week</p><p class="all-n">42 films</p><p class="all-go">See all films <i>→</i></p>' },
],

card() clones the template and fills it. The last card has no picture of its own: its window becomes a mosaic of the others, with a count and the way to see them all. A picture that fails to load leaves a quiet gradient in the window instead of a broken image.

app.js — card(): clone, fill, place
function card(data, i) {
  const node = $('#card').content.firstElementChild.cloneNode(true);
  const c = { el: node, win: $('.win', node), pic: $('.pic', node), glare: $('.glare', node), info: $('.info', node), data, i };
  if (data.all) {                                            // the last card: a mosaic of the others, and the way to all of them
    node.classList.add('is-all');
    c.pic.remove();
    c.pic = document.createElement('div');
    c.pic.className = 'pic mosaic';
    c.pic.innerHTML = LOOK.cards.filter((o) => o.img).map((o) => `<i style="background-image:url('${o.img}')"></i>`).join('');
    c.win.prepend(c.pic);
  } else {
    c.pic.src = data.img;
    c.pic.alt = data.name;
    c.pic.addEventListener('error', () => c.win.classList.add('is-empty'), { once: true });   // a picture still to come
  }
  c.info.innerHTML = data.html;
  c.shade = document.createElement('i');
  c.shade.className = 'shade';
  c.win.append(c.shade);
  rigIn.append(node);
  return c;
}

There is no position yet, so the cards simply stand in a row from the middle, the first one in front. Each picture is pushed a little inside its window by its card's place: the window, standing still. Stage 3 replaces these lines.

app.js — for now, a row
const cards = LOOK.cards.map(card);
cards.forEach((c, k) => { c.el.setAttribute('aria-label', `${c.i + 1} of ${n}: ${c.data.name}`); if (k >= n) c.el.setAttribute('aria-hidden', 'true'); });
// until stage 3 gives the carousel a position, the cards stand in a row from the middle, the first in front, and each
// picture is pushed inside its window by its card's place (stage 3 moves both as the position changes)
cards.forEach((c, i) => {
  c.el.style.transform = `translate3d(${i * 266}px, 0, 0)`;
  c.pic.style.transform = `translate3d(${-Math.min(i, 1) * 8}%, 0, 0)`;
  if (i === 0) c.info.style.opacity = '1';
});

The rig is drawn for a phone, about 430 px square, and scaled to the screen: up to 1.7 times on a wide one, a little down on the narrowest. A ResizeObserver keeps the scale right when the window changes.

app.js — one size, scaled to the screen
// the whole rig is drawn for a phone and scaled up on wider screens (and a little down on the narrowest)
let sc = 1;
new ResizeObserver(() => {
  sc = clamp(Math.min(rig.clientWidth / 430, rig.clientHeight / 430), 0.82, 1.7);
  rigIn.style.transform = `scale(${sc.toFixed(3)})`;
}).observe(rig);

Each look dresses its card. A film card is a poster with a rating ring (a conic gradient), tags, the year and the director, and a Trailer button; an icon is a walnut gallery frame with a white mat and a museum caption; a car carries its year, name and three figures.

style.css — the film card's words
.f-rate { position: absolute; right: 10px; top: 10px; display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%;
  background: conic-gradient(var(--fi-gold) calc(var(--r, 80) * 1%), rgb(255 255 255 / .14) 0); }
.f-rate::before { content: ''; position: absolute; inset: 3px; border-radius: 50%; background: rgb(10 8 14 / .86); }
.f-rate b, .f-rate i { position: relative; line-height: 1; }
.f-rate b { font: 700 14px var(--sans); color: #fff; margin-top: 6px; }
.f-rate i { font: 600 7px var(--mono); letter-spacing: .1em; font-style: normal; color: var(--fi-gold); margin-bottom: 6px; }
.f-tags { display: flex; gap: 5px; margin: 0; }
.f-tags span { padding: 3px 8px; border-radius: 99px; font: 600 9.5px var(--sans); letter-spacing: .04em; color: #fff; background: rgb(255 255 255 / .16); }
.f-meta { margin: 0; font: 500 11px var(--sans); color: rgb(255 255 255 / .74); }
.f-acts { display: flex; gap: 6px; margin: 2px 0 0; }
.f-play { flex: 1; display: grid; place-items: center; height: 32px; border-radius: 9px; font: 650 12px var(--sans); color: #0A0810; background: #fff; }
.f-add { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; font: 500 18px var(--sans); color: #fff; background: rgb(255 255 255 / .16); }
style.css — the gallery frame
/* a gallery frame: black walnut, a white mat cut on a bevel round the picture, a museum caption under it */
[data-look="icons"] .card { --cw: 236px; --ch: 344px; margin-top: calc(var(--ch) / -2 - 8px); transform-origin: 50% -320px; border-radius: 3px;
  background: linear-gradient(135deg, #3A2E25, #1B1511 45%, #2B221B); box-shadow: inset 0 1px 0 rgb(255 255 255 / .16), inset 0 0 0 1px rgb(0 0 0 / .4); }
[data-look="icons"] .card::before { content: ''; position: absolute; inset: 9px; border-radius: 1px; background: linear-gradient(180deg, #FDFCF9, #F6F2EA);
  box-shadow: inset 0 2px 4px rgb(0 0 0 / .28), inset 0 0 0 1px rgb(0 0 0 / .1); }
[data-look="icons"] .win { inset: 26px 26px auto; height: 66%; border-radius: 0; background: var(--ic-200);
  box-shadow: 0 0 0 1px #fff, 0 0 0 2px rgb(150 130 100 / .35), 0 0 0 3px rgb(255 255 255 / .9), inset 0 1px 3px rgb(0 0 0 / .25); }
[data-look="icons"] .glare { border-radius: 3px; }
[data-look="icons"] .info { inset: auto 26px 24px; padding: 0; opacity: 1; display: flex; flex-direction: column; align-items: center; text-align: center; }
style.css — the See-all card
/* the See-all card: a mosaic of the set under a veil, a count, and the way on */
.mosaic { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; background: #000; }
.mosaic i { background: center / cover no-repeat; filter: saturate(.8) brightness(.8); }
.card.is-all .info { inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; gap: 2px;
  background: radial-gradient(70% 60% at 50% 50%, rgb(0 0 0 / .55), rgb(0 0 0 / .78)); border-radius: var(--card-r); }
.all-k { margin: 0; font: 600 10px var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--em); }
.all-n { margin: 0; font-family: var(--title-face); font-weight: var(--title-w); font-size: 34px; letter-spacing: -.03em; color: var(--on-field); }
.all-go { margin: 8px 0 0; display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px; border-radius: 999px; font-size: 12.5px; font-weight: 650; color: var(--on-accent); background: var(--accent); }
.all-go i { font-style: normal; }
Stage 2 · Films: the cards, standing in a rowOpen ↗
Stage 2 · Cars: the same cards, another lookOpen ↗

3 · The carousel's position, and wrapping it

The carousel owns one number: its position, in cards. 0 is the first card in front, 1 the second, 2.4 is part of the way from the third to the fourth. Everything on screen is worked out from that number. The number lives on a spring: a value with a target, a velocity, a stiffness and a damping, stepped in fixed slices of 1/240 s so it moves the same at 60 and 120 frames a second.

spring.js — all of it
// 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;
  }
}

With prefers-reduced-motion every spring is critically damped: it still arrives, it just never overshoots.

app.js — reduced motion is a damping
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
const critical = (stiffness) => ({ stiffness, damping: 2 * Math.sqrt(stiffness) });

The target is always a whole card, so wherever the value goes it ends up on a card. The position never wraps: after the seventh card it simply carries on to 7, 8, 9. wrapped() folds a position back into the set when we need to know which card it is.

app.js — the position
const pos = new Spring(0, spring(140, 22));                // the position, in cards; the target is always a whole card
const wrapped = (v) => (((v % n) + n) % n);
const active = () => wrapped(Math.round(pos.value));

How the number becomes a picture is a separate job, the mechanic, in mechanics.js. A mechanic is a function that gets the cards and returns how a drag maps to cards and a frame() that draws every card for a position. Keeping it apart is what lets stage 5 give each look its own movement without touching the carousel.

mechanics.js — what a mechanic is
// The mechanic: how the carousel's position becomes a picture. The carousel (app.js) owns the position — a number in
// cards that never runs out (2.4 is between the third and the fourth, 9 is the third again on a seven-card set) —
// and the arrows that move it. A mechanic only turns that number into a picture.
// A mechanic is MECH.name(ctx) → { unit, axis, repeat, frame(s) }:
//   ctx  = { rig, cards: [{ el, win, pic, glare, info, shade, data }], n (distinct cards), calm, spring, critical }
//   unit = how many px of drag move one card; axis = 'x' or 'y'; repeat = how many times the set is laid out
//   s    = { pos, vel (cards a second), tx, ty (the tilt of the front card, −1…1), dt, t, active (data index) }
// Until stage 5 the three looks share one plain row; stage 5 gives each look its own.
window.MECH = {};

Until stage 5 the three looks share one plain mechanic: a row. The important line is the first one in the loop. A card's distance from the position, i - pos, is folded into the range from −n/2 to n/2 by wrap(). On a seven-card set at position 0, card 6 is not six places to the right but one place to the left, so the row has no end. The rest is the place that distance gives: across, back, a little smaller, faded out far away, and the picture sliding the other way inside its window.

mechanics.js — the helpers, and a plain row
const wrap = (v, n, lo) => ((((v - lo) % n) + n) % n) + lo;        // v folded into [lo, lo + n)
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const f2 = (v) => v.toFixed(2);

// A ROW · the cards stand side by side, the one in front a little larger and the rest stepped back. A card's place is
// its distance from the position, folded round (wrap) so the row never runs out: on a seven-card set the card three
// to the right of the last one is the second. The picture slides inside its window against the card's travel, and the
// words show on the card in front.
MECH.films = MECH.icons = MECH.cars = ({ cards, n }) => {
  const W = 236, GAP = 30, STEP = W + GAP;                          // a card's width, the row's step
  return {
    unit: STEP * 0.8, axis: 'x', repeat: 1,
    frame({ pos }) {
      for (const [i, c] of cards.entries()) {
        const d = wrap(i - pos, n, -n / 2), ad = Math.abs(d);
        const x = Math.sign(d) * (ad < 1 ? ad * STEP : STEP + (ad - 1) * STEP * 0.84);
        const z = -Math.min(ad, 2) * 70, scale = 1 - Math.min(ad, 1) * 0.1;
        c.el.style.transform = `translate3d(${f2(x)}px, 0, ${f2(z)}px) scale(${f2(scale)})`;
        c.el.style.opacity = f2(clamp01((2.6 - ad) / 0.6));
        c.pic.style.transform = `translate3d(${f2(-Math.max(-1, Math.min(1, d)) * 8)}%, 0, 0)`;
        c.info.style.opacity = f2(clamp01(1 - ad * 2.2));
      }
    },
  };
};

In app.js the carousel asks the mechanic how it wants to be laid out (how many times to lay out the set), makes the cards, labels each "1 of 7: Dune" and so on, then creates the mechanic with them.

app.js — the cards, and their mechanic
const probe = MECH[look]({ rig: rigIn, cards: [], n, calm, spring, critical });   // ask the mechanic how it wants to be laid out
rigIn.replaceChildren();
const cards = [];
for (let r = 0; r < probe.repeat; r++) LOOK.cards.forEach((d, i) => cards.push(card(d, i)));
cards.forEach((c, k) => { c.el.setAttribute('aria-label', `${c.i + 1} of ${n}: ${c.data.name}`); if (k >= n) c.el.setAttribute('aria-hidden', 'true'); });
const mech = MECH[look]({ rig: rigIn, cards, n, calm, spring, critical });

go() moves the target by whole cards. goTo() takes the nearest way round to a card: from the last card to the first is one step forward, not six back. The previous and next buttons call go().

app.js — go, goTo, and the arrows
function go(by) { pos.target = Math.round(pos.target) + by; wake(); }
function goTo(i) {                                           // the nearest way round to card i
  const now = Math.round(pos.target), diff = (((i - wrapped(now)) % n) + n) % n;
  pos.target = now + (diff > n / 2 ? diff - n : diff);
  wake();
}
$('.prev').addEventListener('click', () => go(-1));
$('.next').addEventListener('click', () => go(1));
index.html — the arrows
<div class="controls">
  <button class="nav prev" type="button" aria-label="Previous"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg></button>
  <button class="nav next" type="button" aria-label="Next"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg></button>
</div>

One loop steps the spring and asks the mechanic to draw. It runs every frame; with reduced motion it sleeps as soon as everything rests, and wake() starts it again. If nothing moves and the mechanic says it has settled, the frame skips the drawing.

app.js — the loop
let running = false, last = 0, quiet = false;
function wake() { quiet = false; 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;
  pos.step(dt);
  const t = studio.now() / 1000;
  const still = pos.resting(0.0005);
  if (!(still && quiet)) {                                   // nothing moving and the look has settled: nothing to redraw
    mech.frame({ pos: pos.value, vel: pos.velocity,
      tx: 0, ty: 0, dt, t, active: active() });
  }
  quiet = still && (mech.idle ? mech.idle() : false);
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}
wake();
Stage 3 · press ‹ and ›; it never runs outOpen ↗

4 · Moving: drag, throw, snap, dots, keys, wheel

The drag uses pointer events, so a mouse, a finger and a pen all work, and the pointer is captured, so the drag keeps going outside the rig. While you drag, the position follows the pointer directly (divided by the rig's scale and the mechanic's unit, the px that make one card), and the last few moves are kept as samples, each with its time. When you let go, dragSpeed() works out the speed in cards a second from the samples of the last 0.1 s only, so a drag that stops and holds still before you let go has no speed left and simply lands. The spring takes that speed as its velocity, and the target is the nearest whole card a little further on: the throw, then the snap. A drag only starts after 4 px, so a tap is still a tap, and a tap brings the card under it to the front.

app.js — drag, throw and snap
// a drag moves the position with the pointer; let go and it carries on with the speed it had, landing on a card
let drag = null;
rig.addEventListener('pointerdown', (e) => {
  if (e.button !== 0) return;
  drag = { x: e.clientX, y: e.clientY, from: pos.value, moved: false, samples: [{ p: pos.value, at: performance.now() }] };
  rig.setPointerCapture(e.pointerId);
});
rig.addEventListener('pointermove', (e) => {
  if (!drag) return;
  const k = sc;                                              // the rig is scaled to the screen
  const along = mech.axis === 'y' ? -(e.clientY - drag.y) : -(e.clientX - drag.x);
  if (!drag.moved && Math.abs(along) < 4) return;
  drag.moved = true;
  rig.classList.add('is-dragging');
  pos.value = pos.target = drag.from + along / k / mech.unit;
  pos.velocity = 0;
  drag.samples.push({ p: pos.value, at: performance.now() });   // the page's clock (the same one the springs run on)
  if (drag.samples.length > 6) drag.samples.shift();
  wake();
});
// the drag's speed in cards a second, from the last 0.1 s of it only: a drag that has stopped does not throw
function dragSpeed() {
  const now = performance.now(), s = drag.samples.filter((p) => now - p.at < 100);
  return s.length > 1 ? (s.at(-1).p - s[0].p) / Math.max(1, s.at(-1).at - s[0].at) * 1000 : 0;
}
const release = (e) => {
  if (!drag) return;
  const v = dragSpeed();
  if (drag.moved) {
    pos.velocity = calm ? 0 : v;
    pos.target = Math.round(pos.value + (calm ? 0 : clamp(v * 0.28, -3, 3)));
  } else if (e.type === 'pointerup') {                       // a tap: the card comes to the front (stage 6: the front one opens)
    const c = cardAt(e.clientX, e.clientY);
    if (c) goTo(c.i);
  }
  drag = null;
  rig.classList.remove('is-dragging');
  wake();
};
rig.addEventListener('pointerup', release);
rig.addEventListener('pointercancel', release);

Which card a tap is on is worked out from the cards' boxes on screen, front card first. The browser's own hit test is not reliable through 3D transforms: in Chromium it can hand back the rig itself instead of the card you pressed.

app.js — which card a tap is on
// which card a tap is on: the nearest one to the front whose window covers the point. Worked out from the cards'
// boxes on screen — the browser's own hit-testing is not reliable through the 3D transforms
function cardAt(x, y) {
  const off = (c) => Math.abs((((c.i - pos.value) % n) + n * 1.5) % n - n / 2);
  return [...cards].sort((a, b) => off(a) - off(b)).find((c) => {
    if (+(c.el.style.opacity || 1) < 0.3) return false;
    const r = c.win.getBoundingClientRect();
    return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
  });
}

The dots are a tab list, one real button per card; pressing one takes the nearest way round to its card. They follow the position through tell(), which the loop calls every frame and which only touches the page when the card in front changes.

app.js — the dots
LOOK.cards.forEach((d, i) => {
  const b = document.createElement('button');
  b.className = 'dot';
  b.type = 'button';
  b.setAttribute('role', 'tab');
  b.setAttribute('aria-label', d.name);
  b.addEventListener('click', () => goTo(i));
  dotsEl.append(b);
});
app.js — the dots follow the position
let shown = -1;
function tell() {
  const a = active();
  if (a === shown) return;
  shown = a;
  [...dotsEl.children].forEach((d, i) => d.setAttribute('aria-selected', String(i === a)));
}
style.css — the dots
.dots { display: flex; align-items: center; gap: 8px; }
.dot { width: 8px; height: 8px; padding: 0; border: 0; border-radius: 99px; background: var(--dot); cursor: pointer; transition: width .3s, background-color .3s; }
.dot[aria-selected="true"] { width: 26px; background: var(--dot-on); }

The carousel takes the focus (tabindex="0"), and the arrow keys move it while it has it. A sideways wheel or trackpad swipe moves it a card at a time: the deltas are added up until they pass 60, and a vertical wheel is left alone, so the page still scrolls past.

app.js — the arrow keys
carousel.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); go(-1); }
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); go(1); }
});
app.js — the wheel
let wheelSum = 0;
rig.addEventListener('wheel', (e) => {
  const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
  if (mech.axis === 'x' && Math.abs(e.deltaX) < Math.abs(e.deltaY)) return;   // a vertical wheel scrolls the page past a sideways carousel
  e.preventDefault();
  wheelSum += d;
  if (Math.abs(wheelSum) > 60) { go(Math.sign(wheelSum)); wheelSum = 0; }
}, { passive: false });

In the loop, the spring rests while you drag (the pointer sets the value), and the mechanic is told the speed of the drag, so a look can react to it.

app.js — the loop, during a drag
if (!drag?.moved) pos.step(dt);
const t = studio.now() / 1000;
const still = pos.resting(0.0005) && !drag;
if (!(still && quiet)) {                                   // nothing moving and the look has settled: nothing to redraw
  mech.frame({ pos: pos.value, vel: drag?.moved ? dragSpeed() : pos.velocity,
    tx: 0, ty: 0, dt, t, active: active() });
Stage 4 · drag it, throw it, press the dotsOpen ↗

5 · The mechanics: a screen, a window, a drift

Now each look gets its own mechanic. All three take the same position, speed and time and return the same three things, so app.js does not change: only mechanics.js is new. First, something all three use: every card stands over a glossy floor. The reflection is a flipped copy under the card, faded out with a mask (for Icons a copy of the whole frame), and the loop keeps its picture moving with the real one.

app.js — the reflection, in card()
// every card stands over a glossy floor: a flipped copy below it — the picture for films and cars, the whole frame
// (mat, picture and caption) for icons
const r = document.createElement('div');
r.className = 'refl';
r.setAttribute('aria-hidden', 'true');
if (look === 'icons') { r.classList.add('frame'); r.append(c.win.cloneNode(true), c.info.cloneNode(true)); }
else r.append(c.pic.cloneNode(true));
c.reflPic = r.querySelector('.pic');
c.reflPic.removeAttribute('alt');
c.refl = r;
node.append(r);
style.css — the reflection
/* the reflection on the floor: a flipped copy of the window's picture under the card, faded out. (box-reflect drew
   the same but repainted every card on every frame; this one only moves) */
.refl { position: absolute; inset: 0; overflow: hidden; border-radius: var(--card-r); pointer-events: none; transform-origin: 50% 100%;
  transform: translateY(var(--gap)) scaleY(-1); -webkit-mask: linear-gradient(transparent var(--fade), rgb(0 0 0 / var(--a))); mask: linear-gradient(transparent var(--fade), rgb(0 0 0 / var(--a))); }

Films: you stand inside a curved wide screen. Each card's angle on the wall is its distance times the angle one card takes up. sin of that angle gives how far across it stands, 1 − cos how far it curves away, and the card turns by the same angle, so the cards beside the front one face you. When a card settles after a fast move, a band of light sweeps across it. The band is a CSS gradient whose place is a custom property, set only when it changes, because setting a custom property makes the browser restyle the card. Behind it all, two layers crossfade between the poster in front and the next one, so the poster's colours fill the room. Those pictures come pre-blurred and small, because a live blur that size cost a frame.

mechanics.js — Films: the curved screen
// FILMS · a wide curved screen you stand inside. The cards line its inner wall: the one in front of you is flat and
// largest, the ones beside it turn in to face you as the wall curves round. Drag and the wall turns; when a card
// settles in front of you a band of light sweeps across it. The front poster's colours spill into the room behind,
// and every card stands on a glossy floor that reflects it.
MECH.films = ({ rig, cards, n, calm }) => {
  const R = 560, GAP = 26, W = 210, STEP = (W + GAP) / R;          // the wall's radius, and the angle between two cards
  const back = [...document.querySelectorAll('.backdrop')];
  const shown = [-1, -1];
  let fast = false, sweep = 1;
  return {
    unit: W + GAP, axis: 'x', repeat: 1,
    frame({ pos, vel, tx, ty, dt }) {
      const speed = Math.abs(vel);
      if (speed > 1.2) fast = true;
      if (fast && speed < 0.25 && Math.abs(pos - Math.round(pos)) < 0.02) { fast = false; sweep = calm ? 1 : 0; }   // a card has landed
      sweep = Math.min(1, sweep + dt / 0.7);
      for (const [i, c] of cards.entries()) {
        const d = wrap(i - pos, n, -n / 2), ad = Math.abs(d), a = d * STEP, k = clamp01(1 - ad * 2);
        const x = Math.sin(a) * R, z = (1 - Math.cos(a)) * R * 0.9 - k * 30, scale = 1 + k * 0.08;
        c.el.style.transform = `translate3d(${f2(x)}px, ${f2(-k * 6)}px, ${f2(-z)}px) rotateY(${f2((-a * 180) / Math.PI)}deg) scale(${f2(scale)}) ${tilt(c, k, tx, ty)}`;
        c.el.style.zIndex = String(100 - Math.round(ad * 10));
        c.el.style.opacity = f2(clamp01((2.2 - ad) / 0.6));             // only the neighbours show; the rest are round the back
        c.pic.style.transform = `translate3d(${f2(-d * 10 - tx * 4 * k)}%, ${f2(-ty * 3 * k)}%, 0)`;
        c.shade.style.opacity = f2(Math.min(0.7, ad * 0.55));
        c.info.style.opacity = f2(clamp01(1 - ad * 3));
        c.info.style.transform = `translate3d(0, ${f2((1 - clamp01(1 - ad * 3)) * 14)}px, 40px)`;
        const sw = k > 0.5 ? f2(-60 + sweep * 220) + '%' : '-60%';
        if (c.sweep !== sw) { c.sweep = sw; c.el.style.setProperty('--sweep', sw); }   // a custom property restyles the card: only when it moves
      }
      const a = wrap(Math.floor(pos), n, 0), b = (a + 1) % n, f = pos - Math.floor(pos);
      [a, b].forEach((idx, j) => { if (shown[j] !== idx) { shown[j] = idx; back[j].style.backgroundImage = cards[idx].data.img ? `url("${cards[idx].data.img.replace('img/', 'img/blur/')}")` : 'none'; } });
      back[0].style.opacity = f2((1 - f) * 0.9);
      back[1].style.opacity = f2(f * 0.9);
    },
    idle: () => sweep >= 1 && !fast,
  };
};
style.css — the band of light
[data-look="films"] .card .win::after { content: ''; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(105deg, transparent calc(var(--sweep, -60%) - 18%), rgb(255 255 255 / .38) var(--sweep, -60%), transparent calc(var(--sweep, -60%) + 18%)); mix-blend-mode: overlay; }
style.css — the poster in the room
.backdrop { position: absolute; inset: -80px; background: center / cover no-repeat; opacity: 0; display: none; will-change: opacity; }   /* the picture comes pre-blurred and small (img/blur): a live blur this size cost a frame */
[data-look="films"] .backdrop { display: block; }

Icons: the frames hang on threads, each at its own height, the neighbours tucked in behind the one in front and turned a little towards it. Every frame swings on its own spring, stiffness 24, 30 or 36 with very little damping, so they swing at different paces, as things on strings do. The push comes from the row's acceleration: when the row speeds up, every frame lags behind and then swings past. The frame turns around a point 320 px above it (the top of its thread: transform-origin: 50% -320px, in the frame's rule from stage 2), the product inside turns back by most of the angle so it seems to hang level, the shadow on the wall swings with it, and the reflection is mirrored about the floor and swings the other way.

mechanics.js — Icons: frames on threads
// ICONS · a gallery window by day. Framed products hang on fine threads, each at its own height, the neighbours
// tucked in behind the one in front and turned a little towards it, over a glossy floor. Push the row and every frame
// swings on its thread — each at its own pace, as things on strings do — and sways to rest; its shadow swings on the
// wall behind it, and the picture inside stays level, so the product seems to hang in the window.
MECH.icons = ({ rig, cards, n, calm, spring }) => {
  const OVER = 150, PAST = 104, PUSH = 14;                          // how far the first neighbour sits out, the rest; how hard a push swings them
  const DROP = [0, -26, 18, -12, 24, -20, 10];                      // each thread its own length
  const GAP = 12;                                                   // twice the frame's height over the floor, at rest
  el('div', 'gallery-floor', rig);
  for (const c of cards) c.el.prepend(Object.assign(document.createElement('i'), { className: 'thread' }));
  const swing = cards.map((_, i) => new Spring(0, spring(24 + (i % 3) * 6, 2.4)));
  const lift = new Spring(1, spring(60, 15.5));                     // the front frame comes forward a little when it settles
  let lastVel = 0;
  return {
    unit: OVER, axis: 'x', repeat: 1,
    frame({ pos, vel, tx, ty, dt }) {
      const acc = Math.max(-40, Math.min(40, (vel - lastVel) / Math.max(dt, 1 / 240)));   // the row's push, cards a second²
      lastVel = vel;
      lift.target = Math.abs(pos - Math.round(pos)) < 0.02 && Math.abs(vel) < 0.15 ? 1 : 0;
      lift.step(dt);
      for (const [i, c] of cards.entries()) {
        const s = swing[i];
        if (!calm) s.velocity -= acc * PUSH * dt;                    // the frame lags behind the rail, then swings past
        s.step(dt);
        const d = wrap(i - pos, n, -n / 2), ad = Math.abs(d), k = clamp01(1 - ad * 2), out = clamp01(ad);
        const x = Math.sign(d) * (ad < 1 ? ad * OVER : OVER + (ad - 1) * PAST);
        const y = DROP[i % DROP.length] * out, z = -Math.min(ad, 3) * 120 + k * lift.value * 24;
        const a = Math.max(-28, Math.min(28, s.value));
        c.el.style.transform = `translate3d(${f2(x)}px, ${f2(y)}px, ${f2(z)}px) rotateY(${f2(-Math.sign(d) * out * 12)}deg) rotateZ(${f2(a)}deg) ${tilt(c, k * 0.6, tx, ty)}`;
        c.el.style.opacity = f2(clamp01((3.2 - ad) / 0.6));
        c.el.style.filter = `drop-shadow(${f2(14 + a * 1.8)}px ${f2(22 + k * 10)}px 18px rgb(70 50 30 / ${f2(0.2 + k * 0.1)}))`;   // its shadow on the wall
        c.pic.style.transform = `translate3d(${f2(-d * 8 - tx * 4 * k)}%, ${f2(-ty * 3 * k)}%, 0) rotate(${f2(-a * 0.7)}deg)`;
        c.shade.style.opacity = f2(Math.min(0.5, ad * 0.28));
        // its reflection: mirrored about the floor, not about the frame — a frame hanging higher sees it further down —
        // and swinging the other way, as a mirror image does
        c.refl.style.transform = `translateY(${f2(GAP - 2 * y)}px) rotate(${f2(-2 * a)}deg) scaleY(-1)`;
      }
    },
    idle: () => lift.resting(0.001) && swing.every((s) => s.resting(0.02)),
  };
};
style.css — the thread and its brass clip
/* the thread it hangs from, and the brass clip that holds it */
.thread { position: absolute; left: 50%; bottom: 100%; width: 1px; height: 700px; margin-left: -.5px; pointer-events: none;
  background: linear-gradient(0deg, rgb(20 18 16 / .5), rgb(20 18 16 / .14) 55%, transparent); }
.thread::after { content: ''; position: absolute; left: -5px; bottom: -4px; width: 11px; height: 10px; border-radius: 2px;
  background: linear-gradient(180deg, #E2C186, var(--ic-brass) 55%, #7E5E31); box-shadow: 0 1px 2px rgb(0 0 0 / .3); }

Cars: the cars stand in a row on a wet floor under one warm light. Throw the row and every card swings round on its wheels: a slide angle on a loose spring follows the speed, so the cars swing past straight before they settle. Tyre smoke and rubber are drawn on two canvases, one behind the cards and one in front. Each canvas is pushed back or forward in depth and scaled so it still draws in the rig's own pixels. A soft puff is drawn once and stamped many times, and the random numbers are seeded, so the same throw draws the same smoke.

mechanics.js — Cars: the slide, the canvases, the puff, the headlights
// CARS · drift. The cars stand in a row on a wet floor under one warm light. Throw the row and every car slides in
// sideways — the cards swing round on their wheels, tyre smoke pours off the back corners and black rubber is laid on
// the floor; let go and they catch the slide, swing past straight and settle. The car that lands in front flashes its
// own headlights twice, and its figures count up.
MECH.cars = ({ rig, cards, n, calm, spring }) => {
  const W = 236, GAP = 30, STEP = W + GAP, FLOOR = 154;            // a card's width, the row's step, the floor line below the centre
  const CW = 1000, CH = 560, PERSP = 1000;                         // the smoke canvases, in the rig's px; the rig's perspective
  const yaw = new Spring(0, spring(70, 8));                         // the slide: it swings past straight before it settles
  const count = new Spring(1, spring(50, 13));
  el('div', 'floor', rig);
  // two canvases, one behind the cards and one in front, pushed back and forward in depth and scaled so they still
  // draw in the rig's own px (the perspective's centre is the rig's centre)
  const layer = (cls, z) => {
    const cv = el('canvas', `smoke ${cls}`, rig);
    cv.style.transform = `translateZ(${z}px) scale(${(PERSP - z) / PERSP})`;
    return { cv, g: cv.getContext('2d') };
  };
  const back = layer('back', -160), front = layer('front', 60);
  // one soft puff, drawn once and stamped many times
  const puff = document.createElement('canvas');
  puff.width = puff.height = 128;
  { const g = puff.getContext('2d'), r = g.createRadialGradient(64, 64, 0, 64, 64, 64);
    r.addColorStop(0, 'rgba(232,228,222,1)'); r.addColorStop(0.45, 'rgba(220,216,210,.45)'); r.addColorStop(1, 'rgba(210,206,200,0)');
    g.fillStyle = r; g.fillRect(0, 0, 128, 128); }
  // the headlights: a layer the size of the picture, and each car's own lights on it where they are in the photo —
  // their shape, size, angle and colour (halogen, LED ring, daytime strip, parking lamp, covered lens, vintage)
  for (const c of cards) {
    if (!c.data.lamps) continue;
    c.lamps = el('div', 'lamps', c.win, c.data.lamps.map(([x, y, w, h, r, kind]) =>
      `<i class="lamp ${kind}" style="left:${x}%;top:${y}%;width:${w}%;padding-top:${h}%;--rot:${r}deg"></i>`).join(''));
    c.lamps.style.setProperty('--on', '0');
  }
  let seed = 7;
  const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };   // seeded: the same throw draws the same smoke
  const puffs = [], marks = [], prev = new Map();

Every frame each card gets its slide angle and its place, and the car that has just landed flashes its headlights: two quick pulses, then a soft glow (with reduced motion there is no flash: its lights just come on). Its figures count up from zero on their own spring.

mechanics.js — the slide, and the flash
const speed = Math.abs(vel), dir = Math.sign(vel) || 1;      // vel > 0: the row runs to the left
yaw.target = calm ? 0 : Math.max(-26, Math.min(26, -vel * 8));
yaw.step(dt);
if (active !== lastActive) { lastActive = active; count.value = 0; count.target = 1; }
count.step(dt);
if (speed > 1) armed = true;
if (armed && speed < 0.2 && Math.abs(pos - Math.round(pos)) < 0.03) { armed = false; flash = calm ? 1 : 0; landed = active; }   // it has landed: a flash, or just its lights on under reduced motion
flash += dt;
const pulse = (c) => Math.exp(-(((flash - c) / 0.07) ** 2));
const lampsOn = Math.min(1, 0.32 + 1.4 * Math.max(pulse(0.06), pulse(0.38)));

for (const [i, c] of cards.entries()) {
  const d = wrap(i - pos, n, -n / 2), ad = Math.abs(d), k = clamp01(1 - ad * 2);
  const x = Math.sign(d) * (ad < 1 ? ad * STEP : STEP + (ad - 1) * STEP * 0.84);
  const z = -Math.min(ad, 2) * 70, scale = 1 - Math.min(ad, 1) * 0.1;
  const y2 = yaw.value * (1 - Math.min(ad, 1.5) * 0.3);       // this card's slide angle
  c.el.style.transform = `translate3d(${f2(x)}px, 0, ${f2(z)}px) rotateY(${f2(y2)}deg) rotateZ(${f2(y2 * 0.08)}deg) scale(${f2(scale)}) ${tilt(c, k, tx, ty)}`;
  c.el.style.opacity = f2(clamp01((2.6 - ad) / 0.6));
  const picT = `translate3d(${f2(-d * 8 - tx * 4 * k + y2 * 0.22)}%, ${f2(-ty * 3 * k)}%, 0)`;
  c.pic.style.transform = picT;
  if (c.lamps) {
    c.lamps.style.transform = `translateY(-50%) ${picT}`;
    c.lamps.style.setProperty('--on', f2(i === landed && i === active ? lampsOn : 0));
  }
  c.shade.style.opacity = f2(Math.min(0.65, ad * 0.5));
  c.info.style.opacity = f2(clamp01(1 - ad * 2.2));
  c.info.style.transform = `translate3d(0, ${f2((1 - clamp01(1 - ad * 2.2)) * -10)}px, 30px)`;
  if (i === active) for (const b of c.info.querySelectorAll('[data-to]')) b.textContent = (+b.dataset.to * Math.min(1, count.value)).toFixed(+(b.dataset.dp || 0));

A sliding car smokes from its back corners. The number of puffs a frame grows with the slide, and each puff rises, spreads and fades over 1.8 to 3.6 seconds. Two lines of rubber are laid between where each wheel was last frame and where it is now; they fade over four seconds.

mechanics.js — the tyres
// the tyres: a sliding car smokes from its back corners and lays rubber on the floor
if (calm || ad > 1.3) { prev.delete(i); continue; }
const cos = Math.cos((y2 * Math.PI) / 180), half = (W / 2) * scale * cos;
const wheels = [x + dir * half * 0.86, x - dir * half * 0.8];   // the back corner (it trails), then the front
const slide = clamp01((speed - 0.5) / 2.5) * (1 - ad * 0.55);
if (slide > 0) {
  let want = slide * 150 * dt;                                  // puffs this frame
  while (want > 0) {
    if (rnd() < want) {
      const w = rnd() < 0.78 ? 0 : 1, life = 1.8 + rnd() * 1.8;
      puffs.push({ x: wheels[w] + (rnd() - 0.5) * 16, y: FLOOR - 4 - rnd() * 8, vx: (rnd() - 0.5) * 40 - dir * 14,
        vy: -20 - rnd() * 55, r0: 14 + rnd() * 10, r1: 90 + rnd() * 130 * slide, life, age: 0, a: (0.3 + rnd() * 0.25) * (0.6 + slide * 0.6), front: rnd() < 0.4 });
    }
    want -= 1;
  }
}
if (slide > 0.25 && Math.abs(y2) > 5) {
  const p = prev.get(i);
  if (p) wheels.forEach((wx, w) => marks.push({ x1: p[w], x2: wx, y: FLOOR + 2 + w * 5, age: 0, a: Math.min(1, slide * 1.2) * (w ? 0.55 : 1) }));
  prev.set(i, wheels);
} else prev.delete(i);
mechanics.js — drawing the rubber and the smoke
// draw: the rubber and most of the smoke behind the cards, a thinner veil of smoke in front
for (const l of [back, front]) { l.g.setTransform(res, 0, 0, res, (CW / 2) * res, (CH / 2) * res); l.g.clearRect(-CW / 2, -CH / 2, CW, CH); }
const bg = back.g;
bg.lineCap = 'round';
for (let m = marks.length - 1; m >= 0; m--) {
  const s = marks[m];
  s.age += dt;
  if (s.age > 4) { marks.splice(m, 1); continue; }
  bg.strokeStyle = `rgba(8,8,10,${f2(s.a * 0.75 * (1 - s.age / 4))})`;
  bg.lineWidth = 7;
  bg.beginPath(); bg.moveTo(s.x1, s.y); bg.lineTo(s.x2, s.y); bg.stroke();
}
if (marks.length > 900) marks.splice(0, marks.length - 900);
for (let q = puffs.length - 1; q >= 0; q--) {
  const p = puffs[q];
  p.age += dt;
  if (p.age > p.life) { puffs.splice(q, 1); continue; }
  const u = p.age / p.life, e = 1 - (1 - u) ** 3;
  p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 1 - dt * 0.8; p.vy *= 1 - dt * 0.3;
  const r = p.r0 + (p.r1 - p.r0) * e, a = p.a * (1 - u) ** 1.6 * Math.min(1, p.age / 0.08) * (p.front ? 0.6 : 1);
  const g = p.front ? front.g : bg;
  g.globalAlpha = a;
  g.drawImage(puff, p.x - r, p.y - r * 0.8, r * 2, r * 1.6);
}
back.g.globalAlpha = front.g.globalAlpha = 1;
if (puffs.length > 900) puffs.splice(0, puffs.length - 900);

The headlights are each car's own. In the table, every car lists where its lights are in the photo: x and y, width and height (as a share of the picture), an angle, and a kind. A layer the size of the picture moves with it, so the lamps stay on the car, and one custom property, --on, from 0 to 1, drives the lens, the glow and the long flare of every lamp. The kinds give each car its own light: halogen, an LED ring, a daytime strip, a parking lamp, a covered lens, a vintage bulb.

app.js — the F40's headlights, in the table
{ name: 'Ferrari F40', lamps: [[23.5, 61.7, 3.9, 2.8, -3, 'halo rect'], [46.9, 63.2, 4.8, 3.9, 4, 'halo rect']], img: '../../img/car-ferrari-f40.jpg', html: '<p class="c-year">1987</p><p class="c-name">Ferrari F40</p><div class="c-specs"><span><b data-to="478">478</b>hp</span><span><b data-to="4.1" data-dp="1">4.1</b>s 0–100</span><span><b data-to="324">324</b>km/h</span></div>' },
style.css — a lamp: lens, glow and flare
/* the headlights: a layer the size of the picture (it moves with it), a lamp at each light, a glow and a long flare */
.lamps { position: absolute; left: -14%; top: 50%; width: 128%; aspect-ratio: 896 / 1200; pointer-events: none; mix-blend-mode: screen; }
/* a lamp is its own lens (the core), a glow round it, and a long flare that only shows in the flash; --on is 0…1 */
.lamp { --c: 255 226 176; --core: #FFF6E6; position: absolute; height: 0; transform: translate(-50%, -50%) rotate(var(--rot)); border-radius: 50%;
  background: radial-gradient(closest-side, var(--core) 30%, rgb(var(--c) / .9) 62%, rgb(var(--c) / 0) 100%); opacity: clamp(0, calc(var(--on) * 1.7), 1); }
.lamp::before { content: ''; position: absolute; inset: -130%; border-radius: 50%; background: radial-gradient(closest-side, rgb(var(--c) / .55), rgb(var(--c) / .18) 45%, transparent);
  opacity: var(--on); }
.lamp::after { content: ''; position: absolute; left: 50%; top: 50%; width: 1100%; height: 3px; border-radius: 3px; transform: translate(-50%, -50%) rotate(calc(var(--rot) * -1));
  background: linear-gradient(90deg, transparent, rgb(var(--c) / .75) 42% 58%, transparent); filter: blur(.6px); opacity: clamp(0, calc((var(--on) - .4) * 1.8), 1); }
.lamp.rect { border-radius: 22%; background: radial-gradient(farthest-side, var(--core) 40%, rgb(var(--c) / .85) 75%, rgb(var(--c) / .1)); }
.lamp.rect::before { border-radius: 30%; }
Stage 5 · Films: the curved screenOpen ↗
Stage 5 · Icons: push the rail, the frames swingOpen ↗
Stage 5 · Cars: throw the row, it driftsOpen ↗

6 · The tilt, the words for screen readers, and the detail

The front card leans towards the pointer. Two springs follow where the pointer is over the rig, from −1 to 1 each way; not while you drag, and never with reduced motion.

app.js — where the pointer is
const tx = new Spring(0, spring(120, 14)), ty = new Spring(0, spring(120, 14));
function aim(e) {                                            // the front card leans towards the pointer
  if (calm || drag?.moved) { tx.target = ty.target = 0; return; }
  const r = rig.getBoundingClientRect();
  tx.target = clamp(((e.clientX - r.left) / r.width - 0.5) * 2, -1, 1);
  ty.target = clamp(((e.clientY - r.top) / r.height - 0.5) * 2, -1, 1);
  wake();
}
rig.addEventListener('pointerleave', () => { tx.target = ty.target = 0; wake(); });

Every mechanic turns that into the same lean, through one shared function: the card rotates up to 9° on each axis, the glare (a soft radial gradient blended over the card) moves with the light, and in each mechanic the picture slides the other way inside the window. That is the window idea again.

mechanics.js — the tilt, shared by the looks
// the front card's tilt, shared: it leans towards the pointer, the picture slides the other way (the window),
// the glare follows the light
function tilt(c, k, tx, ty) {
  c.glare.style.opacity = f2(k * (0.25 + 0.5 * Math.hypot(tx, ty)));
  c.glare.style.setProperty('--gx', `${50 + tx * 40}%`);
  c.glare.style.setProperty('--gy', `${35 + ty * 30}%`);
  return `rotateY(${f2(tx * 9 * k)}deg) rotateX(${f2(-ty * 9 * k)}deg)`;
}

The card in front is now also said out loud. A polite live region gets "Dune, 1 of 7" when the card in front changes, and only then, so a screen reader is not flooded while the row moves.

app.js — the dots, and the words for screen readers
let shown = -1;
function tell() {
  const a = active();
  if (a === shown) return;
  shown = a;
  [...dotsEl.children].forEach((d, i) => d.setAttribute('aria-selected', String(i === a)));
  live.textContent = `${LOOK.cards[a].name}, ${a + 1} of ${n}`;
}

The loop now steps the tilt too, and a light drifts behind the section. The light reads its time from a studio clock a capture tool can pause or drive; with reduced motion it stands still.

app.js — the loop, finished
const glows = [...document.querySelectorAll('.amb .glow')];
let running = false, last = 0, quiet = false;
function wake() { quiet = false; 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;
  if (!drag?.moved) pos.step(dt);
  tx.step(dt); ty.step(dt);
  const t = studio.now() / 1000;
  const still = pos.resting(0.0005) && tx.resting(0.001) && ty.resting(0.001) && !drag;
  if (!(still && quiet)) {                                   // nothing moving and the look has settled: nothing to redraw
    mech.frame({ pos: pos.value, vel: drag?.moved ? dragSpeed() : pos.velocity,
      tx: tx.value, ty: ty.value, dt, t, active: active() });
    for (const c of cards) if (c.reflPic && c.reflPic.style.transform !== c.pic.style.transform) c.reflPic.style.transform = c.pic.style.transform;
  }
  quiet = still && (mech.idle ? mech.idle() : false);
  tell();
  const amb = calm ? 0 : t;
  glows.forEach((el, i) => (el.style.transform = `translate(${(Math.sin(amb * (0.13 + i * 0.05) + i * 2) * 26).toFixed(1)}px, ${(Math.cos(amb * (0.11 + i * 0.04) + i) * 18).toFixed(1)}px)`));
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}
wake();

The last part is what happens when you tap the card in front (or press Enter on the carousel): the detail behind it opens. It is a real <dialog>, opened with showModal(), so the rest of the page cannot be reached while it is open and focus stays inside. On a phone it is a sheet from the bottom; from 760 px up it is a panel with the picture beside the words. Its tools (previous, the count, next, close) sit outside the scrolling panel, so they stay in the corner while the panel scrolls.

index.html — the detail
<!-- the detail behind a card: tap the front card (or press Enter on the carousel) and it opens. app.js fills it -->
<dialog class="detail" aria-labelledby="dt-title">
  <div class="dt-panel">
    <i class="dt-sheet" aria-hidden="true"></i>
    <figure class="dt-media"><img class="dt-pic" alt=""><div class="dt-lamps" aria-hidden="true"></div></figure>
    <div class="dt-body"></div>
  </div>
  <!-- outside the panel, so it stays in the corner while the panel scrolls -->
  <div class="dt-tools">
    <button class="dt-prev" type="button" aria-label="Previous"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg></button>
    <span class="dt-count" aria-hidden="true"></span>
    <button class="dt-next" type="button" aria-label="Next"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg></button>
    <button class="dt-close" type="button" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
  </div>
</dialog>

fill() puts a card into it: the picture, the car's own headlights, the words for the look and the count. The words are built from the card's own HTML plus a second table with what only the detail needs (a plot, the cast and today's times for a film; the facts for an icon; the engine, the facts and a line of history for a car).

app.js — fill(): a card into the detail
function fill(c) {
  opened = c;
  dPic.src = c.data.img;
  dPic.alt = c.data.name;
  dLamps.innerHTML = c.data.lamps ? c.data.lamps.map(([x, y, w, h, r, kind]) =>
    `<i class="lamp ${kind}" style="left:${x}%;top:${y}%;width:${w}%;padding-top:${h}%;--rot:${r}deg"></i>`).join('') : '';
  if (look === 'films') sheet.style.setProperty('--poster', `url("${c.data.img.replace('img/', 'img/blur/')}")`);
  dBody.innerHTML = DETAIL[look]({ ...c.data, i: c.i }, MORE[c.data.name]);
  $('.dt-count').textContent = `${pad(real.indexOf(c) + 1)} / ${pad(real.length)}`;
  // the figures count up, the bars fill, the headlights flash — as on the card
  const nums = [...dBody.querySelectorAll('[data-to]')], start = performance.now();
  (function count(now) {
    const k = calm ? 1 : Math.min(1, (now - start - 250) / 900), e = 1 - (1 - Math.max(0, k)) ** 3;
    nums.forEach((b) => (b.textContent = (+b.dataset.to * e).toFixed(+b.dataset.dp)));
    dBody.style.setProperty('--fill', e.toFixed(3));
    if (k < 1 && opened === c) requestAnimationFrame(count);
  })(start);
  dLamps.classList.remove('is-flash'); void dLamps.offsetWidth; dLamps.classList.add('is-flash');
}

For Cars, each figure gets a bar measured against the best of the six: the most power, the highest top speed, and for 0–100 the quickest time.

app.js — each figure against the hall
// cars: each figure against the rest of the hall — the bar under it is full for the best of the six (for 0–100, the quickest)
const FIG = (html) => [...html.matchAll(/<b data-to="([\d.]+)"(?: data-dp="(\d)")?>[^<]*<\/b>([^<]+)/g)].map(([, to, dp, unit]) => ({ to: +to, dp: +(dp || 0), unit }));
const best = look === 'cars' ? FIG(real[0].data.html).map((f, j) => {
  const all = real.map((c) => FIG(c.data.html)[j].to);
  return /0–100/.test(f.unit) ? { low: true, v: Math.min(...all) } : { low: false, v: Math.max(...all) };
}) : [];

Opening is a FLIP: the picture is measured on the card and in the dialog, then animated from the card's box to its own, so it seems to fly out of the card. The sheet fades in, the words rise a little, and the focus goes to Close.

app.js — opening: the picture flies out of the card
function openDetail(c) {
  if (!c || c.data.all || !MORE[c.data.name] || dialog.open) return;
  fill(c);
  dialog.showModal();
  html.classList.add('is-locked');
  dialog.classList.remove('is-shut');
  // the picture flies from the card into place; the sheet and the words fade in round it
  const from = c.win.getBoundingClientRect(), to = media.getBoundingClientRect();
  const t = calm ? 0 : 1;
  media.animate([{ transform: `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / to.width}, ${from.height / to.height})` },
    { transform: 'none' }], { duration: 560 * t, easing: ease });
  sheet.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 320 * t, easing: 'ease-out' });
  for (const el of [dBody, $('.dt-tools')]) el.animate([{ opacity: 0, transform: 'translateY(18px)' }, { opacity: 1, transform: 'none' }], { duration: 460 * t, delay: 160 * t, easing: ease, fill: 'backwards' });
  $('.dt-close').focus();
}

‹ and › (or the arrow keys) step to the previous or next card without closing: the words slide over, the picture crossfades, and the carousel moves underneath, so closing flies back to the right card.

app.js — the next one, without closing
// the next or the previous one, without closing: the words slide over, the picture crossfades, and the carousel
// follows underneath so closing flies back to the right card
function step(by) {
  if (!dialog.open || dialog.classList.contains('is-shut')) return;
  const c = real[(real.indexOf(opened) + by + real.length) % real.length], t = calm ? 0 : 1;
  goTo(c.i);
  const out = dBody.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: `translateX(${-by * 24}px)` }], { duration: 160 * t, easing: 'ease-in', fill: 'forwards' });
  media.animate([{ opacity: 1 }, { opacity: 0.2 }], { duration: 160 * t, easing: 'ease-in' });
  out.finished.then(() => {
    fill(c);
    out.cancel();
    dBody.animate([{ opacity: 0, transform: `translateX(${by * 24}px)` }, { opacity: 1, transform: 'none' }], { duration: 380 * t, easing: ease });
    media.animate([{ opacity: 0.2, transform: 'scale(1.04)' }, { opacity: 1, transform: 'none' }], { duration: 480 * t, easing: ease });
  });
}

Closing runs the flight backwards, into the window of the card in front, then closes the dialog and gives the focus back to the carousel. Escape arrives as the dialog's cancel event, which is stopped, so the dialog does not vanish before its animation; a tap on the backdrop closes it too.

app.js — closing, back into the card
function closeDetail() {
  if (!dialog.open || dialog.classList.contains('is-shut')) return;
  dialog.classList.add('is-shut');
  const c = opened, from = media.getBoundingClientRect(), to = c.win.getBoundingClientRect(), t = calm ? 0 : 1;
  sheet.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300 * t, delay: 80 * t, easing: 'ease-in', fill: 'forwards' });
  for (const el of [dBody, $('.dt-tools')]) el.animate([{ opacity: 1 }, { opacity: 0, transform: 'translateY(12px)' }], { duration: 180 * t, easing: 'ease-in', fill: 'forwards' });
  const fly = media.animate([{ transform: 'none' }, { transform: `translate(${to.left - from.left}px, ${to.top - from.top}px) scale(${to.width / from.width}, ${to.height / from.height})` }],
    { duration: 420 * t, easing: ease, fill: 'forwards' });
  fly.finished.then(() => {
    dialog.close();
    html.classList.remove('is-locked');
    dialog.getAnimations({ subtree: true }).forEach((a) => a.cancel());
    carousel.focus({ preventScroll: true });
  });
}
app.js — the buttons, the keys, Escape, a tap outside
$('.dt-close').addEventListener('click', closeDetail);
$('.dt-prev').addEventListener('click', () => step(-1));
$('.dt-next').addEventListener('click', () => step(1));
dialog.addEventListener('keydown', (e) => {
  if (e.target.closest('.dt-times')) return;                 // the arrow keys there move between the times
  if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
  if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
});
dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDetail(); });   // Escape
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDetail(); });   // a tap outside the panel
style.css — the detail: the page stops, a sheet from the bottom
html.is-locked { overflow: hidden; }
.detail { position: fixed; inset: auto 0 0; width: 100%; max-width: none; max-height: 92dvh; margin: 0; padding: 0; border: 0; background: none; color: var(--m-ink); overflow: visible; }
.detail::backdrop { background: rgb(4 3 6 / .62); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); animation: dt-fade .3s ease-out; }
[data-look="icons"] .detail::backdrop { background: rgb(40 32 24 / .42); }
.detail.is-shut::backdrop { opacity: 0; transition: opacity .3s ease-in; }
@keyframes dt-fade { from { opacity: 0; } }
.dt-panel { position: relative; isolation: isolate; display: flex; flex-direction: column; max-height: 92dvh; overflow: auto; overscroll-behavior: contain; border-radius: 24px 24px 0 0; }
style.css — from 760 px up: a panel (its first rules)
@media (min-width: 760px) {
  .detail { inset: 0; width: min(960px, calc(100% - 48px)); max-height: min(88dvh, 660px); margin: auto; }
  .dt-panel { flex-direction: row; max-height: min(88dvh, 660px); border-radius: 26px; overflow: hidden; }
  .dt-sheet { box-shadow: 0 50px 120px rgb(0 0 0 / .5), inset 0 0 0 1px rgb(255 255 255 / .08); }
  [data-look="icons"] .dt-sheet { box-shadow: 0 50px 120px rgb(60 40 20 / .35), inset 0 0 0 1px rgb(20 18 16 / .08); }
  .dt-media { width: 44%; height: auto; min-height: 560px; border-radius: 26px 0 0 26px; }
  /* … the picture, the words and the tools, placed for the panel */
}

A touch circle (for the video) and a small console API for scripts arrive too.

app.js — for scripts and the console
// for scripts and the tutorial's console
window.__cards = { go, goTo, open: (i = active()) => openDetail(cards[i]), close: closeDetail, state: () => ({ pos: +pos.value.toFixed(3), active: active(), name: LOOK.cards[active()].name }) };
Stage 6 · the finished carousel; tap the front cardOpen ↗

Three looks, three mechanics

The looks share the markup, the carousel and the three token layers. Each one is a block of semantic tokens, an entry in the look table, a few rules of its own and a mechanic. Here are the tokens of Icons and Cars.

style.css — the semantic tokens of Icons
[data-look="icons"] {
  --page: var(--ic-100); --on-field: var(--ic-ink); --on-field-muted: var(--ic-ink-2); --chip: var(--ic-ink); --on-chip: var(--ic-50);
  --accent: var(--ic-ink); --on-accent: var(--ic-50); --light: rgb(255 244 228 / .7); --avatar-ring: var(--ic-ink); --lockup: brightness(0);
  --title-face: var(--serif); --title-w: 350; --title-size: 64px; --title-track: -.045em; --title-case: none; --em-face: var(--serif); --em-style: italic; --em-w: 300; --em: var(--ic-brass);
  --card-r: 4px; --card-edge: rgb(20 18 16 / .08); --card-drop: none;
  --btn: var(--ic-50); --btn-edge: rgb(20 18 16 / .12); --on-btn: var(--ic-ink); --dot: rgb(20 18 16 / .18); --dot-on: var(--ic-ink);
  --pill: rgb(251 249 246 / .9); --pill-edge: rgb(20 18 16 / .1); --on-pill: var(--ic-ink); --on-pill-muted: var(--ic-ink-2); --pill-icon: var(--ic-ink); --on-pill-icon: var(--ic-50);
}
style.css — the semantic tokens of Cars
[data-look="cars"] {
  --page: var(--ca-950); --on-field: var(--ca-100); --on-field-muted: var(--ca-300); --chip: var(--ca-amber); --on-chip: var(--ca-950);
  --accent: var(--ca-amber); --on-accent: var(--ca-950); --light: rgb(255 176 32 / .07); --avatar-ring: var(--ca-amber); --lockup: none;
  --title-face: var(--wide); --title-w: 900; --title-size: 64px; --title-track: -.03em; --title-case: uppercase; --em-face: var(--wide); --em-style: normal; --em-w: 300; --em: var(--ca-amber);
  --card-r: 14px; --card-edge: rgb(255 255 255 / .1); --card-drop: 0 40px 60px -30px rgb(0 0 0 / .95);
  --btn: rgb(255 255 255 / .05); --btn-edge: rgb(255 255 255 / .14); --on-btn: var(--ca-100); --dot: rgb(255 255 255 / .2); --dot-on: var(--ca-amber);
  --pill: var(--ca-900); --pill-edge: rgb(255 255 255 / .12); --on-pill: var(--ca-100); --on-pill-muted: var(--ca-300); --pill-icon: var(--ca-amber); --on-pill-icon: var(--ca-950);
}
Look · Films, the curved screenOpen ↗
Look · Icons, frames on threadsOpen ↗
Look · Cars, the driftOpen ↗

Make it yours

  • Looks are tokens, an entry in the look table and a mechanic: pick one with ?look=films, icons or cars (or a, b, c).
  • Your own cards are the table: a name, a picture and the words as a little HTML. The last card (with all: true) becomes the mosaic that leads to everything. Any number of cards works.
  • Your own movement is a new mechanic: a function that returns unit, axis, repeat and frame(s), and draws every card from s.pos. Use wrap() for each card's distance and the row never ends; add idle() and the loop stops redrawing when your look has settled.
  • The feel is a handful of numbers: the position spring (stiffness 140, damping 22), how far a throw carries (the speed × 0.28, at most 3 cards), the tilt springs, and in each mechanic its own springs.
  • Accessibility is already there: a labelled region with the carousel role, every card a labelled slide, the dots a tab list of real buttons, the arrow keys on the focused carousel, the card in front announced politely, the detail a real modal dialog that keeps the focus, closes on Escape and hands the focus back, and prefers-reduced-motion (no throw, no tilt, no swing, no light sweep, no slide or smoke, no flashing lights; the carousel still moves a card at a time).
The one idea worth keeping

Give the carousel one number and nothing else. The position, on a spring, is all the drag, the throw, the arrows, the dots, the keys and the wheel ever change; a mechanic turns it into a picture, and wrap() makes it endless. That is why three looks that move nothing alike share every line of app.js.