Library / Tutorial

File upload,
step by step.

The file fields of a form, where the field itself carries the upload. A real file input you can press, tab to or drop a file on; a check that says why a file stays out; an upload, simulated or real, that a truck, a crane or a coin carries across the field; an error you can retry; and a button that waits for every field. Six stages, each a live page you can use.

6Stages
3Looks
0Libraries

This is the whole build of File upload, in the order it was written: the file fields of a form, where the field itself carries the upload. Three made-up companies ask for three documents each, and in each the file travels its own way: Freight's truck drives it across the field, Tower's crane lays it block by block, Vault's coin rolls it into a lock. Each stage below is a live page you can use: choose a file of your own, drop one on a field, or drag a sample. 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 form, style.css for the tokens and the layout, and app.js for the behaviour. From stage 3 there is spring.js, and from stage 5 riders.js. There is no library and no build step. Unless you set a server's address, nothing is sent anywhere: the upload is simulated.

1 · The frame, an empty form and three companies

The first stage is the whole page in a 432 × 768 frame that app.js scales to fit the screen; add ?full=1 and the same markup is a real page at the size of your window. The form has a head that says who is asking and for what, three file fields, and a button. Here the form is written by hand, for Freight.

index.html — the form and its head
<form class="form" aria-labelledby="up-co" novalidate>
  <header class="up-head">
    <span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 2.5 3.5 20.5 12 16l8.5 4.5z"/></svg></span>
    <div class="who"><p class="co" id="up-co">Northline Freight</p><p class="job">Shipment NL-48210 · customs</p></div>
  </header>

A field is the part that matters. It is a real <input type="file">, so the keyboard, screen readers and the file picker all work as they should. The input itself is hidden visually but kept in the page, and two labels point at it: the field's name above the box, and the whole box (a button and the line "or drop it here"). Press anywhere on the box and the browser opens the file picker; no script is needed for that. The box also already holds everything it will show later: the riders' layer, the percentage, the "done" row with the file's name, Retry and a ×. The hint and the message line are tied to the input with aria-describedby, so a screen reader reads what the field takes and what happened to the file.

index.html — one field, by hand
<div class="field" data-state="empty">
  <div class="f-top"><label class="f-label" for="file-0">Commercial invoice</label><span class="f-hint" id="file-0-hint">PDF or JPG · up to 25 MB</span></div>
  <div class="box">
    <input class="f-input" type="file" id="file-0" accept=".pdf,.jpg,.jpeg,.png" aria-describedby="file-0-hint file-0-msg">
    <label class="pick" for="file-0"><span class="btn"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></svg>Choose file</span><span class="none">or drop it here</span></label>
    <div class="ride" aria-hidden="true"></div>
    <span class="pct" aria-hidden="true"></span>
    <div class="got"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5.5 12.5l4 4 9-9.5"/></svg><b class="g-name"></b><span class="g-size"></span></div>
    <button class="retry" type="button" aria-label="Retry the upload for Commercial invoice">Retry</button>
    <button class="x" type="button" aria-label="Remove the file from Commercial invoice"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg></button>
  </div>
  <p class="f-msg" id="file-0-msg" aria-live="polite"></p>
</div>
index.html — the form's button, waiting
<button class="send" type="submit" disabled><span>Send to customs</span></button>

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

style.css — the primitives
:root {
  --n-0: #fff; --b-40: rgb(0 0 0 / .4);
  /* freight: asphalt at night, sodium light, hazard yellow */
  --fr-950: #0B0C0E; --fr-900: #121417; --fr-850: #17191D; --fr-800: #1E2125; --fr-700: #2A2D32; --fr-500: #5E6268; --fr-300: #9EA1A6; --fr-100: #F3F0E8;
  --fr-yellow: #FFC21A; --fr-amber: #FF9F1C; --fr-green: #5BD27A; --fr-red: #FF5A4E;
  /* tower: daylight on concrete, graphite lines, a gold hand */
  --tw-50: #FBFAF7; --tw-100: #F2EFEA; --tw-200: #E6E1D8; --tw-ink: #1B1B19; --tw-ink-2: #67635B; --tw-gold: #B07D2B; --tw-green: #3E8A5C; --tw-red: #C2412D;
  /* vault: bank green, brass, warm paper ink */
  --va-950: #05100B; --va-900: #0A1A13; --va-850: #0E2219; --va-800: #132D22; --va-600: #2B4A3D; --va-300: #9DB3A8; --va-100: #F2ECDE;
  --va-brass: #D9A25F; --va-brass-2: #F3D19C; --va-green: #6FD39A; --va-red: #FF6B5B;

  --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
  --wide: 'Archivo', 'Geist', ui-sans-serif, sans-serif;
  --syne: 'Syne', 'Geist', ui-sans-serif, 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 card, the box, the accent, done and failed get their own names, and a whole set is written once per company on [data-look]. Three companies ship from the first stage, and each is its own post: Freight (a logistics firm at night: asphalt, sodium light, hazard yellow, a wide industrial face), Tower (an architecture studio by day: concrete paper, a drawing grid, a gold hand) and Vault (a private bank: deep green, brass, a serif). Here is Freight.

style.css — the semantic tokens of Freight
/* A · FREIGHT: a logistics firm at night. Asphalt black, sodium glow, hazard yellow, a wide industrial face */
[data-look="freight"] {
  --title-face: var(--wide); --title-w: 800; --title-size: 44px; --title-wdth: 112; --title-track: -.04em;
  --em-face: var(--wide); --em-style: normal; --em-w: 800; --em: var(--fr-yellow); --co-face: var(--wide); --co-w: 800;
  --page: var(--fr-950);
  --field-bg: radial-gradient(70% 36% at 80% 0%, rgb(255 168 52 / .16), transparent 70%), radial-gradient(60% 40% at 0% 100%, rgb(255 194 26 / .07), transparent 70%);
  --on-field: var(--fr-100); --on-field-muted: var(--fr-300); --chip: var(--fr-yellow); --on-chip: var(--fr-950);
  --avatar-ring: var(--fr-yellow); --lockup: none; --light: rgb(255 170 60 / .08);
  --card: linear-gradient(180deg, var(--fr-850), var(--fr-900)); --card-edge: var(--fr-700); --card-r: 18px;
  --card-drop: 0 30px 60px -30px rgb(0 0 0 / .9), inset 0 1px 0 rgb(255 255 255 / .05);
  --card-top: repeating-linear-gradient(-45deg, var(--fr-yellow) 0 9px, var(--fr-950) 9px 18px);
  --on-card: var(--fr-100); --on-card-muted: var(--fr-300); --rule: var(--fr-700); --accent: var(--fr-yellow); --on-accent: var(--fr-950);
  --ok: var(--fr-green); --bad: var(--fr-red); --busy: var(--fr-yellow);
  --box: #0F1013; --box-edge: var(--fr-700); --box-r: 12px; --btn-r: 9px; --glow: rgb(255 194 26 / .14); --ok-edge: rgb(91 210 122 / .45);
  --sample: var(--fr-850); --sample-edge: var(--fr-700); --on-sample: var(--fr-100);
  --pill: var(--fr-850); --pill-edge: var(--fr-700); --on-pill: var(--fr-100); --on-pill-muted: var(--fr-300); --pill-icon: var(--fr-yellow); --on-pill-icon: var(--fr-950);
}

The form then names the tokens it uses, and its rules read only those names. Changing the company never touches a rule.

style.css — the form's component tokens
.form     { --u-card: var(--card); --u-edge: var(--card-edge); --u-drop: var(--card-drop); --u-r: var(--card-r); --u-top: var(--card-top);
            --u-ink: var(--on-card); --u-dim: var(--on-card-muted); --u-rule: var(--rule); --u-accent: var(--accent); --u-on-accent: var(--on-accent);
            --u-ok: var(--ok); --u-bad: var(--bad); --u-busy: var(--busy); --u-co-face: var(--co-face); --u-co-w: var(--co-w);
            --u-box: var(--box); --u-box-edge: var(--box-edge); --u-box-r: var(--box-r); --u-btn-r: var(--btn-r); --u-glow: var(--glow); --u-ok-edge: var(--ok-edge); }

A script in the page's head picks the company from ?look=freight, tower or vault before the first paint, so the page never flashes the wrong one.

index.html — the company, before the first paint
(() => {
  const html = document.documentElement, q = new URLSearchParams(location.search);
  const look = { a: 'freight', b: 'tower', c: 'vault' }[q.get('look')] || q.get('look');
  html.dataset.look = ['freight', 'tower', 'vault'].includes(look) ? look : 'freight';
  if (q.get('full') === '1') html.dataset.full = '';
})();

The box is a fixed 60 px row. The input is 1 px and transparent, but it still takes the focus, and when it has the keyboard focus the button next to it gets the ring (:focus-visible ~ .pick).

style.css — a field, its box and the hidden input
/* a field: its label and what it takes, the box, and a line that says what happened */
.fields { display: grid; gap: 4px; }
.f-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; height: 20px; }
.f-label { font-size: 13px; font-weight: 600; }
.f-hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; color: var(--u-dim); }
.box { position: relative; height: 60px; margin-top: 4px; border-radius: var(--u-box-r); background: var(--u-box); box-shadow: inset 0 0 0 1px var(--u-box-edge);
  transition: box-shadow .2s, background-color .2s; }
.box[data-over] { box-shadow: inset 0 0 0 2px var(--u-accent), 0 0 0 4px var(--u-glow); }
.field[data-state="error"] .box { box-shadow: inset 0 0 0 1.5px var(--u-bad); }
.field[data-state="done"] .box { box-shadow: inset 0 0 0 1px var(--u-ok-edge); }
.f-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }        /* the real input: kept for the keyboard and screen readers */
.f-input:focus-visible ~ .pick { outline: 2px solid var(--u-accent); outline-offset: 3px; }
style.css — empty: a button and a line
/* empty: a plain file input — a button and a line */
.pick { position: absolute; inset: 0; display: flex; align-items: center; gap: 12px; padding: 0 12px; border-radius: inherit; cursor: pointer; transition: opacity .25s; }
.btn { display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px 0 11px; border-radius: var(--u-btn-r); font-size: 13px; font-weight: 650;
  color: var(--u-on-accent); background: var(--u-accent); box-shadow: 0 8px 18px -12px var(--u-accent); transition: transform .15s; }
.pick:hover .btn { transform: translateY(-1px); }
.pick:active .btn { transform: translateY(1px); }
.btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.none { font-size: 12.5px; color: var(--u-dim); }
.field:not([data-state="empty"]) .pick { opacity: 0; pointer-events: none; }
.box[data-over] .pick { opacity: .35; }

With ?full=1 the frame lets go: the title, the samples and the form flow down the page, the form 560 px wide at most.

style.css — ?full=1: the real page
/* ---- 7. ?full=1: the page on its own, at the real viewport ---- */
[data-full] body { overflow: auto; }
[data-full] .stage { height: auto; min-height: 100dvh; overflow: visible; }
[data-full] .frame { position: relative; left: auto; top: auto; width: 100%; min-height: 100dvh; height: auto; margin: 0; padding: clamp(36px, 8vh, 96px) 16px 64px;
  display: flex; flex-direction: column; align-items: center; }
[data-full] .amb { position: fixed; }
[data-full] .title { position: static; }
[data-full] .form { position: relative; left: auto; right: auto; top: auto; width: min(100%, 560px); margin-top: 28px; }   /* (stage 3 puts the samples above it) */
app.js — all of it: fit the frame, colour the browser
// fit the 432 × 768 composition to the screen (?full=1: no frame, the page is the screen)
function fit() { html.style.setProperty('--k', 'full' in html.dataset ? '1' : Math.min(innerWidth / 432, innerHeight / 768).toFixed(4)); }
fit();
addEventListener('resize', fit);
document.querySelector('meta[name="theme-color"]').content = getComputedStyle(html).getPropertyValue('--page').trim();
Stage 1 · an empty form, by handOpen ↗
Stage 1 · the same markup, Tower's tokensOpen ↗

2 · A field: the real input, checking a file

The hand-written field moves into a <template>: markup the browser parses but never shows, ready to be cloned. Every part is there, empty.

index.html — one field, as a template
<!-- one file field; app.js names it and gives it its rider -->
<template id="field">
  <div class="field" data-state="empty">
    <div class="f-top"><label class="f-label"></label><span class="f-hint"></span></div>
    <div class="box">
      <input class="f-input" type="file">
      <label class="pick"><span class="btn"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></svg>Choose file</span><span class="none">or drop it here</span></label>
      <div class="ride" aria-hidden="true"></div>
      <span class="pct" aria-hidden="true"></span>
      <div class="got"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5.5 12.5l4 4 9-9.5"/></svg><b class="g-name"></b><span class="g-size"></span></div>
      <button class="retry" type="button">Retry</button>
      <button class="x" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg></button>
    </div>
    <p class="f-msg" aria-live="polite"></p>
  </div>
</template>

What changes from company to company is one table: who is asking and for what, its mark, the types it takes and its size limit, the hint, its three fields and the words on its button. Here is Freight's.

app.js — Freight, in the company table
freight: {
  name: 'Northline Freight', job: 'Shipment NL-48210 · customs', mark: 'M12 2.5 3.5 20.5 12 16l8.5 4.5z',
  types: ['pdf', 'jpg', 'jpeg', 'png'], max: 25, hint: 'PDF or JPG · up to 25 MB', send: 'Send to customs',
  fields: ['Commercial invoice', 'Packing list', 'Waybill'],
},

Before a file goes anywhere it is checked, and if something is wrong the field says what: not "invalid file" but "setup.exe isn't a PDF, JPG, PNG file" or "plans.pdf is 240 MB · the limit is 200 MB". The accept attribute only steers the file picker (a user can still pick "All files", and a dropped file ignores it), so the check is the real gate.

app.js — the type, the size, and why a file stays out
const extOf = (name) => (name.includes('.') ? name.split('.').pop().toLowerCase() : '').slice(0, 4);
function size(bytes) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < MB) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0)} MB`;
}
// what is wrong with a file, if anything: it stays out, and the field says why
function check(name, bytes) {
  if (!COMPANY.types.includes(extOf(name))) return `${name} isn't a ${COMPANY.types.filter((t) => t !== 'jpeg').map((t) => t.toUpperCase()).join(', ')} file`;
  if (bytes > COMPANY.max * MB) return `${name} is ${size(bytes)} · the limit is ${COMPANY.max} MB`;
  return '';
}

Each field is a clone of the template, with everything named: the input gets an id, both labels point at it, the hint and the message get ids the input is described by, the input's accept is written from the table, and the × and Retry get labels that say which field they belong to. Choosing a file fires the input's change.

app.js — the fields, cloned and named
const fields = COMPANY.fields.map((label, i) => {
  const el = $('#field').content.firstElementChild.cloneNode(true), id = `file-${i}`;
  const f = { i, el, box: $('.box', el), input: $('.f-input', el), msg: $('.f-msg', el), state: 'empty' };
  f.input.id = id;
  f.input.accept = COMPANY.types.map((t) => `.${t}`).join(',');
  f.input.setAttribute('aria-describedby', `${id}-hint ${id}-msg`);
  $('.f-label', el).htmlFor = id;
  $('.f-label', el).textContent = label;
  $('.pick', el).htmlFor = id;
  $('.f-hint', el).id = `${id}-hint`;
  $('.f-hint', el).textContent = COMPANY.hint;
  f.msg.id = `${id}-msg`;
  $('.x', el).setAttribute('aria-label', `Remove the file from ${label}`);
  $('.retry', el).setAttribute('aria-label', `Retry the upload for ${label}`);
  f.input.addEventListener('change', () => { if (f.input.files[0]) choose(f, f.input.files[0]); });
  $('.x', el).addEventListener('click', () => clear(f));
  $('.fields').append(el);
  return f;
});

choose() is where every file comes in. A file that fails the check shakes the box (the class is taken off and put back, with a forced reflow in between, so the animation runs again on a second try) and empties the input. A file that passes is simply in for now: the field shows its name and size. Stage 4 uploads it from this same place.

app.js — choose(): checked, then in
// src: the File that was chosen (stage 3 adds dropped files and the samples)
function choose(f, src) {
  const why = check(src.name, src.size);
  if (why) {                                                  // turned away: the field shakes and says why
    f.msg.textContent = f.state === 'empty' ? why : `${why} · kept ${f.name}`;
    f.el.classList.remove('is-turned'); void f.el.offsetWidth; f.el.classList.add('is-turned');
    f.input.value = '';
    return;
  }
  Object.assign(f, { name: src.name, size: src.size, ext: extOf(src.name), file: src,
    state: 'done' });                                         // until stage 4 uploads it, a file that passes is simply in
  $('.g-name', f.el).textContent = f.name;
  $('.g-size', f.el).textContent = size(f.size);
  f.el.classList.remove('is-turned');
  f.msg.textContent = `${f.name} chosen`;
  show(f);
}

Remove empties the field and puts the focus back on the input, so a keyboard user can choose again straight away instead of starting from the top of the page.

app.js — remove, and showing a field's state
function clear(f) {
  Object.assign(f, { state: 'empty', name: '' });
  f.input.value = '';
  f.msg.textContent = '';
  f.input.focus();
  show(f);
}

// until stage 4 draws every field each frame, a field shows its state when it changes
function show(f) {
  f.el.dataset.state = f.state;
  f.el.toggleAttribute('data-got', f.state === 'done');
}
style.css — turned away: the box shakes, the line turns red
.field.is-turned .box { animation: turned .42s cubic-bezier(.36, .07, .19, .97); }
@keyframes turned { 20%, 60% { transform: translateX(-6px); } 40%, 80% { transform: translateX(6px); } }
style.css — done: the file, and a way to remove it
/* done: the file, and a way to remove it */
.got { position: absolute; inset: 0 48px 0 0; display: flex; align-items: center; gap: 10px; padding-left: 14px; opacity: 0; transform: translateX(-8px); transition: opacity .3s, transform .3s; }
.got svg { flex: none; width: 26px; height: 26px; padding: 5px; border-radius: 50%; fill: none; stroke: var(--u-on-accent); stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; background: var(--u-ok); }
.got b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; }
.got span { flex: none; font-family: var(--mono); font-size: 11px; color: var(--u-dim); }
.field[data-got] .got { opacity: 1; transform: none; }
.x { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 0; border-radius: 9px;
  background: none; color: var(--u-dim); cursor: pointer; opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s .3s; }
.x:hover { color: var(--u-ink); box-shadow: inset 0 0 0 1px var(--u-rule); }
.x svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.field[data-got] .x { opacity: 1; visibility: visible; transition: opacity .3s; }
Stage 2 · choose a file of your own, or a wrong oneOpen ↗

3 · Dropping, and the sample files

A field also takes a file dropped on it. While a file is held over the box it lights up. The catch is that dragenter and dragleave fire for every child the pointer crosses, so the field counts them and only lets go of the light when the count is back to zero. The window also cancels drops of its own, so a file dropped beside a field doesn't open in the tab and throw the form away.

app.js — dropping a file on a field
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files');
const setOver = (f, on) => { if (f.over !== on) { f.over = on; f.box.toggleAttribute('data-over', on); wake(); } };
for (const f of fields) {
  let depth = 0;                                            // dragenter and dragleave fire for every child: count them
  f.box.addEventListener('dragenter', (e) => { if (hasFiles(e)) { e.preventDefault(); depth++; setOver(f, true); } });
  f.box.addEventListener('dragover', (e) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } });
  f.box.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; setOver(f, false); } });
  f.box.addEventListener('drop', (e) => { e.preventDefault(); depth = 0; setOver(f, false); if (e.dataTransfer.files[0]) choose(f, e.dataTransfer.files[0]); });
}
// a file dropped beside a field shouldn't open in the tab
addEventListener('dragover', (e) => e.preventDefault());
addEventListener('drop', (e) => e.preventDefault());

Above the form go three sample files, one per field, so the component can be tried without hunting for a PDF. Press one and it goes into its field; drag it and a card lifts off the chip and follows the pointer. That card is the first thing on the page that moves, so here comes the 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
// With reduced motion every spring is critically damped: things still arrive, they just don't bounce.
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
const critical = (stiffness) => ({ stiffness, damping: 2 * Math.sqrt(stiffness) });

The card has five springs: its position (stiff, so it stays close behind the pointer), its tilt (the horizontal speed, so it leans into the way you move), its scale and its opacity.

app.js — the dragged card, on springs
// the sample files: press one and it goes into its own field; drag it and a card follows the pointer (a spring behind
// it, tilting with its speed) — let go over a field and it drops in, anywhere else and it flies home
const ghost = $('.ghost');
const g = { x: new Spring(0, spring(900, 52)), y: new Spring(0, spring(900, 52)), r: new Spring(0, spring(300, 22)), s: new Spring(1, spring(400, 28)),
  o: new Spring(0, critical(400)), on: false, home: null };
const fieldAt = (x, y) => fields.find((f) => { const r = f.box.getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; });

A chip is a real button. The drag uses pointer events with the pointer captured, so it keeps following outside the chip; it only starts after 5 px, so a press is still a press, and the click that follows a drag is ignored. Over a field the field lights up, the same light a real file gives; let go there and the sample goes into that field, anywhere else and the card flies home.

app.js — a sample chip: press it, or drag it
COMPANY.samples.forEach(([name, mb, failAt], i) => {
  const b = document.createElement('button');
  b.className = 'sample';
  b.type = 'button';
  b.innerHTML = '<i class="ext"></i><b></b>';
  b.firstChild.textContent = extOf(name);
  b.lastChild.textContent = name;
  b.setAttribute('aria-label', `Put the sample file ${name} in ${COMPANY.fields[i]}`);
  const src = { name, size: Math.round(mb * MB), failAt, sample: b };
  let start = null, moved = false;
  b.addEventListener('click', () => { if (!moved && !b.classList.contains('is-out')) choose(fields[i], src); });
  b.addEventListener('pointerdown', (e) => {
    if (e.button !== 0 || b.classList.contains('is-out')) return;
    start = { x: e.clientX, y: e.clientY }; moved = false;
    b.setPointerCapture(e.pointerId);
  });
  b.addEventListener('pointermove', (e) => {
    if (!start) return;
    if (!moved && Math.hypot(e.clientX - start.x, e.clientY - start.y) < 5) return;
    if (!moved) {                                            // the drag begins: the card lifts off the chip
      moved = true;
      const r = b.getBoundingClientRect();
      g.home = { x: r.left, y: r.top };
      Object.assign(g.x, { value: r.left, target: r.left, velocity: 0 });
      Object.assign(g.y, { value: r.top - 6, target: r.top - 6, velocity: 0 });
      g.s.value = 0.9; g.s.target = 1; g.o.target = 1; g.on = true;
      ghost.querySelector('.ext').textContent = extOf(name);
      ghost.querySelector('b').textContent = name;
      ghost.querySelector('span').textContent = size(src.size);
      ghost.classList.add('is-on');
      b.classList.add('is-out');
    }
    g.x.target = e.clientX - 30; g.y.target = e.clientY - 25;
    const at = fieldAt(e.clientX, e.clientY);
    fields.forEach((f) => setOver(f, f === at));
    wake();
  });
  const end = (e) => {
    if (!start) return;
    start = null;
    if (!moved) return;
    const at = fieldAt(e.clientX, e.clientY);
    fields.forEach((f) => setOver(f, false));
    b.classList.remove('is-out');
    if (at) { choose(at, src); g.s.target = 0.5; g.o.target = 0; }                             // in: it drops into the field
    else { g.x.target = g.home.x; g.y.target = g.home.y; g.o.target = 0; }                    // anywhere else: home
    setTimeout(() => { moved = false; });                   // the click that follows a drag isn't a press
  };
  b.addEventListener('pointerup', end);
  b.addEventListener('pointercancel', end);
  $('.samples').append(b);
});
app.js — drawing the card
function paintGhost(dt) {
  if (!g.on) return;
  g.r.target = clamp(g.x.velocity * 0.012, -14, 14);
  [g.x, g.y, g.r, g.s, g.o].forEach((s) => s.step(dt));
  put(ghost, 'transform', `translate(${px(g.x.value)}, ${px(g.y.value)}) rotate(${g.r.value.toFixed(2)}deg) scale(${g.s.value.toFixed(3)})`);
  put(ghost, 'opacity', Math.max(0, g.o.value).toFixed(3));
  if (g.o.target === 0 && g.o.value < 0.02) { g.on = false; ghost.classList.remove('is-on'); }
}

One loop steps the springs and draws. It runs every frame; with reduced motion it sleeps as soon as everything rests, and wake() starts it again.

app.js — the loop
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
  last = now;
  paintGhost(dt);
  const still = !g.on;
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}
wake();
style.css — the sample chips and the card
/* ---- 4. the sample files (drag one onto the picture, or press it), and the one being dragged ---- */
.samples { position: absolute; left: 20px; right: 20px; top: 146px; z-index: 5; display: flex; gap: 7px; }
.sample { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 36px; padding: 0 10px 0 6px; border: 0; border-radius: 10px; cursor: grab; touch-action: none; user-select: none;
  color: var(--s-ink); background: var(--s-bg); box-shadow: inset 0 0 0 1px var(--s-edge), 0 10px 20px -16px var(--b-40); transition: transform .15s, opacity .2s; }
.sample:active { cursor: grabbing; }
.sample:focus-visible { outline: 2px solid var(--s-accent); outline-offset: 2px; }
.sample.is-out { opacity: .32; }                                       /* it is being dragged, or it is already in */
.sample b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; font-weight: 600; }
.sample .ext { width: 26px; height: 22px; font-size: 7.5px; }
.ext { flex: none; display: grid; place-items: center; width: 30px; height: 26px; border-radius: 6px; font-family: var(--mono); font-size: 8.5px; font-weight: 600; font-style: normal;
  letter-spacing: .04em; text-transform: uppercase; color: var(--s-on-accent, var(--u-on-accent)); background: var(--s-accent, var(--u-accent)); }
.ghost { position: fixed; left: 0; top: 0; z-index: 40; display: none; align-items: center; gap: 10px; width: 210px; height: 50px; padding: 0 14px 0 9px; border-radius: 12px; pointer-events: none;
  color: var(--s-ink); background: var(--s-bg); box-shadow: inset 0 0 0 1px var(--s-edge), 0 24px 40px -14px rgb(0 0 0 / .55); transform-origin: 30px 25px; }
.ghost.is-on { display: flex; }
Stage 3 · drag a sample onto a fieldOpen ↗

4 · The transport: simulated, or a real XMLHttpRequest

Now the file goes up. With no server the field simulates the network, so the demo runs anywhere. Set ENDPOINT to your server's URL and a real file is sent with XMLHttpRequest: fetch() still can't report how much of an upload has been sent, while xhr.upload.onprogress can. The sample files have no bytes, so they are always simulated.

app.js — where uploads go
// With no endpoint the field simulates the network, so the demo works anywhere. Set ENDPOINT to your server's URL and
// each real file is sent with XMLHttpRequest, whose upload.onprogress moves the same rider.
const ENDPOINT = null;

function begin(f) {
  f.started = clock;
  if (!ENDPOINT || !f.file) { f.simulated = true; return; }
  f.simulated = false;
  const xhr = (f.xhr = new XMLHttpRequest());
  xhr.upload.onprogress = (e) => { if (e.lengthComputable) f.loaded = (e.loaded / e.total) * f.size; wake(); };
  xhr.onload = () => (xhr.status < 300 ? finish(f) : fail(f, `The server said ${xhr.status}`));
  xhr.onerror = () => fail(f, 'Connection lost');
  const body = new FormData();
  body.append('file', f.file);
  xhr.open('POST', ENDPOINT);
  xhr.send(body);
}
function stop(f) { if (f.xhr) { f.xhr.abort(); f.xhr = null; } }

The simulation is meant to feel like a network: a slow first half second, a wobble, and bigger files take longer (the time grows with the logarithm of the size, as real uploads roughly do once the connection is up). A sample can be set to fail part way, at failAt; it fails once, so Retry gets through.

app.js — a believable network
// a believable network: a slow first half second, a wobble, bigger files take longer
function simulate(f, dt) {
  const seconds = 1.6 + 0.6 * Math.log2(1 + f.size / MB);
  const warm = Math.min(1, (clock - f.started) / 0.5);
  const wobble = 0.75 + 0.5 * (0.5 + 0.5 * Math.sin(clock * 2.3 + f.i * 1.7));
  f.loaded = Math.min(f.size, f.loaded + (f.size / seconds) * dt * wobble * (0.35 + 0.65 * warm));
  if (f.failAt && f.loaded >= f.size * f.failAt) { f.loaded = f.size * f.failAt; f.failAt = 0; fail(f, 'Connection lost'); }
  else if (f.loaded >= f.size) finish(f);
}

The end of an upload is one of three things. Finished: everything is loaded. Failed: the field turns red, says why and how far it got. Retried: it carries on from there.

app.js — finished, failed, retried
function finish(f) { f.loaded = f.size; f.uploaded = true; f.xhr = null; wake(); }
function fail(f, why) { f.state = 'error'; f.reason = why; f.xhr = null; f.msg.textContent = `${why} · stopped at ${Math.floor((f.loaded / f.size) * 100)}%`; wake(); }
function retry(f) { f.state = 'busy'; f.msg.textContent = `Uploading ${f.name}…`; begin(f); wake(); }

choose() now resets the field and calls begin() in place of showing the file at once.

app.js — choose() starts the upload
stop(f);
if (f.sample) f.sample.classList.remove('is-out');
Object.assign(f, { name: src.name, size: src.size, ext: extOf(src.name), file: src instanceof File ? src : null, sample: src.sample || null,
  failAt: src.failAt || 0, loaded: 0, uploaded: false, state: 'busy', reason: '' });
f.sample?.classList.add('is-out');
f.k.value = f.k.target = 0;
f.arrive.value = f.arrive.target = 0;
$('.g-name', f.el).textContent = f.name;
$('.g-size', f.el).textContent = size(f.size);
f.el.classList.remove('is-turned');
f.msg.textContent = `Uploading ${f.name}…`;
begin(f);
wake();

Every frame, paint() draws each field. The progress goes through a spring, so the number counts up smoothly even when the network delivers it in lumps. The field is "done" only once the spring has made it to the top, not when the last byte left, so the number never jumps from 70% to the file. The state goes on the element as data-state, and the stylesheet does the rest: the button and its line fade, the percentage shows, a failed box turns red and shows Retry. aria-busy tells a screen reader the field is working, and the message line announces the start, a failure and the end.

app.js — drawing a field
function paint(f, dt) {
  if (f.state === 'busy' || f.state === 'error') f.k.target = f.size ? f.loaded / f.size : Number(f.uploaded);   // an empty file is up at once
  if (f.state === 'busy' && f.uploaded && f.k.value > 0.995) {  // it has made it: the file (stage 5: the rider bows first)
    f.state = 'done';
    f.arrive.target = 1;
    f.msg.textContent = `${f.name} uploaded`;
  }
  f.k.step(dt);
  f.arrive.step(dt);
  if (f.el.dataset.state !== f.state) f.el.dataset.state = f.state;
  f.el.toggleAttribute('data-got', f.state === 'done' && f.arrive.value > 0.9);
  f.box.setAttribute('aria-busy', String(f.state === 'busy'));
  put(f.pct, 'text', f.state === 'error' ? 'Paused' : `${Math.floor(f.k.value * 100 + 1e-6)}%`);
}
app.js — the loop runs the uploads
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
  last = now;
  clock += dt;
  for (const f of fields) if (f.state === 'busy' && f.simulated && !f.uploaded) simulate(f, dt);
  fields.forEach((f) => paint(f, dt));
  paintGhost(dt);
  const still = !g.on && fields.every((f) => f.state !== 'busy' && f.state !== 'error' && f.k.resting(0.0005) && f.arrive.resting(0.001) && !f.over);
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}
wake();
Stage 4 · press the samples; the third one fails, press RetryOpen ↗

5 · The riders: truck, crane, coin

Now the field carries the file. Each company has a rider, drawn into the field's .ride layer. riders.js defines them all with one small interface: a rider gets its layer and the spring helpers, and returns label(ext) and frame(s), which is called every frame with the progress, the state, the field's size and the time. Everything a rider does is worked out from those numbers, so it follows the upload, stops when it fails and goes back to the start when the field is emptied.

riders.js — what a rider is
// The riders: what carries a file across its field while it uploads. Each company has one, drawn in the field's .ride
// layer and moved from the upload's progress. A rider is RIDERS.name(ride, { ext, spring, critical, calm }) and returns
// { frame(s), label(ext), resting() }, where s = {
//   k: the upload's progress 0–1 (smoothed)      state: 'empty' | 'busy' | 'error' | 'done'
//   over: a file is being dragged over the field  arrive: 0–1, the bow it takes once the file is up
//   w, h: the field's size in px                  dt: seconds since the last frame    t: the studio clock in seconds }
// The percentage sits at the field's right (PCT px), so a rider stops short of it.
window.RIDERS = {};

Freight: a flatbed truck carries the file's crate, its type stamped on a label. Its x is a spring after the progress, it bobs a little with its speed, its wheels turn with the distance, its headlight goes ahead of it, and the road behind it fills yellow. A failure stops it with the hazard lights blinking; at 100% it drives off the right edge, on exactly the arrive curve. A file held over an empty field makes it nose in from the left.

riders.js — Freight's truck
const x = new Spring(-80, spring(170, 24));
return {
  label(ext) { tag.textContent = ext.toUpperCase(); },
  resting: () => x.resting(0.05),
  frame({ k, state, over, arrive, w, h, dt, t }) {
    const run = w - TW - PCT - 8;
    x.target = state === 'empty' ? (over ? -26 : -80) : 8 + k * run + arrive * (w + 30);
    if (state === 'done' && arrive > 0) x.value = x.target;       // driving off: it follows the bow exactly
    x.step(dt);
    const top = (h - 30 * S) / 2 + 2, bob = calm ? 0 : Math.sin(t * 24) * Math.min(1, Math.abs(x.velocity) / 80) * 0.5;
    truck.style.transform = `translate(${px2(x.value)}, ${px2(top + bob)})`;
    wheels.forEach((el, i) => el.setAttribute('transform', `rotate(${((x.value / 3.6) * 57.3).toFixed(1)} ${[9, 18, 47, 57][i]} 26)`));
    fill.style.width = px2(Math.max(0, x.value + 6 * S));
    beam.style.transform = `translate(${px2(x.value + 63 * S)}, ${px2(top + 6)})`;
    haz.setAttribute('opacity', state === 'error' ? (Math.sin(t * 9) > 0 ? 1 : 0.1) : 0.25);
  },

Tower: the progress is turned into blocks. The whole blocks are laid in a row along the field's floor, and the fraction of the next one is the crane's job: the hanging block comes down from the jib on an ease-in-out and lands as the row grows by one. A failure leaves the block swinging and the lamp flashing; at 100% a gold flag goes up on the last block and the crane rolls away.

riders.js — Tower's crane, frame by frame
frame({ k, state, over, arrive, w, h, dt, t }) {
  const run = w - INSET - PCT - 30, laid = state === 'empty' ? 0 : k * run;
  const n = Math.floor(laid / B), phase = state === 'done' ? 1 : (laid % B) / B;    // blocks down, and the next one's way
  fill.style.width = px2(n * B);
  const spot = INSET + n * B;                                    // where the next block lands
  cx.target = state === 'empty' ? (over ? INSET + B / 2 - 4 : -60) : spot + B / 2 - 4 + arrive * (w + 40);
  cx.step(dt);
  const top = h - INSET - 52;                                    // the crane's feet on the ground line, its jib in the field
  crane.style.transform = `translate(${px2(cx.value)}, ${px2(top)})`;
  crane.style.opacity = (1 - arrive).toFixed(3);
  const ease = phase < 0.5 ? 2 * phase * phase : 1 - 2 * (1 - phase) ** 2;
  const land = 52 - BAR - 16.4;                                  // where the load hangs when its block sits on the row
  const drop = state === 'empty' ? (over ? land - 4 : 6) : 6 + ease * (land - 6);
  const swing = calm ? 0 : state === 'error' ? Math.sin(t * 3.2) * 9 : 0;
  load.setAttribute('transform', `translate(0 ${drop.toFixed(2)}) rotate(${swing.toFixed(2)} 4 0)`);
  load.style.display = state === 'done' ? 'none' : '';
  cable.setAttribute('y2', drop.toFixed(2));
  lamp.setAttribute('opacity', state === 'error' ? (Math.sin(t * 11) > 0 ? 1 : 0.1) : 0.4 + 0.6 * (Math.sin(t * 2.4) > 0.5));
  ghost.style.opacity = state === 'empty' && over ? (0.6 + 0.4 * Math.sin(t * 8)).toFixed(3) : '0';
  up.target = state === 'done' ? 1 : 0;
  up.step(dt);
  flag.style.transform = `translate(${px2(INSET + n * B - 9)}, ${px2(h - INSET - BAR - 20 + (1 - up.value) * 8)}) scale(${Math.max(0, up.value).toFixed(3)})`;
},

Vault: a coin stamped with the bank's V rolls along (its rotation is its distance over its radius, so it never slides) and leaves a bar of gold behind. A failure rocks it on the spot and turns the lock red; at 100% it rolls into the lock, shrinks into it, and the shackle snaps shut on a stiff spring as the lock turns green.

riders.js — Vault's coin and lock
frame({ k, state, over, arrive, w, h, dt, t }) {
  const lockX = w - LOCK - 12, run = lockX - 8 - D - 8 - 44;     // the coin stops short of the percentage, left of the lock
  const rock = state === 'error' ? Math.sin(t * 7) * 3 : 0;
  x.target = state === 'empty' ? (over ? 6 : -40) : 8 + k * run + rock + arrive * (lockX + 2 - (8 + run));
  if (state === 'done' && arrive > 0) x.value = x.target;
  x.step(dt);
  const into = Math.max(0, (arrive - 0.6) / 0.4);                 // the last stretch: it drops into the lock
  const y = (h - D) / 2 - 3;
  coin.style.transform = `translate(${px2(x.value)}, ${px2(y)}) rotate(${((x.value / (D / 2)) * 57.3).toFixed(1)}deg) scale(${(1 - into).toFixed(3)})`;
  fill.style.width = px2(Math.max(0, Math.min(x.value + D / 2, lockX) - 8));
  snap.target = state === 'done' && arrive > 0.95 ? 1 : 0;
  snap.step(dt);
  shackle.setAttribute('transform', `translate(0 ${(snap.value * 3).toFixed(2)})`);
  const colour = state === 'error' ? '#FF6B5B' : snap.value > 0.5 ? '#6FD39A' : '#D9A25F';
  body.setAttribute('fill', colour); shackle.setAttribute('stroke', colour);
  lock.style.transform = `translate(${px2(lockX)}, ${px2((h - 26) / 2)}) scale(${(1 + Math.max(0, snap.velocity) * 0.004).toFixed(3)})`;
  lock.style.opacity = state === 'empty' && !over ? '0' : '1';
},

In app.js the field gets its rider when it is made, and a ResizeObserver keeps the field's size, so a rider fits the box at any width.

app.js — each field gets its rider
f.rider = RIDERS[look]($('.ride', el), { spring, critical, calm });
new ResizeObserver(() => { f.w = f.box.clientWidth; f.h = f.box.clientHeight; }).observe(f.box);

Done now waits for the rider's bow: when the progress reaches the top, the arrive spring runs from 0 to 1, the rider drives off, raises its flag or drops into the lock, and the file's row only shows once it is nearly there.

app.js — the rider, drawn with the field
function paint(f, dt, t) {
  if (f.state === 'busy' || f.state === 'error') f.k.target = f.size ? f.loaded / f.size : Number(f.uploaded);   // an empty file is up at once
  if (f.state === 'busy' && f.uploaded && f.k.value > 0.995) {  // the rider has made it: its bow, then the file
    f.state = 'done';
    f.arrive.target = 1;
    f.msg.textContent = `${f.name} uploaded`;
  }
  f.k.step(dt);
  f.arrive.step(dt);
  if (f.el.dataset.state !== f.state) f.el.dataset.state = f.state;
  f.el.toggleAttribute('data-got', f.state === 'done' && f.arrive.value > 0.9);
  f.box.setAttribute('aria-busy', String(f.state === 'busy'));
  put(f.pct, 'text', f.state === 'error' ? 'Paused' : `${Math.floor(f.k.value * 100 + 1e-6)}%`);
  f.rider.frame({ k: f.k.value, state: f.state, over: f.over, arrive: f.arrive.value, w: f.w, h: f.h, dt, t });
}
style.css — the riders' worlds
/* the riders' worlds */
/* freight: the field becomes a lane at night — a dashed centre line, the road behind the truck lit yellow, a headlight */
[data-look="freight"] .lane { inset: 0; width: 100%; height: 100%; background: linear-gradient(90deg, rgb(255 194 26 / .5) 50%, transparent 50%) 0 50% / 16px 1.4px repeat-x; opacity: .45; }
[data-look="freight"] .fill { height: 100%; background: linear-gradient(90deg, rgb(255 194 26 / .04), rgb(255 194 26 / .2)); box-shadow: inset 0 -2px 0 var(--fr-yellow); }
[data-look="freight"] .beam { width: 100px; height: 26px; background: linear-gradient(90deg, rgb(255 241 196 / .42), transparent); clip-path: polygon(0 38%, 100% 0, 100% 100%, 0 62%); }
[data-look="freight"] .field[data-state="error"] .fill { background: linear-gradient(90deg, rgb(255 90 78 / .04), rgb(255 90 78 / .18)); box-shadow: inset 0 -2px 0 var(--fr-red); }
Stage 5 · Freight: the truck carries each fileOpen ↗
Stage 5 · Tower: the crane lays it block by blockOpen ↗
Stage 5 · Vault: the coin rolls into the lockOpen ↗

6 · The form's state, and the post

The last stage makes the fields a form. A small pill in the head counts them ("2 of 3 uploaded", "Upload stopped", "Sent"), and is a status, so a screen reader hears it change. The button waits until all three are up.

app.js — the form's state, and the button that waits
function tell() {
  const done = fields.filter((f) => f.state === 'done').length;
  const error = fields.some((f) => f.state === 'error');
  const all = done === fields.length;
  send.disabled = !all || sent;
  form.dataset.state = sent ? 'sent' : error ? 'error' : all ? 'done' : fields.some((f) => f.state === 'busy') ? 'busy' : 'idle';
  put(stateText, 'text', sent ? 'Sent' : error ? 'Upload stopped' : `${done} of ${fields.length} uploaded`);
}
app.js — sending
form.addEventListener('submit', (e) => {                    // in a real form the uploaded files' ids would go with it
  e.preventDefault();
  sent = true;
  $('.send span').textContent = COMPANY.sent;
  wake();
});

And it becomes the page that was posted. Each company is its own post in the series, with its own number, name and line in the title.

index.html — each company, its own post
// each company is its own post in the series: its number, its name and its line in the title
const [num, name, hint] = {
  freight: ['17', 'Freight', 'Pick a file. Watch it ship.'],
  tower: ['18', 'Tower', 'Every byte lays a block.'],
  vault: ['19', 'Vault', 'Roll it in. Lock it up.'],
}[html.dataset.look];
document.addEventListener('DOMContentLoaded', () => {
  document.querySelector('.series b').textContent = num;
  document.querySelector('.title h1').firstChild.textContent = `${name} `;
  document.querySelector('.hint').textContent = hint;
  document.title = `${name} upload — UI Components ${num}`;
});

A light drifts behind the form, reading time from a studio clock a capture tool can pause or drive; with reduced motion it stands still. A footer with the credits and a touch circle (for the video) arrive too, and a small console API for scripts.

app.js — the loop, with a light that drifts
const glows = [...document.querySelectorAll('.amb .glow')];
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
  last = now;
  clock += dt;
  const t = studio.now() / 1000;
  for (const f of fields) if (f.state === 'busy' && f.simulated && !f.uploaded) simulate(f, dt);
  fields.forEach((f) => paint(f, dt, t));
  tell();
  paintGhost(dt);
  const amb = calm ? 0 : t;
  glows.forEach((el, i) => put(el, 'transform', `translate(${px(Math.sin(amb * (0.13 + i * 0.05) + i * 2) * 26)}, ${px(Math.cos(amb * (0.11 + i * 0.04) + i) * 18)})`));
  const still = !g.on && fields.every((f) => f.state !== 'busy' && f.state !== 'error' && f.k.resting(0.0005) && f.arrive.resting(0.001) && !f.over && f.rider.resting());
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}
wake();
app.js — for scripts and the console
// for scripts and the tutorial's console: put a sample (0, 1, 2) or a made-up file in a field, read the state
window.__upload = {
  sample: (i) => document.querySelectorAll('.sample')[i].click(),
  put: (field, name, mb, failAt = 0) => choose(fields[field], { name, size: Math.round(mb * MB), failAt }),
  state: () => ({ state: form.dataset.state, fields: fields.map((f) => ({ name: f.name || '', state: f.state, pct: Math.floor(f.k.value * 100) })) }),
};
Stage 6 · the finished formOpen ↗

Three companies, three riders

The companies share the markup, the script and the three token layers; each is a block of semantic tokens, a row of the company table and its rider. Here are Tower and Vault.

style.css — Tower's semantic tokens
/* B · TOWER: an architecture studio by day. Warm concrete paper, a faint drawing grid, graphite lines, a gold hand */
[data-look="tower"] {
  --title-face: var(--syne); --title-w: 700; --title-size: 46px; --title-wdth: 100; --title-track: -.035em;
  --em-face: var(--syne); --em-style: normal; --em-w: 500; --em: var(--tw-gold); --co-face: var(--syne); --co-w: 700;
  --page: var(--tw-100);
  --field-bg: radial-gradient(70% 46% at 50% 30%, rgb(255 255 255 / .9), transparent 72%),
              linear-gradient(rgb(40 50 70 / .055) 1px, transparent 1px) 0 0 / 24px 24px, linear-gradient(90deg, rgb(40 50 70 / .055) 1px, transparent 1px) 0 0 / 24px 24px;
  --on-field: var(--tw-ink); --on-field-muted: var(--tw-ink-2); --chip: var(--tw-ink); --on-chip: var(--tw-50);
  --avatar-ring: var(--tw-ink); --lockup: brightness(0); --light: rgb(255 236 200 / .5);
  --card: var(--tw-50); --card-edge: rgb(27 27 25 / .12); --card-r: 4px;
  --card-drop: 0 34px 60px -34px rgb(60 50 30 / .35), 0 2px 0 rgb(27 27 25 / .04);
  --card-top: linear-gradient(90deg, var(--tw-ink) 0 28%, var(--tw-gold) 28% 30%, transparent 30%);
  --on-card: var(--tw-ink); --on-card-muted: var(--tw-ink-2); --rule: rgb(27 27 25 / .1); --accent: var(--tw-gold); --on-accent: var(--tw-50);
  --ok: var(--tw-green); --bad: var(--tw-red); --busy: var(--tw-gold);
  --box: #F6F3EE; --box-edge: rgb(27 27 25 / .16); --box-r: 2px; --btn-r: 2px; --glow: rgb(176 125 43 / .14); --ok-edge: rgb(62 138 92 / .5);
  --sample: var(--tw-50); --sample-edge: rgb(27 27 25 / .14); --on-sample: var(--tw-ink);
  --pill: rgb(251 250 247 / .9); --pill-edge: rgb(27 27 25 / .12); --on-pill: var(--tw-ink); --on-pill-muted: var(--tw-ink-2); --pill-icon: var(--tw-ink); --on-pill-icon: var(--tw-50);
}
style.css — Vault's semantic tokens
/* C · VAULT: a private bank. Deep green, brass rims, warm ink, a serif that has been signing cheques for a century */
[data-look="vault"] {
  --title-face: var(--serif); --title-w: 600; --title-size: 50px; --title-wdth: 100; --title-track: -.03em;
  --em-face: var(--serif); --em-style: italic; --em-w: 400; --em: var(--va-brass); --co-face: var(--serif); --co-w: 600;
  --page: var(--va-950);
  --field-bg: radial-gradient(70% 40% at 50% 0%, rgb(43 120 88 / .28), transparent 72%), radial-gradient(50% 30% at 50% 100%, rgb(217 162 95 / .08), transparent 70%);
  --on-field: var(--va-100); --on-field-muted: var(--va-300); --chip: var(--va-brass); --on-chip: var(--va-950);
  --avatar-ring: var(--va-brass); --lockup: none; --light: rgb(111 211 154 / .07);
  --card: linear-gradient(180deg, var(--va-850), var(--va-900)); --card-edge: rgb(217 162 95 / .3); --card-r: 20px;
  --card-drop: 0 34px 64px -30px rgb(0 0 0 / .85), inset 0 1px 0 rgb(243 209 156 / .1);
  --card-top: linear-gradient(90deg, transparent, var(--va-brass) 30%, var(--va-brass-2) 50%, var(--va-brass) 70%, transparent);
  --on-card: var(--va-100); --on-card-muted: var(--va-300); --rule: rgb(217 162 95 / .16); --accent: var(--va-brass); --on-accent: var(--va-950);
  --ok: var(--va-green); --bad: var(--va-red); --busy: var(--va-brass);
  --box: #07140E; --box-edge: rgb(217 162 95 / .22); --box-r: 14px; --btn-r: 999px; --glow: rgb(217 162 95 / .16); --ok-edge: rgb(111 211 154 / .45);
  --sample: var(--va-850); --sample-edge: rgb(217 162 95 / .28); --on-sample: var(--va-100);
  --pill: var(--va-850); --pill-edge: rgb(217 162 95 / .3); --on-pill: var(--va-100); --on-pill-muted: var(--va-300); --pill-icon: var(--va-brass); --on-pill-icon: var(--va-950);
}
Look · Freight, the truckOpen ↗
Look · Tower, the craneOpen ↗
Look · Vault, the coinOpen ↗

Make it yours

  • Companies are tokens, one row of the company table and a rider: pick one with ?look=freight, tower or vault (or a, b, c). ?full=1 shows the real page at the size of your window.
  • A real server is one line: set ENDPOINT and every real file is posted as multipart/form-data under the name file, with its progress moving the rider. In a real form your server would answer with an id for each file, and the form would send those ids.
  • Your own fields and limits are the table: the types, the size limit, the hint and the fields. The check always runs in the page; check again on the server, which is the only check that counts.
  • Your own rider is a function that draws into .ride and returns label() and frame(); the progress, the state and the field's size are all it needs.
  • Accessibility is already there: real file inputs with real labels, the hint and the message tied to each input, a reason for every file that stays out, a busy field marked aria-busy, a message line and a form state that are announced, real buttons for Retry, × and sending, the focus back on the input after a remove, and prefers-reduced-motion.
The one idea worth keeping

Keep the real input and dress the field, not the other way round. The <input type="file"> and its labels give you the picker, the keyboard and the screen reader for free; everything else (the drop, the samples, the rider) only calls the same choose(), and the field shows its state through one data-state.