Library / Tutorial

Character auth,
step by step.

A sign-in card that is one piece of liquid glass. It springs to the size of each step: the sign-in form, the longer create-account form, a code step with six boxes, a done step. Each field checks itself as you go, and a character watches over it all: it follows your caret, hides its eyes for your password, gets sad at a mistake, nods when a field is right and jumps for joy at the end. Six stages, each a live page you can use.

6Stages
3Characters
0Libraries

This is the whole build of Character auth, in the order it was written: a sign-in card for a made-up product, Calmdeck, with a character that watches you fill it in. Each stage below is a live page you can use: type into it, switch the tabs, make mistakes on purpose. The code under each heading is only what that stage adds; the finished source is at the end.

Three files do the work from the start: index.html for the page and the card, style.css for the tokens and the layout, and app.js for the behaviour. From stage 3 there is a fourth, spring.js, and in stage 6 the character arrives in four more: characters.js and one file per character. Nothing is sent anywhere: every check runs in the page and the code is a demo code, printed on the card.

1 · The frame, the card and its glass

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 sign-in page at the size of your window, with a top bar, a footer and, on a wide screen, the product's pitch beside the card. The card is a <section class="auth">. Its first child is the glass, .drop: an empty box with the material of the look, hidden from screen readers. Over it sit the steps, called views: the form, the code step and the done step. The form is a real <form> with two tabs on top.

index.html — the card, its glass and the tabs
<section class="auth" aria-label="Sign in (demo)">
  <div class="drop" aria-hidden="true"><i class="d-spot"></i><i class="d-sheen"></i><i class="d-rim"></i><i class="d-ring"></i></div>

  <!-- step 1 · the form: sign in, or create an account (the same fields; the name and the strength meter flow in).
       Each field checks itself: a tick when it is right, a mark and a line under it when it is not. -->
  <form class="view v-form" id="v-form" novalidate>
    <div class="seg" role="tablist" aria-label="Account">
      <i class="seg-lens" aria-hidden="true"></i>
      <button type="button" role="tab" id="tab-in" aria-selected="true" aria-controls="v-form">Sign in</button>
      <button type="button" role="tab" id="tab-up" aria-selected="false" aria-controls="v-form" tabindex="-1">Create account</button>

Every field is a box with its label inside, a mark at its end (a tick or an exclamation, drawn as one SVG) and a line under it for what is wrong, announced politely. The line is linked to the input with aria-describedby.

index.html — the email field
<div class="field f-email">
  <div class="field-in">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" inputmode="email" placeholder="you@example.com" spellcheck="false" required aria-describedby="email-msg">
    <span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24"><circle class="mark-bg" cx="12" cy="12" r="11"/><path class="mark-ok" pathLength="1" d="M7.5 12.4l3 3 6-6.6"/><path class="mark-bad" d="M12 6.8v6.4M12 17v.2"/></svg></span>
    <div class="msg" id="email-msg" aria-live="polite"><p class="msg-in"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 4.8v3.6M8 11v.1"/></svg><span class="msg-text"></span></p></div>
  </div>
</div>

The code step and the done step are in the markup from the start, hidden until stage 4 needs them.

index.html — the two later steps open like this
<!-- step 2 · the code: one real input (one-time-code) under six drawn boxes -->
<div class="view v-code" id="v-code" role="group" aria-labelledby="code-title" hidden>
  …
<!-- step 3 · done -->
<div class="view v-done" id="v-done" role="status" hidden>

Every colour, shadow and material comes from a CSS variable, in three layers. At the bottom are the primitives: ramps with no meaning, one family per look (night greys, oat paper and cobalt, clay pastels).

style.css — the primitives, the start of the ramps and the faces
:root {
  --n-1000: #000; --n-975: #050506; --n-950: #08080A; --n-900: #0E0E11; --n-850: #151519; --n-800: #1D1D22;
  --n-700: #2B2B31; --n-500: #6E6E78; --n-400: #A0A0A9; --n-300: #C6C6CD; --n-200: #E3E3E8; --n-100: #F2F2F4; --n-0: #fff;
  …
  --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
  --serif: 'Instrument Serif', 'Times New Roman', serif;
  --mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
  --round: 'Fredoka', ui-rounded, system-ui, sans-serif;
}

On top are the semantic tokens: what a value is for. The page, the glass, the lens, the fields and the button each get their own names, and a whole set is written once per look on [data-look]. Three looks ship from the first stage: glass (smoked liquid glass at night), nordic (oat paper, charcoal ink and cobalt) and clay (soft pastel clay, pressed out of the page). Here is glass.

style.css — the semantic tokens of the glass look
/* A · GLASS: smoked liquid glass at night. A clear lens with a bright rim, white type, white light behind */
[data-look="glass"] {
  --title-face: var(--sans); --title-w: 700; --em-face: var(--serif); --em-style: italic;
  --head-face: var(--sans); --head-w: 650; --head-size: 22px; --btn-face: var(--sans); --btn-w: 650;
  --page: var(--n-975);
  --field-bg: radial-gradient(ellipse 70% 42% at 50% 58%, var(--n-850) 0%, var(--n-950) 62%, var(--n-975) 100%);
  --on-field: var(--n-0); --on-field-muted: var(--n-400); --chip: var(--n-0); --on-chip: var(--n-1000); --em: var(--n-300);
  --avatar-ring: var(--n-300); --lockup: none; --light: rgb(255 255 255 / .16); --halo: var(--w-9);
  --glass: linear-gradient(180deg, var(--w-9), var(--w-4) 60%), rgb(14 14 17 / .56); --glass-filter: blur(28px) saturate(1.5) brightness(1.08);
  --glass-rim: inset 0 1px 0 var(--w-40), inset 0 -1px 0 var(--w-6), inset 0 0 0 1px var(--w-12), inset 0 0 30px var(--w-4);
  --glass-drop: 0 40px 80px -28px var(--n-1000), 0 12px 24px -16px var(--b-70);
  --card-r: 28px; --spot: var(--w-9); --rim: var(--w-70); --sheen: var(--w-12);
  --lens-bg: radial-gradient(closest-side, transparent 56%, var(--w-6) 80%, var(--w-18) 100%), linear-gradient(180deg, var(--w-12), transparent 50%);
  --lens-filter: brightness(1.3) saturate(1.2);
  --lens-rim: inset 0 0 0 1px var(--w-28), inset 0 1.5px 3px var(--w-40), inset 0 -2px 6px var(--w-6), 0 10px 22px -12px var(--n-1000);
  --on-lens: var(--n-0);
  --on-card: var(--n-0); --on-card-muted: var(--n-400);
  --input: var(--w-4); --input-edge: var(--w-12); --input-focus: var(--w-70); --placeholder: var(--n-500); --input-depth: 0 0 #0000;
  --danger: var(--r-400); --on-danger-soft: var(--r-300); --danger-glow: rgb(255 107 107 / .16); --success: var(--gn-400); --on-mark: var(--n-1000);
  --cta: var(--n-0); --on-cta: var(--n-1000); --cta-depth: inset 0 -2px 4px var(--b-12), 0 10px 26px -12px var(--w-40);
  --meter-1: var(--n-500); --meter-2: var(--n-300); --meter-3: var(--n-0);
  --box: var(--w-4); --box-edge: var(--w-12); --check: var(--n-1000); --ring: var(--w-70);
  --accent: var(--n-0); --accent-soft: var(--w-12); --chip-soft: var(--w-6); --chip-soft-edge: var(--w-12); --ok: var(--gn-400);
  --em-fill: linear-gradient(100deg, var(--n-0) 0%, var(--n-400) 55%, var(--n-0) 100%); --em-drop: none;
  --grain: .22; --grid-line: rgb(255 255 255 / .05);
  --pill: var(--w-6); --pill-edge: var(--w-12); --on-pill: var(--n-0); --on-pill-muted: var(--n-400); --pill-icon: var(--n-0); --on-pill-icon: var(--n-1000);
}

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

style.css — the card's component tokens
.auth     { --au-glass: var(--glass); --au-filter: var(--glass-filter); --au-rim: var(--glass-rim); --au-drop: var(--glass-drop); --au-spot: var(--spot); --au-light: var(--rim); --au-sheen: var(--sheen);
            --au-ink: var(--on-card); --au-dim: var(--on-card-muted); --au-input: var(--input); --au-input-edge: var(--input-edge); --au-focus: var(--input-focus); --au-ph: var(--placeholder);
            --au-danger: var(--danger); --au-danger-soft: var(--on-danger-soft); --au-cta: var(--cta); --au-on-cta: var(--on-cta);
            --au-m1: var(--meter-1); --au-m2: var(--meter-2); --au-m3: var(--meter-3); --au-box: var(--box); --au-box-edge: var(--box-edge); --au-check: var(--check); --au-ring: var(--ring);
            --au-lens: var(--lens-bg); --au-lens-filter: var(--lens-filter); --au-lens-rim: var(--lens-rim); --au-on-lens: var(--on-lens);
            --au-input-depth: var(--input-depth); --au-cta-depth: var(--cta-depth); --au-ok: var(--success); --au-on-mark: var(--on-mark); --au-danger-glow: var(--danger-glow);
            --au-head: var(--head-face); --au-head-w: var(--head-w); --au-head-size: var(--head-size); --au-btn: var(--btn-face); --au-btn-w: var(--btn-w); }

A script in the page's head picks the look from ?look=glass, nordic or clay before the first paint, so the page never flashes the wrong one.

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

The page follows its container, not the screen. .signin is a size container; from 860 px it has room for the brand side, so a container query shows it. In the 9:16 frame the container is just the card's width, so the frame always shows the phone layout.

style.css — the page follows its container
.signin { container: signin / inline-size; position: absolute; left: 44px; top: 136px; width: 344px;
  display: flex; align-items: center; justify-content: center; gap: clamp(48px, 7vw, 112px); }
.card-side { position: relative; flex: none; width: min(100%, 400px); padding: 0 0 22px; }   /* 22: the note */
.note { position: absolute; left: 0; right: 0; top: 0; margin: 0; text-align: center; font-size: 12px; font-weight: 500; color: var(--note-ink); }   /* rides under the glass (app.js) */
.brand-side { display: none; flex: 0 1 520px; color: var(--bs-ink); }
@container signin (min-width: 860px) { .brand-side { display: block; } }

The glass is one element under the content. Later stages move its box; the steps are laid out at the card's width on top of it, and clipped to it.

style.css — the card, its glass, the steps
/* ---- 5. the card: an area with the glass (the drop) in it. The drop's box is sprung by app.js; each step (a view)
        is laid out at the card's width and clipped to the drop, so the steps slide inside one piece of glass. ---- */
.auth { position: relative; width: 100%; height: var(--au-h, 360px); z-index: 4; color: var(--au-ink); }   /* --au-h: the tallest step (app.js) */
.drop { position: absolute; left: 0; top: 0; width: 10px; height: 10px; overflow: hidden; border-radius: var(--card-r); pointer-events: none;
  background: var(--au-glass); box-shadow: var(--au-rim), var(--au-drop); -webkit-backdrop-filter: var(--au-filter); backdrop-filter: var(--au-filter); }
.drop i { position: absolute; pointer-events: none; }
.d-spot { inset: 0; background: radial-gradient(260px circle at var(--mx, 50%) var(--my, 0), var(--au-spot), transparent 70%); }
.d-rim { inset: 0; border-radius: inherit; padding: 1px; background: radial-gradient(220px circle at var(--mx, 50%) var(--my, 0), var(--au-light), transparent 70%);
  -webkit-mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }
.d-sheen { top: -20%; bottom: -20%; left: 0; width: 50%; opacity: 0; background: linear-gradient(100deg, transparent, var(--au-sheen) 50%, transparent); }
.d-ring { inset: 0; border-radius: inherit; box-shadow: 0 0 0 2px var(--au-ring); opacity: 0; }
.view { position: absolute; left: 0; top: 0; width: 100%; padding: 16px 20px 18px; }
.view[hidden] { display: block; visibility: hidden; }
.view.is-out { visibility: visible; pointer-events: none; }

For now there is no motion at all, so a short block at the end of the stylesheet lets the glass simply fill the form and parks the lens on the chosen tab. Stage 3 deletes it.

style.css — stages 1–2 only: the card without springs
/* ---- stages 1–2 only: no springs yet. The card is as tall as the form and its glass fills it; the lens sits on the
        chosen tab and the note under the card. From stage 3 app.js springs the glass, the lens and the note. ---- */
.auth { height: auto; }
.v-form { position: relative; }
.drop { width: 100%; height: 100%; }
.seg-lens { left: 4px; top: 4px; width: calc(50% - 4px); height: 36px; }
.note { top: auto; bottom: 0; }
.field[data-only="up"] { display: none; }                         /* stage 2 shows them for "Create account" */
app.js — all of it: fit the frame, colour the browser, keep the form home
// 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();

// nothing is sent anywhere: until stage 2 checks it, the form just stays where it is
document.querySelector('#v-form').addEventListener('submit', (e) => e.preventDefault());
Stage 1 · a still card in its glassOpen ↗
Stage 1 · the same markup, the nordic tokensOpen ↗

2 · The form: tabs, fields, checking as you go

The tabs are two buttons with role="tab". Only the chosen one is in the tab order (roving focus), and the arrow keys, Home and End move between them. Two fields belong to "Create account" only, the name and a password-strength meter, marked data-only="up"; switching the tab makes the other tab's fields inert, so they can't be reached while they are gone. The password's autocomplete changes with the tab too, so a password manager offers a new password, not a saved one.

app.js — switching the tab
function setMode(next, { focus = false } = {}) {
  if (next === mode) return;
  mode = next;
  for (const [k, t] of Object.entries(tabs)) {
    t.setAttribute('aria-selected', String(k === mode));
    t.tabIndex = k === mode ? 0 : -1;
  }
  if (focus) tabs[mode].focus();
  for (const f of flows) {
    const on = f.only === mode;
    $$('input, a, button', f.el).forEach((el) => { el.tabIndex = on ? 0 : -1; });

Checking is where most sign-in forms are rude: they shout "invalid email" at the first letter. These rules say what is missing, in words a person would use.

app.js — what each field needs
const RULES = {
  name: (v) => (v.trim().length >= 2 ? '' : 'At least 2 letters, please.'),
  email: (v) => {
    v = v.trim();
    if (/\s/.test(v)) return 'An email has no spaces.';
    if (!v.includes('@')) return 'Add an @, like you@example.com.';
    const [user, domain] = [v.slice(0, v.indexOf('@')), v.slice(v.indexOf('@') + 1)];
    if (!user) return 'Add your name before the @.';
    if (!domain || domain.includes('@')) return 'Add where your email lives, after the @.';
    if (!/^[^.]+(\.[^.]+)*\.[a-z]{2,}$/i.test(domain)) return 'Add the ending, like .com.';
    return '';
  },
  pass: (v) => { const left = 8 - v.length; return left > 0 ? `${left} more character${left === 1 ? '' : 's'}.` : ''; },
};
const EMPTY = { name: 'Tell us your name.', email: 'Enter your email.', pass: 'Enter your password.' };

The trick is when a field speaks. While you type into a fresh field it stays quiet: a half-typed email is not a mistake. When you leave it with something wrong in it, it speaks; from then on it is live, so the line goes away the moment you fix it. A right value earns its tick at once, while you type. Each field has a state (idle, ok or bad) in data-state, and a wrong one gets aria-invalid.

app.js — a field's state, and when it changes
const checks = Object.entries(inputs).map(([key, input]) => {
  const field = input.closest('.field');
  return { key, input, field, text: $('.msg-text', field), state: 'idle', touched: false };
});
function setCheck(c, state, message = '') {
  c.state = state;
  c.field.dataset.state = state;                                   // the mark, the tick and the line show by this (CSS)
  if (state === 'bad') { c.input.setAttribute('aria-invalid', 'true'); if (c.text.textContent !== message) c.text.textContent = message; }
  else c.input.removeAttribute('aria-invalid');
}
// what a field says now: quiet until there is something to say (an empty field, or a wrong one you haven't left yet)
function recheck(c, { loud = false } = {}) {
  const v = c.input.value, problem = v ? RULES[c.key](v) : loud ? EMPTY[c.key] : '';
  if (!problem) setCheck(c, v ? 'ok' : 'idle');
  else if (loud || c.touched || c.state === 'bad') setCheck(c, 'bad', problem);
  else setCheck(c, 'idle');
  return !problem;
}
for (const c of checks) {
  c.input.addEventListener('input', () => recheck(c));
  c.input.addEventListener('blur', () => { if (c.input.value) { c.touched = true; recheck(c); } });
}

Submitting checks every field at once, the empty ones too, and moves the focus to the first wrong one. Switching the tab clears the lines, so the other form starts clean.

app.js — submitting checks everything
function clearErrors() { for (const c of checks) { c.touched = false; setCheck(c, c.input.value && !RULES[c.key](c.input.value) ? 'ok' : 'idle'); } }
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const shown = checks.filter((c) => c.key !== 'name' || mode === 'up');
  const wrong = shown.filter((c) => !(c.input.value && !RULES[c.key](c.input.value)));
  if (wrong.length) {
    wrong[0].input.focus();
    for (const c of shown) recheck(c, { loud: true });

The meter scores the password from 0 to 4: long enough, both cases, a digit, a symbol, twelve characters.

app.js — the strength meter
function score(p) {
  if (p.length < 8) return 0;
  return clamp(1 + (/[a-z]/.test(p) && /[A-Z]/.test(p)) + /\d/.test(p) + /[^\w\s]/.test(p) + (p.length >= 12) - 1, 1, 4);
}
inputs.pass.addEventListener('input', () => {
  const s = score(inputs.pass.value);
  meterLabel.textContent = STRENGTH[s];
  meterFill.dataset.s = s;
  meterFill.style.width = `calc(${s * 25}% - ${s * 30.5}px)`;
  meterFill.style.background = `var(--au-m${s >= 4 ? 3 : s >= 2 ? 2 : 1})`;
});

In this stage every change is instant: the stylesheet shows the mark, the tick and the line by the field's state. The point is to get the logic and the accessibility right before anything moves.

style.css — stage 2 only: states without motion
/* ---- stage 2 only: no springs yet. The card is as tall as the form and its glass fills it; the lens jumps to the
        chosen tab; a field that only the other tab has is gone (app.js makes it inert); a field's state shows at once:
        the mark, the tick, the line under it. From stage 3 app.js springs all of these. ---- */
.auth { height: auto; }
.v-form { position: relative; }
.drop { width: 100%; height: 100%; }
.seg-lens { left: 4px; top: 4px; width: calc(50% - 4px); height: 36px; }
.seg:has(#tab-up[aria-selected="true"]) .seg-lens { translate: 100% 0; }
.note { top: auto; bottom: 0; }
.field[data-only][inert] { display: none; }
.field[data-state="ok"] .mark, .field[data-state="bad"] .mark { opacity: 1; }
.field[data-state="ok"] .mark-ok { stroke-dashoffset: 0; }
.field[data-state="bad"] .msg { height: auto; }
Stage 2 · switch the tabs, leave a field half done, press the buttonOpen ↗

3 · The springs that make it liquid

Now everything moves, and all of it on one small thing: a spring. A value has a target, a velocity, a stiffness and a damping, and it is stepped in fixed slices of 1/240 s, so it moves the same at 60 and 120 frames a second. Changing the target mid-way never jumps: the motion just bends towards the new target.

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;
  }
}

The glass becomes a drop: a box whose four edges are four springs. When it reshapes, the edge that has the furthest to travel gets the bouncier spring and leads; the others follow on a calmer one. That is what makes the card pour into its new size instead of scaling to it.

app.js — the drop: four sprung edges
// A drop: a box whose four edges are springs. When it reshapes, the edge that travels furthest leads with a jelly
// bounce and the others follow, so it pours rather than scales.
class Drop {
  constructor() { [this.l, this.r, this.t, this.b] = [0, 0, 0, 0].map(() => new Spring(0)); }
  to({ x, y, w, h }, lead = spring(300, 17), trail = spring(240, 24)) {
    const pair = (a, b, va, vb) => {
      const da = Math.abs(va - a.target), db = Math.abs(vb - b.target);
      a.set(da >= db ? lead : trail); b.set(db >= da ? lead : trail);
      a.target = va; b.target = vb;
    };
    pair(this.l, this.r, x, x + w);
    pair(this.t, this.b, y, y + h);
  }
  place(box) { this.to(box); this.springs().forEach(snap); }
  springs() { return [this.l, this.r, this.t, this.b]; }
  get box() { return { x: this.l.value, y: this.t.value, w: this.r.value - this.l.value, h: this.b.value - this.t.value }; }
}

The tabs get a lens, a small drop that follows the chosen tab. The edge in front leads and the one behind lags, so the lens stretches as it travels. To keep it liquid it keeps its volume: the longer it gets, the thinner it is drawn (each side is divided by the square root of how much the other one grew).

app.js — the lens keeps its volume
// A lens: a small drop that follows its target. The edge in front leads and the one behind lags, so it stretches
// while it travels — and keeps its volume: the longer it gets, the thinner.
class Lens extends Drop {
  constructor(el) { super(); this.el = el; this.o = new Spring(0, spring(320, 28)); this.rest = { w: 1, h: 1 }; }
  to({ x, y, w, h }) {
    const lead = spring(620, 30), trail = spring(210, 17);
    if (this.o.value < 0.02) this.place({ x, y, w, h });
    const dx = x - this.l.target, dy = y - this.t.target;
    if (Math.abs(dx) > 0.5) { (dx > 0 ? this.r : this.l).set(lead); (dx > 0 ? this.l : this.r).set(trail); }
    if (Math.abs(dy) > 0.5) { (dy > 0 ? this.b : this.t).set(lead); (dy > 0 ? this.t : this.b).set(trail); }
    this.l.target = x; this.r.target = x + w; this.t.target = y; this.b.target = y + h;
    this.rest = { w, h };
    this.o.target = 1;
  }
  place(box) { [[this.l, box.x], [this.r, box.x + box.w], [this.t, box.y], [this.b, box.y + box.h]].forEach(([s, v]) => { s.value = s.target = v; s.velocity = 0; }); this.rest = { w: box.w, h: box.h }; }
  hide() { this.o.target = 0; }
  springs() { return [...super.springs(), this.o]; }
  render(origin = { x: 0, y: 0 }) {
    const { x, y, w, h } = this.box;
    const sw = w / Math.sqrt(Math.max(1, h / this.rest.h)), sh = h / Math.sqrt(Math.max(1, w / this.rest.w));   // keep the volume
    promote(this.el, !this.springs().every((s) => s.resting(0.01)));
    put(this.el, 'opacity', clamp(this.o.value, 0, 1).toFixed(3));
    put(this.el, 'transform', `translate(${px(x - origin.x + (w - sw) / 2)}, ${px(y - origin.y + (h - sh) / 2)})`);
    put(this.el, 'width', px(Math.max(0, sw))); put(this.el, 'height', px(Math.max(0, sh)));
  }
}

The fields that only one tab has get a sprung height, from 0 to their own. The card grows around them, because every frame the drop's target is the form's current height.

app.js — a field that flows in and out
// every field that only one tab has: its height springs between 0 and its own, so the card grows around it
const flows = $$('.field[data-only]', form).map((el) => ({ el, only: el.dataset.only, inner: $('.field-in', el), h: new Spring(0, spring(320, 26)) }));
app.js — the drop follows the form
// the drop follows the form's height as it changes (fields flowing in), without resetting its edges' feel
function followDrop({ x, y, w, h }) {
  if (Math.abs(drop.b.target - (y + h)) > 0.5 || Math.abs(drop.r.target - (x + w)) > 0.5 || Math.abs(drop.l.target - x) > 0.5 || Math.abs(drop.t.target - y) > 0.5) drop.to({ x, y, w, h });
}

Every frame, render() writes the drop's box to .drop and clips the form to it with clip-path, rounded like the glass. While the glass is still pouring, its moving edge cuts the content, so the fields look like they are inside it.

app.js — draw the drop, clip the form to it
function render() {
  // the drop: around the form
  const vh = form.offsetHeight;
  followDrop({ x: 0, y: 0, w: cardW, h: vh });
  const { x, y, w, h } = drop.box, r = Math.min(RADIUS, w / 2, h / 2);
  promote(dropEl, !drop.springs().every((s) => s.resting(0.05)));
  put(dropEl, 'transform', `translate(${px(x)}, ${px(y)})`);
  put(dropEl, 'width', px(Math.max(0, w))); put(dropEl, 'height', px(Math.max(0, h))); put(dropEl, 'borderRadius', px(r));
  dropEl.style.setProperty('--mx', px(spotX.value - x)); dropEl.style.setProperty('--my', px(spotY.value - y));
  put(noteEl, 'transform', `translateY(${px(auth.offsetTop + Math.max(0, drop.b.value) + 18)})`);   // the note rides under the glass
  // the form: clipped to the drop, so the glass's moving edge cuts it
  put(form, 'clipPath', `inset(${px(Math.max(0, y))} ${px(Math.max(0, cardW - (x + w)))} ${px(Math.max(0, vh - (y + h)))} ${px(Math.max(0, x))} round ${px(r)})`);

A field's state now kicks springs: the line under it opens to its own height, the mark pops (a push of velocity on top of its target), the tick draws itself, and a field that turns wrong gets a sideways shake.

app.js — a state change kicks the springs
function setCheck(c, state, message = '') {
  const was = c.state;
  c.state = state;
  c.field.dataset.state = state;
  if (state === 'bad') { c.input.setAttribute('aria-invalid', 'true'); if (c.text.textContent !== message) c.text.textContent = message; }
  else c.input.removeAttribute('aria-invalid');
  c.h.target = state === 'bad' ? 1 : 0;
  c.pop.target = state === 'idle' ? 0 : 1;
  c.draw.target = state === 'ok' ? 1 : 0;
  if (state === 'ok' && was !== 'ok') { c.draw.value = 0; if (!calm) c.pop.velocity += 6; }
  if (state === 'bad' && was !== 'bad') { if (!calm) { c.shake.velocity += 260; c.pop.velocity += 6; } }
  wake();
}
app.js — drawing a field
// the checks: the line under a field opens and fades in, the mark pops, the tick draws itself, a wrong field shakes
for (const c of checks) {
  const hv = clamp(c.h.value, 0, 1.2), pv = clamp(c.pop.value, 0, 1.4);
  put(c.msg, 'height', px(hv * c.msgIn.offsetHeight));
  put(c.msgIn, 'opacity', clamp(hv * 1.6 - 0.4, 0, 1).toFixed(3));
  put(c.msgIn, 'transform', `translateY(${px((1 - clamp(hv, 0, 1)) * -6)})`);
  put(c.inner, 'transform', Math.abs(c.shake.value) > 0.05 ? `translateX(${px(c.shake.value)})` : '');
  put(c.mark, 'opacity', clamp(pv * 2, 0, 1).toFixed(3));
  put(c.mark, 'transform', `scale(${pv.toFixed(3)})`);
  put(c.tick, 'strokeDashoffset', (1 - clamp(c.draw.value, 0, 1)).toFixed(3));
}
app.js — the flowing fields and the meter
// the flowing fields (the ones shown follow their own height, which grows with their line), the meter, the label
for (const f of flows) if (f.only === mode) f.h.target = f.inner.offsetHeight;
for (const f of flows) { const hh = Math.max(0, f.h.value); put(f.el, 'height', px(hh)); put(f.el, 'opacity', clamp(hh / Math.max(1, f.inner.offsetHeight), 0, 1).toFixed(3)); }
put(meterFill, 'width', `calc(${(clamp(meter.value, 0, 1) * 100).toFixed(2)}% - ${px(clamp(meter.value, 0, 1) * 122)})`);
put(meterFill, 'background', `var(--au-m${meter.target >= 1 ? 3 : meter.target >= 0.5 ? 2 : 1})`);

The button's label rolls when the tab changes: the old word goes up and out, the new one comes in from below (down, when you go back). Two spans and one spring.

app.js — the label roll
// the button label rolls up when the tab changes
let labelNext = 'Sign in', rollDir = 1;
function rollLabel(text, dir) {
  if (roll.target === 1) finishRoll();
  labelNext = text; rollDir = dir; goLabels[1].textContent = text;
  if (calm) { finishRoll(); return; }
  roll.value = 0; roll.velocity = 0; roll.target = 1;
}
function finishRoll() { goLabels[0].textContent = labelNext; goLabels[1].textContent = ''; roll.value = 0; roll.velocity = 0; roll.target = 0; }

One loop steps every spring and draws. It runs every frame while there is ambient life (a soft light drifts across the glass, and follows the pointer when there is one). With prefers-reduced-motion every spring is critically damped, so things still arrive but never bounce, and the loop sleeps as soon as everything rests.

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) });
const snap = (s) => { s.value = s.target; s.velocity = 0; };
app.js — the loop
// ---- the loop: with ambient life it runs every frame; with reduced motion only while something moves ----
const springs = () => [...drop.springs(), roll, spotX, spotY, meter, ...segLens.springs(),
  ...checks.flatMap((c) => [c.h, c.pop, c.draw, c.shake]), ...flows.map((f) => f.h)];
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;
  if (roll.target === 1 && roll.resting(0.004)) finishRoll();
  const all = springs();
  all.forEach((s) => s.step(dt));
  const t = calm ? 0 : studio.now() / 1000;
  if (!pointerIn) { spotX.target = cardW * (0.5 + 0.44 * Math.sin(t * 0.55)); spotY.target = 30; }
  render();
  const still = all.every((s) => s.resting(0.002));
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}

The card is as tall as its tallest step plus room for two lines of errors, so nothing under it moves while the glass reshapes; and when its container changes width, everything that was measured is measured again, in place.

app.js — the card's height, and a new width
// the card is as tall as its tallest step (plus two lines for checks), so nothing below it moves while it reshapes
function sizeCard() {
  const base = form.offsetHeight - flows.reduce((sum, f) => sum + Math.max(0, f.h.value), 0);
  const open = (m) => base + flows.reduce((sum, f) => sum + (f.only === m ? f.inner.offsetHeight : 0), 0);
  auth.style.setProperty('--au-h', px(Math.ceil(Math.max(open('in'), open('up'), codeView.offsetHeight, doneView.offsetHeight) + 44)));
}
// a new width (the container changed): everything that was measured is measured again, in place, without motion
function settle() {
  cardW = auth.offsetWidth;
  for (const f of flows) if (f.only === mode) f.h.value = f.h.target = f.inner.offsetHeight;
  drop.place({ x: 0, y: 0, w: cardW, h: form.offsetHeight });
  segLens.place(boxOf(tabs[mode]));
  sizeCard();
  wake();
}
let seenW = 0;
new ResizeObserver(() => {
  if (auth.offsetWidth !== seenW) { seenW = auth.offsetWidth; settle(); }
}).observe($('#signin'));
Stage 3 · switch the tabs: the glass pours, the lens slidesOpen ↗

4 · Submitting, and steps that slide

With every field right, the button shrinks into a liquid spinner (its width is a spring, from full to a 50 px circle), the form "sends" for a second (simulated: nothing leaves the page), and the card moves on to the code step. The address you typed is written into the steps that mention it.

app.js — submitting
let busy = false;
function clearErrors() { for (const c of checks) { c.touched = false; setCheck(c, c.input.value && !RULES[c.key](c.input.value) ? 'ok' : 'idle'); } }
form.addEventListener('submit', (e) => {
  e.preventDefault();
  if (busy) return;
  const shown = checks.filter((c) => c.key !== 'name' || mode === 'up');
  const wrong = shown.filter((c) => !(c.input.value && !RULES[c.key](c.input.value)));
  if (wrong.length) {                                              // every wrong field speaks at once; the first one gets the focus
    wrong[0].input.focus();
    for (const c of shown) recheck(c, { loud: true });
app.js — the button and its spinner
const gw = clamp(goW.value, 0, 1.05);
put(go, 'width', `calc(50px + ${gw.toFixed(4)} * (100% - 50px))`);
put(goLabels[0].parentElement, 'opacity', clamp((gw - 0.4) * 2, 0, 1).toFixed(3));
put(spinEl, 'opacity', clamp((0.6 - gw) * 2.5, 0, 1).toFixed(3));
put(spinEl, 'transform', `rotate(${((studio.now() / 1000) * 400 % 360).toFixed(1)}deg)`);

Timed things go through one small queue that runs on the loop's own clock, not on setTimeout, so a paused or driven clock pauses them too.

app.js — later()
let clock = 0;
const queue = [];
const later = (s, fn) => queue.push({ at: clock + s, fn });

The steps slide inside one piece of glass. Each view's children are its blocks, each with a spring from 0 (out) to 1 (in). Going forward, the leaving step's blocks slide out to the left as they fade, and the new step's blocks follow in from the right, one after another; going back, the other way round. The drop meanwhile reshapes from the form's height to the code step's.

app.js — show a step
// the views (steps) slide inside the drop: blocks of the leaving one go out the way you go, the new ones follow in
const SLIDE = 36, STAGGER = 0.05;
const views = [form, codeView, doneView].map((el) => ({ el, blocks: [...el.children].map((b) => ({ el: b, e: new Spring(1, spring(320, 24)) })), side: 0 }));
let current = views[0];
function show(view, dir) {
  if (view === current) return;
  const old = current;
  old.side = -dir;
  old.el.classList.add('is-out');
  old.blocks.forEach((b) => { b.e.set(critical(700)); b.e.target = 0; if (calm) b.e.value = 0; });
  old.el.inert = true;
  current = view;
  view.el.hidden = false; view.el.inert = false; view.el.classList.remove('is-out');
  view.side = dir;
  view.blocks.forEach((b, k) => {
    b.e.set(spring(320, 24)); b.e.value = calm ? 1 : 0; b.e.velocity = 0; b.e.target = calm ? 1 : 0;
    if (!calm) later(0.06 + k * STAGGER, () => { b.e.target = 1; });
  });
  sweep(dir);
  wake();
}
function retire(view) { if (view !== current && view.blocks.every((b) => b.e.value < 0.01)) { view.el.hidden = true; view.el.classList.remove('is-out'); } }
app.js — every step is clipped to the drop
// the steps: clipped to the drop, their blocks sliding in and out
for (const v of views) {
  retire(v);
  if (v.el.hidden) continue;
  const vhh = v.el.offsetHeight;
  put(v.el, 'clipPath', `inset(${px(Math.max(0, y))} ${px(Math.max(0, cardW - (x + w)))} ${px(Math.max(0, vhh - (y + h)))} ${px(Math.max(0, x))} round ${px(r)})`);
  for (const b of v.blocks) {
    const e = clamp(b.e.value, 0, 1.2), moving = !b.e.resting(0.004);
    promote(b.el, moving);
    put(b.el, 'opacity', clamp(b.e.value, 0, 1).toFixed(3));
    put(b.el, 'transform', moving ? `translateX(${px(v.side * (1 - e) * SLIDE)})` : '');
  }
}

A band of light crosses the glass the way you went.

app.js — the sheen
// ---- the sheen: a band of light that crosses the card when the step changes ----
let sheenAge = 9, sheenDir = 1;
function sweep(dir) { if (!calm) { sheenAge = 0; sheenDir = dir || 1; } }
…
// the sheen
const sw = clamp(sheenAge / 0.65, 0, 1), from = sheenDir > 0 ? -0.6 : 1.1, to = sheenDir > 0 ? 1.1 : -0.6;
put(sheen, 'opacity', sheenAge < 0.65 ? Math.sin(Math.PI * sw).toFixed(3) : '0');
put(sheen, 'transform', `translateX(${px(w * (from + (to - from) * (1 - (1 - sw) ** 2)))}) skewX(-14deg)`);

For now the code step only has a way back: Back, or Escape anywhere in it.

app.js — the code step, for now
// the code step, for now: it slides in, and Back (or Escape) slides back to the form (stage 5 wires the code itself)
function openCode() {
  show(views[1], 1);
  later(0.25, () => $('.back', codeView).focus({ preventScroll: true }));
}
$('.back', codeView).addEventListener('click', () => { show(views[0], -1); later(0.2, () => inputs.email.focus()); });
codeView.addEventListener('keydown', (e) => { if (e.key === 'Escape') $('.back', codeView).click(); });
Stage 4 · fill it in and press the buttonOpen ↗

5 · The code step and the done step

Six boxes, one input. The input is real (inputmode="numeric", autocomplete="one-time-code", so a phone offers the code from a message, and pasting works), invisible and stretched over six drawn boxes that are hidden from screen readers. A lens, the same Lens as the tabs', marks the box the next digit goes into.

index.html — the code: one input over six boxes
<div class="otp">
  <input id="otp" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" aria-label="6-digit code" aria-describedby="code-err code-demo" spellcheck="false">
  <i class="otp-lens" aria-hidden="true"></i>
  <div class="boxes" aria-hidden="true"><i class="box"><b></b></i><i class="box"><b></b></i><i class="box"><b></b></i><i class="box"><b></b></i><i class="box"><b></b></i><i class="box"><b></b></i></div>
style.css — the code
.otp { position: relative; height: 58px; margin: 18px 0 10px; }
.otp input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; padding: 0; border: 0; outline: none; opacity: 0; font-size: 16px; caret-color: transparent; cursor: text; }
.boxes { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; height: 100%; }
.box { position: relative; display: grid; place-items: center; overflow: hidden; border-radius: 14px; background: var(--au-box); box-shadow: inset 0 0 0 1px var(--au-box-edge), var(--au-input-depth); font-style: normal; }
.box b { font-family: var(--mono); font-size: 24px; font-weight: 600; }
.otp-lens { z-index: 2; border-radius: 14px; opacity: 0; }
.otp:has(input:focus-visible) .otp-lens { box-shadow: var(--au-lens-rim), 0 0 0 2px var(--au-focus); }

Every keystroke keeps only digits, rolls each new digit into its box, slides the lens on, and at six digits waits a moment and checks.

app.js — typing the code
function typed(v) {
  boxes.forEach((bx, i) => {
    const d = v[i] || '';
    if (d && bx.b.textContent !== d) { bx.b.textContent = d; bx.e.value = calm ? 1 : 0; bx.e.target = 1; }   // a new digit rolls in
    if (!d && bx.b.textContent) { bx.e.target = 0; later(0.15, () => { if (!otp.value[i]) bx.b.textContent = ''; }); }
  });
  aimOtpLens();
  wake();
}
function aimOtpLens() {
  const i = Math.min(otp.value.length, 5), b = boxOf(boxes[i].el);
  otpLens.to(b);
}
otp.addEventListener('input', () => {
  otp.value = otp.value.replace(/\D/g, '').slice(0, 6);
  codeErr.textContent = '';
  typed(otp.value);
  if (otp.value.length === 6 && !checking) { checking = true; later(0.3, verify); }
});

A wrong code shakes the whole card (a spring on its translateX), says what to do, and clears. The demo code is 246810.

app.js — checking the code
function verify() {
  checking = false;
  if (otp.value === DEMO_CODE) return success();
  codeErr.textContent = `That code didn’t match. Try ${DEMO_CODE}.`;
  if (!calm) shake.velocity += 300;
  later(0.45, () => { otp.value = ''; typed(''); otp.focus({ preventScroll: true }); });
  wake();
}

The right one folds the card into a drop: the code step's blocks go, the drop's target becomes a 96 px circle in the middle and its corner radius springs to a full round; then a tick draws itself in it with a ring, and the card opens again on "You're in". "Start over" empties the form and slides back to it.

app.js — the fold, and the done step
let folded = false;
function success() {
  folded = true;                                                   // the card folds into a drop …
  views[1].blocks.forEach((b) => { b.e.set(critical(700)); b.e.target = 0; });
  views[1].el.inert = true;
  later(calm ? 0 : 0.42, () => { check.target = 1; ring.value = 0; ring.target = 1; bump.velocity += 5; });   // … a tick, a ring
  later(calm ? 0.1 : 1.15, () => {                                  // … and it opens on "You're in"
    folded = false; check.target = 0;
    views[1].side = 0; current = views[1];
    show(views[2], 1);
    later(0.3, () => $('.go-again').focus({ preventScroll: true }));
  });
  wake();
}
app.js — the drop, folded
// the drop: around the current step, or folded into a round drop in the middle of it
const vh = current.el.offsetHeight;
if (folded) { const s = 96; drop.to({ x: cardW / 2 - s / 2, y: Math.max(0, vh / 2 - s / 2), w: s, h: s }); R.target = 999; }
else { followDrop({ x: 0, y: 0, w: cardW, h: vh }); R.target = RADIUS; }
const { x, y, w, h } = drop.box, r = Math.min(Math.max(0, R.value), w / 2, h / 2);
app.js — the tick and the ring
// the ring and the tick when the code is right
const ra = clamp(ring.value, 0, 1);
put(ringEl, 'opacity', ra > 0 && ra < 1 ? ((1 - ra) * 0.9).toFixed(3) : '0');
put(ringEl, 'transform', `scale(${(1 + ra * 0.6).toFixed(3)})`);
const c = clamp(check.value, 0, 1.1);
put(checkEl, 'opacity', clamp(c * 3, 0, 1).toFixed(3));
put(checkEl, 'strokeDashoffset', (34 * (1 - clamp(c, 0, 1))).toFixed(2));
put(checkEl, 'transform', `translate(${px(x + w / 2)}, ${px(y + h / 2)}) scale(${(0.8 + 0.2 * c).toFixed(3)})`);
app.js — rolling digits, the countdown, the lenses
// the code boxes: digits roll in; the countdown to "Resend"
for (const bx of boxes) { const e = clamp(bx.e.value, 0, 1.2); put(bx.b, 'transform', `translateY(${px((1 - e) * 18)}) scale(${(0.7 + 0.3 * e).toFixed(3)})`); put(bx.b, 'opacity', clamp(e, 0, 1).toFixed(3)); }
const left = Math.max(0, Math.ceil(resendAt - clock));
resendBtn.disabled = left > 0;
put(timerEl, 'opacity', left > 0 ? '1' : '0');
if (timerEl.textContent !== ` in 0:${String(left).padStart(2, '0')}`) timerEl.textContent = ` in 0:${String(left).padStart(2, '0')}`;
// the lenses
segLens.render(boxOf($('.seg')));
const ob = boxOf(otpWrap);
otpLens.render({ x: ob.x, y: ob.y });
Stage 5 · type 246810, or a wrong code firstOpen ↗

6 · The character

The last stage adds the part people remember: a character that watches the card. Each look has its own — a drop of liquid (glass), a porcelain owl (nordic), a clay kitten (clay) — and all three speak one small interface, written down in characters.js.

characters.js — what a character is
// The characters that watch the card: one per look (glass: the drop, nordic: the owl, clay: the cat). Each
// character-<name>.js adds itself here; app.js picks the one for the look, puts its two SVGs around the card and calls
// its draw() every frame with the moods' springs.
//
// A character is { back, front, bits, build }:
//   back, front   two SVGs (class blob blob-back / blob blob-front), each a 300 × 260 box centred over the card, whose
//                 top edge is at y 190 (EDGE). back is drawn behind the card's glass (what hides below the edge shows
//                 through it, blurred), front in front of it. front holds .b-bits (8 particles) and .b-zz (3 z's);
//                 whatever has .b-hit takes a poke.
//   bits          how its particles fly: { gravity (px/s²), drag (1/s), fade (1/s), spin (deg/s) } — droplets fall,
//                 feathers drift, hearts float up
//   build(back, front) → draw(rig) → { eye, top }: finds its parts once; draw() renders one frame and says where its
//                 eyes and the top of its head are (box px), for where it looks from and where particles start
// rig: every mood spring's value (lookX lookY sx sy hop tilt sway cover peek raise chin smile open lid wide joy no brow
//      knit quirk blush zz, see app.js), stand (0 peeking over the edge … 1 standing on it), edge (the card's top edge
//      now: it moves when the card folds), shake (px), blink (0 … 1), t (s), calm.

A character is two SVGs over the same 300 × 260 box: back is drawn behind the card's glass (so whatever hangs below the card's edge shows through it, blurred) and front in front of it (hands on the edge, the particles, the z's). build() finds its parts once and returns draw(rig), which renders one frame and reports where its eyes and the top of its head are.

character-drop.js — the drop, its sizes
// The drop (glass look): a little liquid with eyes. Not a drawn-perfect drop: soft and a little lopsided, its outline
// never still. Peeking, it holds the card's edge with both hands; standing, it sits on the card spread out like a drop
// on a table, a drip running over the edge — it gathers itself up to jump and splats flat when it lands.
characters.drop = (() => {
  const { CX, EDGE, clamp, mix, n, lean, curve, set, fade } = characters;
  const RX = 64, RY = 54, BASE = 0.82;                             // its radii; where it sits, as a share of RY below the centre
  const CY_PEEK = EDGE - 22;                                        // peeking, its centre is just above the edge
character-drop.js — build, draw, and what it hands back
function build(svgBack, svgFront) {
    …
return function draw(r) {
      …
      return { eye: L.at(CX, cy - 8), top: L.at(CX + crownAt * 40, cy - ry * 1.12) };
    };
  }

  return { back, front, build, bits: { gravity: 640, drag: 0.6, fade: 1.1, spin: 0 } };
})();

app.js picks the look's character, puts its two SVGs around the card, and builds it.

app.js — mounting the character
const cardSide = $('.card-side');
const character = characters[{ glass: 'drop', nordic: 'owl', clay: 'cat' }[html.dataset.look]];
cardSide.insertAdjacentHTML('afterbegin', character.back);
auth.insertAdjacentHTML('afterend', character.front);
const blobBack = $('.blob-back', cardSide), blobFront = $('.blob-front', cardSide);
const draw = character.build(blobBack, blobFront);
style.css — its box, behind and in front of the glass
/* the character: two SVGs over one 300 × 260 box, centred over the card with the card's top edge at y 190 — one behind
   the card's glass, one in front (character-*.js draws them; app.js flies the bits and the z's) */
.blob { position: absolute; left: calc(50% - 150px); top: calc(var(--room) - 190px); width: 300px; height: 260px; overflow: visible; pointer-events: none; }
.blob-back { z-index: 3; }
.blob-front { z-index: 5; }

Moods are spring targets. Every feature is a spring — where it looks, squash and stretch, a hop, a tilt, the hands over the eyes, the brows, the mouth, the lids, the blush, sleep. A mood is just a set of targets, so moving between moods is always smooth and an interrupted mood never jumps.

app.js — the features, each a spring
const B = {
  stand: new Spring(0, spring(140, 15)),                                          // 0 peeking over the edge … 1 standing on it
  lookX: new Spring(0, spring(150, 16)), lookY: new Spring(0, spring(150, 16)),   // where it looks (-1 … 1)
  sx: new Spring(0, spring(280, 9)), sy: new Spring(0, spring(280, 9)),           // squash and stretch: jelly
  hop: new Spring(0, spring(240, 13)), tilt: new Spring(0, spring(180, 13)),      // a jump, a lean
  sway: new Spring(0, spring(160, 13)),                                           // what trails a turn (a crown, tufts, a tail)
  cover: new Spring(0, spring(240, 18)), peek: new Spring(0, spring(240, 18)),    // not looking (eyes covered); peeking anyway
  raise: new Spring(0, spring(200, 14)), chin: new Spring(0, spring(200, 16)),    // up in the air; a hand on the chin
  smile: new Spring(0.35, spring(200, 18)), open: new Spring(0, spring(300, 18)), // the mouth: a curve and an "o"
  lid: new Spring(0, spring(200, 20)), wide: new Spring(0, spring(260, 16)),      // eyelids half down; eyes wide
  joy: new Spring(0, spring(220, 20)), no: new Spring(0, spring(420, 9)),         // happy ^ ^ eyes; shaking the head "no"
  brow: new Spring(0, spring(260, 15)), knit: new Spring(0, spring(260, 18)),     // the brows: raised; worried (inner ends up)
  quirk: new Spring(0, spring(220, 15)), blush: new Spring(0.35, spring(120, 16)),// one brow up; the cheeks
  zz: new Spring(0, critical(30)),                                                // asleep
};

The mood comes from what you are doing: the field in focus, the step, a reaction for a moment (a nod when a field turns right, a sad shake at a mistake, surprise when you switch tabs), then back.

app.js — a mood, a reaction, and what the page asks for
function setMood(m) { mood = m; moodUntil = 0; lastInput = clock; wake(); }
// a short reaction, then back to what the page asks for (the field in focus, the step)
function react(m, seconds = 1.1) {
  mood = m; moodUntil = clock + seconds; lastInput = clock;
  if (m === 'sad' && !calm) B.no.velocity += 34;
  if (m === 'surprised' && !calm) { B.hop.velocity -= 140; B.sy.velocity += 3; }
  if (m === 'yes' && !calm) { B.hop.velocity -= 80; B.sy.velocity -= 1.5; }   // a happy little nod
  wake();
}
function moodFromFocus() {
  const a = document.activeElement;
  if (folded || current === views[2]) return 'joy';
  if (a === inputs.pass) return inputs.pass.type === 'text' ? 'peek' : 'shy';
  if (a === inputs.email || a === inputs.name) return 'watch';
  if (current === views[1]) return 'code';
  return 'idle';
}
app.js — each mood as targets
// each mood is a set of targets (the springs make the motion); some also look somewhere
function aim(t, dt) {
  if (moodUntil && clock > moodUntil) { moodUntil = 0; mood = moodFromFocus(); }
  if (mood === 'idle' && clock - lastInput > 9) mood = 'sleepy';
  if (petting && (mood === 'idle' || mood === 'sleepy')) { mood = 'pet'; lastInput = clock; }
  if (!petting && mood === 'pet') mood = moodFromFocus();
  const T = { lookX: 0, lookY: 0, tilt: 0, cover: 0, peek: 0, raise: 0, chin: 0, smile: 0.35, open: 0, lid: 0, wide: 0, joy: 0, brow: 0, knit: 0, quirk: 0, blush: 0.35, zz: 0 };
  const a = document.activeElement;
  switch (mood) {
    case 'idle':
      if (ptr && clock - ptr.at < 2.5) { const l = lookAt(ptr.x, ptr.y); T.lookX = l.x; T.lookY = l.y; T.tilt = l.x * 6; T.brow = 0.15; }   // it follows the pointer …
      else { T.lookX = Math.sin(t * 0.7) * 0.6; T.lookY = Math.sin(t * 0.45) * 0.3; T.tilt = Math.sin(t * 0.5) * 3; }                    // … or looks around
      break;
    case 'watch': T.lookX = lookAt(caretX(a.tagName === 'INPUT' ? a : inputs.email), 0).x; T.lookY = 0.9; T.tilt = T.lookX * 9; T.smile = 0.5; T.brow = 0.2; break;
    case 'shy': T.cover = 1; T.smile = 0.15; T.lid = 0.3; T.tilt = -4; T.brow = 0.2; T.knit = 0.6; T.blush = 0.95; break;
    case 'peek': T.cover = 1; T.peek = 1; T.lookX = lookAt(caretX(inputs.pass), 0).x; T.lookY = 0.9; T.open = 0.35; T.wide = 0.3; T.tilt = 6; T.brow = 0.7; T.blush = 0.7; break;
    case 'think': T.chin = 1; T.lookX = -0.45 + Math.sin(t * 1.3) * 0.35; T.lookY = -0.7; T.smile = 0.1; T.tilt = -5 + Math.sin(t * 1.2) * 2; T.quirk = 1; break;
    case 'sad': T.smile = -0.9; T.lid = 0.35; T.lookY = 0.5; T.tilt = -6; T.knit = 1; T.blush = 0.15; break;
    case 'surprised': T.open = 0.9; T.wide = 1; T.smile = 0; T.lookY = 0.3; T.brow = 1; T.raise = 0.3; break;
    case 'code': { const r = boxes[Math.min(otp.value.length, 5)].el.getBoundingClientRect(); T.lookX = lookAt(r.left + r.width / 2, 0).x; T.lookY = 0.9; T.tilt = T.lookX * 8; T.smile = 0.45; T.brow = 0.25; break; }
    case 'joy': T.joy = 1; T.smile = 1; T.open = 0.3; T.tilt = Math.sin(t * 6) * 5; T.raise = 1; T.brow = 0.5; T.blush = 1; break;
    case 'giggle': T.joy = 1; T.smile = 1; T.open = 0.55; T.tilt = Math.sin(t * 16) * 6; T.raise = 0.6; T.brow = 0.55; T.blush = 1; break;
    case 'yes': T.lookX = a.tagName === 'INPUT' ? lookAt(caretX(a), 0).x : 0; T.lookY = 0.8; T.smile = 1; T.open = 0.25; T.brow = 0.45; T.blush = 0.7; break;
    case 'pet': T.joy = 1; T.smile = 0.8; T.blush = 1; T.lookY = 0.2; T.brow = 0.3; break;
    case 'sleepy': T.lid = 0.75; T.lookY = 0.4; T.smile = 0.1; T.tilt = 7 + Math.sin(t * 0.8) * 2; T.brow = -0.5; T.zz = 1; break;
  }
  for (const k of Object.keys(T)) B[k].target = T[k];

Watching your email, it follows the caret: the text up to the caret is measured with a canvas in the input's own font, and that point on the screen becomes a look direction from its eyes. Both are measured on screen, so it holds at any size and any scale.

app.js — where the caret is, and where to look
// a point on the screen as a look direction (-1 … 1), from the character's eyes
function lookAt(x, y) {
  const r = blobBack.getBoundingClientRect(), k = r.width / 300;
  return { x: clamp((x - r.left - pose.eye.x * k) / (110 * k), -1, 1), y: clamp((y - r.top - pose.eye.y * k) / (90 * k), -1, 1) };
}
// where the caret is on the screen: the input's left edge, its padding, the text up to the caret (measured)
function caretX(input) {
  const n = input.selectionStart ?? input.value.length;
  const before = input.type === 'password' ? '•'.repeat(n) : input.value.slice(0, n);
  const r = input.getBoundingClientRect(), k = r.width / input.offsetWidth, pad = parseFloat(getComputedStyle(input).paddingLeft);
  return r.left + (pad + Math.min(charW(before || ' ', input), input.clientWidth - 2 * pad)) * k;
}

The character peeks over the edge of a narrow card and stands on a wide one. The stylesheet decides, with the same container query that lays out the page, and writes the answer into --pose; the script only reads it and springs stand from 0 to 1, so it climbs up or sinks back when the container changes.

style.css — the pose comes from the container
/* the room above the card is the character's: a narrow card has it peek over the edge (--pose, read by app.js), a wider
   one has it stand on it (how much room that takes is the look's: --stand-room). The stage behind it follows: peeking, the
   plate rises behind it like a sun from the card's edge */
.card-side { --pose: peek; --room: 114px; --decor-y: var(--room); --plate: 200px; position: relative; flex: none; width: min(100%, 400px); padding: var(--room) 0 22px; }   /* 22: the note */
…
@container signin (min-width: 600px) {
  .card-side { --pose: stand; --room: var(--stand-room); --decor-y: calc(var(--room) - 74px); --plate: 300px; }
app.js — reading the pose
// the pose comes from the container (CSS --pose on the card side): it climbs up or sinks back when that changes
function readPose(instant) {
  B.stand.target = getComputedStyle(cardSide).getPropertyValue('--pose').trim() === 'stand' ? 1 : 0;
  if (instant) snap(B.stand);
  wake();
}

The pointer on it is a pet: it closes its eyes, content, and holds still instead of chasing a pointer that is already on it. A tap is a poke: it wobbles, giggles and throws off a few droplets, feathers or hearts.

app.js — a pet, and a poke
// the pointer on it is a pet: it holds still and closes its eyes, content (it doesn't chase a pointer that is on it).
// A poke: it wobbles, giggles and throws off a few bits
$$('.b-hit', blobBack).forEach((el) => {
  el.addEventListener('pointerenter', () => { petting = true; wake(); });
  el.addEventListener('pointerleave', () => { petting = false; wake(); });
  el.addEventListener('pointerdown', () => {
    if (!calm) { B.sx.velocity += 3; B.sy.velocity -= 4; B.hop.velocity -= 160; B.sway.velocity += 160; burst(3); }
    react('giggle', 0.9);
  });
});

Every frame the values of all those springs go to draw() as one rig. Standing, the character rides the card's real top edge, so it moves with the card when the card folds.

app.js — one frame of the character
function renderCharacter(t) {
  const rig = { calm, t, blink, shake: shake.value, edge: characters.EDGE + drop.box.y * clamp(B.stand.value, 0, 1) };   // standing, it rides the card's edge
  for (const k in B) rig[k] = B[k].value;
  rig.stand = clamp(rig.stand, 0, 1);
  pose = draw(rig);

With prefers-reduced-motion the character still changes its mood, but nothing jumps, shakes or flies: the springs are critically damped, the reactions skip their kicks, and the stylesheet drops the sheen and the transitions.

style.css — reduced motion
/* ---- 13. reduced motion: no morphs; things fade (app.js snaps the springs) ---- */
@media (prefers-reduced-motion: reduce) {
  .d-sheen { display: none; }
  .eye-slash, .seg button, .field input { transition: none; }
}
Stage 6 · the finished card, with its characterOpen ↗

Three looks, three characters

The looks share the markup, the script and the three token layers; each is a block of semantic tokens and its own character. Here is nordic's set, a paper card with a white pill for a lens and a serif for headings.

style.css — the nordic set of semantic tokens
/* B · NORDIC: oat paper, charcoal ink and cobalt, quiet. A paper card, a white pill for a lens, a serif for headings;
   its owl is a porcelain figurine, hand-painted in cobalt with gold */
[data-look="nordic"] {
  --title-face: var(--sans); --title-w: 700; --em-face: var(--serif); --em-style: italic;
  --head-face: var(--serif); --head-w: 400; --head-size: 30px; --btn-face: var(--sans); --btn-w: 600;
  --page: var(--o-150);
  --field-bg: radial-gradient(ellipse 80% 50% at 50% 55%, var(--o-50) 0%, var(--o-150) 70%);
  --on-field: var(--o-900); --on-field-muted: var(--o-600); --chip: var(--o-900); --on-chip: var(--o-50); --em: var(--cb-700);
  --avatar-ring: var(--o-900); --lockup: brightness(0); --light: rgb(255 255 255 / .6); --halo: var(--o-200);
  --glass: var(--o-50); --glass-filter: none;
  --glass-rim: inset 0 0 0 1px var(--o-200);
  --glass-drop: 0 34px 60px -34px rgb(70 50 25 / .32), 0 8px 16px -10px rgb(70 50 25 / .2);
  --card-r: 22px; --spot: transparent; --rim: transparent; --sheen: rgb(255 255 255 / .7);
  --lens-bg: var(--n-0); --lens-filter: none;
  --lens-rim: inset 0 0 0 1px var(--o-200), 0 1px 2px rgb(60 40 20 / .12), 0 6px 12px -6px rgb(60 40 20 / .22);
  --on-lens: var(--o-900);
  --on-card: var(--o-900); --on-card-muted: var(--o-600);
  --input: var(--o-100); --input-edge: var(--o-200); --input-focus: var(--cb-700); --placeholder: var(--o-500); --input-depth: 0 0 #0000;
  --danger: var(--rd-600); --on-danger-soft: var(--rd-600); --danger-glow: rgb(180 67 47 / .13); --success: var(--gn-600); --on-mark: var(--pc-0);
  --cta: var(--cb-700); --on-cta: var(--pc-0); --cta-depth: inset 0 1px 0 rgb(255 255 255 / .18), 0 10px 20px -12px rgb(21 41 95 / .6);
  --meter-1: var(--o-300); --meter-2: var(--cb-300); --meter-3: var(--cb-700);
  --box: var(--o-100); --box-edge: var(--o-200); --check: var(--o-50); --ring: var(--cb-700);
  --stand-room: 172px; --zz: var(--o-600);
  --accent: var(--cb-700); --accent-soft: rgb(31 60 147 / .1); --chip-soft: rgb(255 255 255 / .6); --chip-soft-edge: var(--o-200); --ok: var(--gn-600);
  --em-fill: linear-gradient(var(--cb-700), var(--cb-700)); --em-drop: none; --grain: .3;
  --plate-1: var(--pc-0); --plate-2: var(--pc-50); --plate-3: var(--pc-150); --plate-paint: var(--cb-700); --plate-paint-soft: var(--cb-300);
  --sprig: var(--cb-500); --sprig-berry: var(--gd-500);
  --owl-glaze-1: var(--pc-0); --owl-glaze-2: var(--pc-50); --owl-glaze-3: var(--pc-150); --owl-glaze-4: var(--pc-300);
  --owl-shade: rgb(31 60 147 / .2); --owl-shine: var(--pc-0); --owl-paint: var(--cb-700); --owl-paint-soft: var(--cb-500);
  --owl-iris: var(--cb-500); --owl-pupil: var(--cb-900); --owl-lid: var(--pc-50); --owl-wash: var(--cb-300);
  --owl-gold-1: var(--gd-300); --owl-gold-2: var(--gd-500); --owl-gold-3: var(--gd-700); --owl-contact: rgb(40 50 80 / .3);
  --pill: rgb(255 255 255 / .6); --pill-edge: var(--o-200); --on-pill: var(--o-900); --on-pill-muted: var(--o-600); --pill-icon: var(--o-900); --on-pill-icon: var(--o-50);
}
Look · glass, the dropOpen ↗
Look · nordic, the owlOpen ↗
Look · clay, the catOpen ↗

Make it yours

  • Looks are tokens: pick one with ?look=glass, nordic or clay (or a, b, c). ?full=1 shows the real page at the size of your window, and ?view=up, code or done opens a later step.
  • The checks are the RULES object: one function per field that returns what is missing, or nothing. Add a field by adding its markup, its rule and its line in EMPTY.
  • The sending is one later() in the submit handler. A real site calls its server there and opens the code step when the answer comes back; the code check is verify().
  • A character of your own is one file: two SVGs, a build() that returns draw(rig), and how its particles fly. The moods, the caret, the pet and the pose come with it.
  • Accessibility is already there: a real form with labels and autocomplete, tabs with roving focus and the arrow keys, inert fields while they are gone, errors announced politely and marked with aria-invalid, a show-password toggle with aria-pressed, one real input for the code, Escape to go back, and prefers-reduced-motion. The character is decoration and hidden from screen readers.
  • Nothing goes anywhere. Calmdeck is made up, every link is #, and the code is printed on the card.
The one idea worth keeping

Give everything that moves a spring and let the state only set targets. The glass, the lens, the fields, the button and every feature of the character are springs; the page decides where they should be, and the springs decide how they get there. That is why nothing ever jumps, why an interrupted change just bends, and why reduced motion is one line: critical damping.