Part IX · Special topics 24 / 25

24 Motion architecture 24 Motion architecture 24 Motion architecture 24 Motion architecture

Decide once, in one place, and every motion in the product speaks the same language.

Chapter 23 kept one chart honest. This chapter is about keeping a whole product consistent. Most products don’t design their motion. They collect it. A menu gets a transition the week it is built. A toast gets one a month later, from someone else. A dialog borrows a curve from a blog post. Each one looks fine on its own. Together they disagree: one thing bounces, the next one glides, a third takes longer to leave than it took to arrive. Nobody chose that. It arrived one pull request at a time.

A motion system makes those choices once. It names a few durations and curves, builds a handful of reusable motions from them, composes those into the moments the product needs, and writes down the rules every motion obeys. Those four jobs, plus the named values underneath them, are the five layers: tokens, primitives, orchestration, patterns and policy. Each one exists because something specific goes wrong without it, and this chapter says what. It takes one system apart: the site you are reading. Apart from the values the figures are teaching, everything that moves here takes its timing from one small file of named values.

The site, taken apart

Figure 24.1 is this site’s own motion system. The column on the left lists its layers. Choose one to see what it holds. The buttons run the real modules, the same ones the rest of the site uses. Layer names in this chapter are the ones on that list.

Fig. 24.1 — This site's motion system, layer by layer, running the real modules

Try three of the layers:

  • Primitives. Press fade, then rise. Each is one small motion on one card, and each can be reused anywhere.
  • Orchestration. Press stagger. The rows arrive one after another, close enough together to read as one gesture. Then press sequence: the card leaves, a beat passes, and it comes back.
  • Patterns. Press Close panel, wait, then Open panel. A pattern is a named moment, built out of primitives.

The other two layers don’t move anything themselves. Tokens are the values every motion above is made of. Policy is the rules they all obey. Notice what none of the motions has: a timing of its own, typed in on the spot. Every duration you just watched was looked up by name.

Which one belongs here?

Open the View menu at the top right of this page, the half-filled circle, and close it again. Then watch these two menus. Don’t count.

Feel

Which of these menus belongs on this site?

Five layers

A motion system is built in layers, and each layer is made only from the ones beneath it. That rule is what keeps it changeable: a value lives in exactly one layer, so a change has one place to go. The table also says what goes wrong when a layer is missing.

LayerHoldsIn this site
Tokensdurations, curves, springs, distances--dur-base, --ease-out, spring.snappy, --dist-rise, all from src/motion/tokens.json
Primitivesone reusable motion eachfade, rise, scaleIn, collapse, move in primitives.ts; data-motion="rise" and friends in motion.css
Orchestrationtiming between motionsstagger, sequence, onSettle, beat in orchestration.ts
Patternsa named moment, composedthe page cut, the figure reveal, the line reveal, listEnter, panelOpen, tokenMorph in patterns.ts
Policyrules every motion obeysreduced motion, a frequency budget, the interruption contract, a concurrency budget, in policy.ts

Tokens are the vocabulary. Without them, every motion carries its own numbers, and the numbers drift: one menu takes 200ms, the next 250ms, and nobody can say why. This site has four durations, four curves, two springs, three distances and one stagger step. That is the whole list, and 14 is a deliberate size: with more, you could not tell neighbours apart by eye, and a token you can’t tell from another one is only a second place to be wrong. Each duration also has an exit twin at 0.7× the time (chapter 9), named with -exit on the end: --dur-base-exit is 196ms.

TokenValueUsed for
--dur-instant90mspress states, toggles
--dur-quick180mshovers, small reveals
--dur-base280mspanels, cards
--dur-scene480mspage and chapter changes
--ease-outcubic-bezier(.2, .8, .2, 1)enter
--ease-incubic-bezier(.4, 0, 1, 1)exit
--ease-inoutcubic-bezier(.65, 0, .35, 1)on-screen moves
--ease-revealcubic-bezier(.16, 1, .3, 1)lines of text rising out of a mask
spring.snappyresponse 0.3, bounce 0direct manipulation
spring.softresponse 0.5, bounce 0.15playful confirmations
--dist-rise4pxpage cut, figure reveal
--dist-nudge8pxtooltips, menus
--dist-travel24pxpanels arriving from an edge
--stagger-step30msthe delay between siblings

Distances are tokens too. How far a thing travels says as much about it as how long it takes, and distance is the first thing reduced motion takes away. A token is a choice, not a law: 90, 180, 280 and 480 are steps on a scale, each between one and a half and two times the one before, so that any two are easy to tell apart by feel. Pick your own steps and keep them few.

Primitives are single motions, written only in tokens. fade comes in over --dur-quick. rise is a fade plus a short climb: --dur-base on --ease-out coming in, --dur-base-exit on --ease-in going out. scaleIn grows from 0.96 on spring.snappy. move slides a thing to its new place on --ease-inout. collapse closes a disclosure to zero height. A primitive knows nothing about where it will be used. That is the point of the layer: without it, every component that needs an entrance writes its own, and two of them will differ by a curve or a distance without anyone deciding so. Interruptions get handled ad hoc in each one, or not at all.

Orchestration is timing between motions. stagger plays one primitive across a group of siblings, one --stagger-step apart, in reading order. sequence runs steps one after another, and each step waits for the last one’s animations to finish. onSettle resolves when a group of animations has finished or been cancelled. beat is a pause measured in a duration token. Without this layer, timing between motions is a pile of typed delays (delay: 150) that stops matching the moment you change a duration, because the delays are numbers nobody linked to the tokens. Here the gaps are computed from them.

Patterns are the moments a reader actually meets, with names. Without them, each screen composes the same moment again from primitives and its own guesses, and the second version of “a list arriving” is slightly different from the first. Here are the ones this site has. The page cut, when you change chapter. The figure reveal: as a figure scrolls into view, its axes draw, then its ghosts appear, then the red key lands. The line reveal: a paragraph such as a chapter’s lede rises into view one line at a time, each line out of its own mask. listEnter is stagger of rise. panelOpen is rise by the nudge distance. tokenMorph is the glide in a lab’s code panel when you switch notation: numbers that both versions share travel from their old place to their new one.

Policy is the rules. Without it, reduced motion is remembered in some components and forgotten in others, and nothing stops a screen from running twenty animations at once. It sits beside the other layers rather than on top of them: each of them asks it before anything moves.

The payoff is one place to change things. Make --dur-base a little longer, and every panel, every rise and the page cut change with it, together. The sections that follow each say which layer they are about.

Where motion lives

This section cuts across the layers rather than adding one. A system also decides who owns each motion, so that primitives and patterns don’t fight. On the web, motion can live in three places, and each is good at one job.

  • CSS owns state. When a thing’s state changes (open, hovered, shown), a CSS transition can carry it there. The browser does the work, no script has to run, and interruption is built in: change the state back halfway and the transition turns round from where it is, with the return trip shortened to match. This site’s View menu moves with CSS alone. So does the declarative primitive, data-motion="rise".
  • JavaScript owns choreography and physics. Anything that has to know about other elements, measure the page, wait for one motion before starting the next, or carry a velocity from one motion into the next. This site’s script primitives run as Web Animations, and its orchestration layer is all script.
  • Canvas owns the procedural. Particles, flocks, noise: motion computed fresh on every frame by a program, with no elements to animate. Every Canvas figure in this course is a small program of setup, update and draw, run in a loop.

Trouble starts when two of them own the same property of the same element. Say a CSS rule has transition: transform, and a drag handler writes style.transform on every pointer move. Each write starts a new transition toward the pointer, so the element trails behind the finger as if on elastic. Or a Web Animation with fill: 'forwards' finishes and goes on holding its last value. (fill says what an animation does before it starts and after it ends; forwards keeps the last frame applied.) A class change that sets the same property later does nothing, because the animation still wins.

The fix is ownership: one property, one owner. The figure reveal is a clean example. The script decides only when: it adds a class as a figure scrolls into view. The stylesheet decides how: every duration and delay of the drawing-in, all in tokens. Neither touches the other’s half.

Motion as state

This is the reasoning behind the primitives layer. Every animation in an interface is a trip between two states. A panel is closed or open, and the motion is how it gets from one to the other. Drawn as a small machine, it goes closed, opening, open, closing, and closed again.

Chapter 9 named the three kinds of trip: an enter, from closed to open; an exit, from open to closed; and a change, from one on-screen state to another, like a tab’s underline moving. The machine makes one more thing obvious. Events don’t wait for motion to end. Someone can press Close while the panel is still opening. What happens then isn’t a detail. It is a decision, and there are only a few good answers. Try one in your head: a panel is halfway open and the user presses Close. Should it finish opening first, jump shut, or turn round where it is? The table names the four options.

RuleWhat happensGood for
reverseIt turns round and goes back from where it is.Anything undoable: open and close, show and hide.
retargetIt heads for the new target from where it is. A spring keeps its speed as well.Moves, drags, a tab’s underline: anything with momentum.
queueThe running motion finishes, then the next one starts.Moments that shouldn’t be cut short and block nothing, like a second toast waiting for the first.
finishIt jumps to its end state, then the new motion starts.When the path doesn’t matter and the end state does.

The one answer that is never good is the one script gives you by accident. Call animate() again with the same keyframes and the motion restarts from its first keyframe. A panel that blinks shut before it opens again, or jumps to fully open before it closes, is saying the machine lost track of where it was.

The interruption contract

This is the primitives layer’s job. Make it a rule: every motion declares what happens if it is interrupted. Without the rule, each author picks a behaviour by accident, and the accident is usually a restart. In this site that isn’t a convention, it’s a type. A primitive is keyframes, timing, and an interrupt field, and TypeScript won’t accept a primitive without one.

PrimitiveOn interrupt
fade, rise, collapsereverse
scaleIn, moveretarget

The type also names finish and queue. No primitive uses them yet.

play(), in primitives.ts, is the one place the contract is carried out. Before a primitive starts on an element, play() looks for the motion already running there. For finish, it jumps that motion to its end. For the others, it cancels it and starts the new one from the element’s computed style (the values the browser is showing this frame): the aim is to go on from where the element is. A Web Animation has no velocity to hand on, so here reverse and retarget come to the same thing: go on from where you are. A real spring, as in chapter 5, would keep its speed too.

Carrying it out takes care. To go on from where the element is, read its computed style before you cancel the running animation. Cancel first, and the style you read is the resting one, because the cancelled motion’s values are already gone. The element jumps.

To feel the contract, choose Patterns in figure 24.1 and press the panel button several times, quickly. The panel’s primitive is rise, and rise declares reverse: each press takes the panel back from wherever it is. It reads where the panel is before it cancels the old motion; read it after, and every press would jump.

Declarative or imperative

Still in the primitives and patterns layers, the question is how a page asks for them. A system needs an API: the way a page asks for motion. There are two kinds, and this site uses both.

Declarative: the element says what it is, and the system decides how it moves. <p data-motion="rise"> is the whole request. The stylesheet has one rule per primitive, written in tokens, with @starting-style giving the first frame. The note under every FEEL question in this course arrives this way, and so do the drills’ results.

Imperative: code says what to do and when. stagger(rows, () => rise('in')). await sequence([…]). GSAP’s gsap.timeline() is the best-known version of the idea: you place motions on a timeline, then play, pause, reverse or seek the whole thing.

DeclarativeImperative
Looks likedata-motion="rise"sequence(), gsap.timeline()
Lives inmarkup and a stylesheeta script
Runs without JavaScriptyesno
Knows about other elementsnoyes: it can measure, wait, branch
Sequencingdelays, at mostorder, overlap, wait for the end
Easy to auditsearch for the attributevalues can hide anywhere in code

Here, data-motion covers entrances only, because its rules rest on @starting-style, which supplies a first frame and nothing else. Exits and changes need either a pair of CSS rules, like the View menu’s, or a script.

A good system offers both and leads with the declarative one. Most motion is common: things arriving, things answering a hover. Give those a name a designer can read in the markup, and save script for the moments that need to know about the page. On Monday: if a motion is only an entrance, try writing it as an attribute before you write a function. Even then, the script can be declarative at its edge. The page cut is an example. pageCut is plain data: for each direction, a list of keyframe names for the old page and one for the new, with durations and delays taken from tokens. Astro’s router runs it on the page’s scrolling frame.

old
new
0510152025frameold · opacitynew · opacitynew · y
00/60 fr 0ms
Fig. 24.2 — The page cut on an exposure sheet: the old page out, the new page in halfway

Figure 24.2 is a pattern drawn as a timeline: each bar is one animation, and its start is its delay. Look for where the bars overlap. The old page fades out over --dur-base-exit on --ease-in. Halfway through the exit, at 98ms (half of 196ms), the new page starts to fade in and rise --dist-rise, over --dur-base on --ease-out. The overlap is what makes it a dissolve and not a blink. Without it, the screen would be empty for a moment: for a moment, both pages are partly there. The header opts out with transition:animate="none", so only the page body cuts. During the cut, the one thing in the header that moves is the short ink line under the current section’s name: go from Contents to the Lab and it slides across to its new place.

Design and code, one source

Back to the tokens layer, and to who reads it. A spec that says “make it snappier” can’t be built. One that says 280ms cubic-bezier(.2, .8, .2, 1) can, but only an engineer reads it, and somewhere it will be copied wrong. Names fix both. “The panel enters on --dur-base with --ease-out” is a sentence a designer can write and an engineer can type.

Three things make a motion handoff work:

  • Token names, shared by the design file and the code.
  • Spacing charts, which show a curve as positions a designer can see and an engineer can check, frame by frame.
  • Spring parameters in the designer’s pair, response and bounce. Both sides can reason about them, and stiffness and damping are computed from them.

Figure 24.3 draws each named curve as a spacing chart (chapter 3): each row is one motion, and each dot is where the thing is at one frame. Read the gaps between dots, not the dots.

--ease-out · --dur-base17 fr · 280ms01234567917--ease-in · --dur-base-exit12 fr · 196ms023456789101112--ease-inout · --dur-base17 fr · 280ms03456789101112131417spring.snappy28 fr · 474ms012345678910111328spring.soft44 fr · 727ms023456789101112131415171923
00/60 fr 0ms
Fig. 24.3 — The site's curves as spacing charts: enter, exit, move, and the two springs

This is the site’s motion vocabulary as a sheet of drawings. The enter crowds its frames at the end, where it settles. The exit crowds them at the start and is gone at full speed. The move crowds both ends. The two springs have no duration of their own. Theirs were measured from the physics: 474ms for spring.snappy, 727ms for spring.soft.

Names fix the handoff on the day. What keeps it true over time is one source of truth. Every token in this site lives in one JSON file, src/motion/tokens.json, and nobody edits anything else. A build script, scripts/build-tokens.mjs, compiles it into three notations each time the dev server or a build starts:

  • src/styles/tokens.css: CSS custom properties. Each duration gets its exit twin at 0.7×. Each spring becomes a linear() curve plus the duration it must run for, --spring-snappy and --spring-snappy-dur. Under reduced motion, all three distance tokens are set to 0px.
  • src/motion/tokens.ts: the same values as typed constants for scripts, with each spring’s stiffness, damping and linear() string worked out, and types such as DurationToken.
  • public/tokens.figma.json: the same values as Figma variables, in two collections. Inbetween / Color holds the Pencil palette, with one mode for light, Paper, and one for dark, Lightbox. Inbetween / Motion holds the durations, easings, spring response and bounce, and distances, each with its usage note as its description. Open it to see.

Springs are the interesting case. The JSON holds only what a designer chooses: response and bounce. The build hands those to this site’s motion core, @inbetween/core. fromResponse turns them into stiffness and damping, and springToLinear solves the spring, samples it, and simplifies the samples into a short linear() string. So CSS gets real spring physics, and Figma gets the two numbers a designer can reason about.

Each generated file carries the same line: GENERATED from src/motion/tokens.json by scripts/build-tokens.mjs. Do not edit by hand.

Testing motion

This section applies to every layer at once: how you check that the tokens, primitives and patterns still do what they claim. This site has no automated tests. It is checked by eye, in a browser. But it already has the one thing every motion test depends on: a clock it controls.

Animation is a function of time, and time is the one input a test can’t normally set. Take it over, and motion becomes as testable as anything else.

  • A deterministic clock. The virtual clock in src/lib/sandbox/clock.ts replaces requestAnimationFrame, performance.now and Date.now in a page with virtual time. On every frame it also finds each CSS transition, CSS animation and Web Animation through document.getAnimations(), pauses it, and sets its currentTime itself. Time moves only when it is told to: one frame at a time, or fast-forward. It can fake a refresh rate too, with frames of exactly 1000 / 120 ms for a 120Hz display. The Frame stepper uses it: open the specimen sheet in the Frame stepper and step it. For code that runs its own loop, @inbetween/core has a manualClock() that you advance by hand.
  • Snapshot frames. With a fixed clock, frame 9 of a motion is the same picture on every run. Step to a few chosen frames and save what is there, as a screenshot or as computed values. Change a token by accident, and the snapshot changes.
  • Assert the settle. The frame that matters most is the last one. Fast-forward past the end and check the end state exactly: opacity 1, no transform left over, the removed element really gone, nothing still running. onSettle() is the hook for this. It resolves when every animation in a group has finished or been cancelled.
  • Test the interruption. Start a motion, step to its middle, start the opposite one, and compare the frames on either side of the switch. Under a reverse rule, they should match.
  • Snapshot reduced motion. Run the same checks with prefers-reduced-motion: reduce emulated. Playwright has a reducedMotion option for this, and this site’s screenshot script, scripts/shoot.mjs, takes a --reduced flag. The end states should be the same as with full motion. Only the movement should be gone.

A fake clock only helps if the code under test can’t tell it is there. This one drives animations by pausing them, so code that asks whether an animation’s playState is 'running', as play() does, takes a different branch under the clock than in real time. Test that kind of branch in real time as well.

Policy

The policy layer is short: the rules every motion obeys, written down in policy.ts, where code can read them. Without it, each of those rules is a habit, and habits are kept by whoever remembers.

Reduced motion: reduce, don’t remove. The policy follows the system setting, unless the reader has chosen Full or Reduced in the View menu. In script it is one function, prefersReducedMotion(), and every moving thing asks it. When the answer is reduce, movement stops and fades stay, because a fade still says that something changed. It happens in each notation:

  • In the tokens, the distances drop to 0px. Every data-motion="rise" becomes a pure fade, and its rule never changes.
  • In motion.css, scale-ins start at full size, a figure’s axes no longer draw themselves in, and its red key fades in where it is instead of landing.
  • In script, reduce(), which sits beside the primitives in primitives.ts, strips transforms, translations, scales and heights from a primitive, keeps its opacity, and caps its length at --dur-quick. A primitive with nothing left to animate, like move, doesn’t run at all: the thing is simply in its new place. stagger drops its delays, and beat waits for nothing.

Figures are the exception, because their motion is the content. Their Still toggle shows what they become under reduced motion: the onion skin, drawn still. Chapter 21 covers this in full.

Frequency budget. The more often something is seen, the less it may move: a hover you meet a hundred times a day must be nearly invisible, and a chapter change can afford a moment. The table gives a ceiling for each band, not a target.

SeenLongest durationFarthest distanceFor example
constantly--dur-instantnonetyping, pressing, scrolling
frequently--dur-quick--dist-nudgehovers, menus, tabs, toggles
occasionally--dur-base--dist-travelpanels, dialogs
rarely--dur-scene--dist-travelchapter cuts, onboarding

Interruption contract. Every primitive declares its rule, as above.

Concurrency budget. No more than MAX_CONCURRENT_UI, which is 6, interface animations at once. Figures are exempt: they are content.

The two budgets are written as data (a table in code, not a check). Nothing enforces them while the site runs: no code counts animations and refuses the seventh. They are there for the people who write and review motion, and for tools like figure 24.1 to show.

Performance budgets

Performance is a policy too, so it belongs to the same layer. A motion system is where performance gets decided, once, instead of motion by motion.

  • How many at once. Every motion competes for the eye and for the frame. The concurrency budget caps the first, and that helps the second.
  • Compositor-friendly by default. The primitives animate opacity and transforms, which the browser can usually run on the compositor, with no layout or paint on each frame. The exception is collapse, which animates height, because a disclosure really does change the layout. That costs a layout on every frame, so keep it for small things. Chapter 20 walks through the pipeline.
  • No ad-hoc values. scripts/check-motion-tokens.mjs reads every stylesheet and style block in the app, and every .animate() call in its layouts, UI components, motion layer and pages. Put a raw duration, a cubic-bezier(), a linear() curve or an easing keyword in a style, or a raw duration in one of those .animate() calls, and it names the line and exits with an error. Run it from the repo root with pnpm lint:motion; pnpm build runs it first, so a stray value fails the build. The token files are exempt, and so are the values a figure is teaching, which live in its props and not in its styles.
  • Frames, not just milliseconds. At 60Hz a frame lasts 16.7ms. At 120Hz it lasts 8.3ms, and the browser needs part of that for its own style, layout, paint and composite. A tween written in milliseconds adapts by itself: it simply gets more frames. Code that runs on every frame gets half the time, and it must move by elapsed time, dt, never by frame count. Chapter 14 shows why.

Documenting motion

This is a tool for every layer: it makes them visible. A system nobody can see gets forgotten, then copied wrong. So document it where it can’t drift: in a page built from the tokens themselves.

This site’s is the specimen sheet. It is built from tokens.json, so it always shows what the code has: the colours, the type, a sample figure with its drawn-in reveal, and a row for every duration, curve and spring, with its name, its value, what it is for, and a dot that plays it when you hover the row. It is a Storybook for motion: every piece isolated, labelled, and live.

The second half is an inspector. Anywhere on this site, hold Alt (⌥ on a Mac) and point at something with a transition: a button, the View menu, one of the dots on the specimen sheet. A small card shows the duration and the curve, and draws the curve. When a value matches a token, the card names it. A duration with no name beside it is worth asking about.

The case study, end to end

Here is the whole path through this codebase, from a number in a file to a page. Each step is one layer, in order, so use it as a map: whichever file you open, you can say which layer you are in.

  1. src/motion/tokens.json (tokens) is the source: a designer’s decisions, as names and values, each with a note on what it is for.
  2. scripts/build-tokens.mjs compiles it to tokens.css, tokens.ts and tokens.figma.json.
  3. packages/core, the workspace package @inbetween/core, is the maths underneath, with no dependencies: cubic Bézier curves solved the way browsers solve them, springs solved in closed form, any curve sampled into linear(), a frame loop that runs on elapsed time. The token build uses it for springs, the inspector uses it to draw curves, and most of this course’s figures and lab instruments are drawn with it.
  4. primitives.ts and motion.css (primitives) hold single motions, in tokens only, each with its interruption rule.
  5. orchestration.ts (orchestration) holds stagger, sequence and settle.
  6. patterns.ts (patterns) holds the page cut, the figure and line reveals, the list and panel enters, and the token morph.
  7. The pages. Base.astro puts the page cut on the scrolling frame that holds each page. boot.ts runs on every page load: it applies the reader’s preferences, starts the figure reveals, and installs the inspector. The code panel calls tokenMorph when you switch notation.

policy.ts (policy) runs alongside all of it, and every layer asks it before anything moves.

The handoff, as an instrument

The Export desk takes one motion and writes it in every notation this course uses, plus a handoff for a designer. It starts on --ease-out over --dur-base: a panel arriving.

  • Read the handoff card at the bottom. The motion is written once as a sentence a designer can check, and once as tokens, each with a $type and a $value. You should see --ease-out and 280 named in both.
  • Now make it the panel’s exit. Type --ease-in into Any easing and 196 into the duration beside it, which is --dur-base-exit, then press Convert. Every notation above the handoff changes at once: CSS, WAAPI (the Web Animations API, element.animate()), Motion, GSAP and Canvas. The numbers are the same; only the syntax moves.
  • Last, type spring.snappy and press Convert. The duration box stops mattering, because a spring’s duration is its settle time: 474ms. CSS and WAAPI get the spring as a linear() curve, Motion gets the spring itself, and the handoff tokens list stiffness and damping next to response and bounce. One source, many notations: the idea behind tokens.json, one motion at a time.
box · x17 fr · 280ms
00/60 fr 0ms
CSS
/* Export */
.box {
  transform: translateX(0px);
  transition: transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.is-on .box {
  transform: translateX(240px);
}
WAAPI
// Export
const box = document.querySelector('.box');

box.animate(
  [{ transform: 'translateX(0px)' }, { transform: 'translateX(240px)' }],
  {
    duration: 280,
    easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
    fill: 'both',
  },
);
Motion
// Export
import { animate } from 'motion';

animate('.box', { x: [0, 240] }, {
  duration: 0.28,
  ease: [0.2, 0.8, 0.2, 1],
});
GSAP
// Export
import { gsap } from 'gsap';
import { CustomEase } from 'gsap/CustomEase';

gsap.registerPlugin(CustomEase);

gsap.fromTo('.box', { x: 0 }, {
  x: 240,
  duration: 0.28,
  ease: CustomEase.create('ease0', 'M0,0 C0.2,0.8 0.2,1 1,1'),
});
Canvas
// Export
// update(dt) moves things; draw(ctx, width, height) paints them. dt is in seconds.

const box = { x: 0, y: 0, scale: 1, opacity: 1, rotate: 0 };
const from = 0, to = 240;
const duration = 0.28;   // seconds
const ease = cubicBezier(0.2, 0.8, 0.2, 1);
let time = 0;

function update(dt) {
  time += dt;
  const t = Math.min(Math.max(time / duration, 0), 1);
  box.x = from + (to - from) * ease(t);
}

function draw(ctx, width, height) {
  ctx.clearRect(0, 0, width, height);
  drawBox(ctx, box, 60, height / 2);
}

function drawBox(ctx, o, x, y) {
  ctx.save();
  ctx.globalAlpha = o.opacity;
  ctx.translate(x + o.x, y + o.y);
  ctx.rotate((o.rotate * Math.PI) / 180);
  ctx.scale(o.scale, o.scale);
  ctx.fillStyle = '#FF3B1F';
  ctx.fillRect(-20, -20, 40, 40);
  ctx.restore();
}

// cubic-bezier(), solved the way browsers do: find t for x, then read y.
function cubicBezier(x1, y1, x2, y2) {
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
  const X = (t) => ((ax * t + bx) * t + cx) * t;
  const dX = (t) => (3 * ax * t + 2 * bx) * t + cx;
  return (x) => {
    let t = x;
    for (let i = 0; i < 8; i++) {   // Newton: fast while the slope is healthy
      const d = dX(t);
      if (Math.abs(d) < 1e-6) break;
      t -= (X(t) - x) / d;
    }
    if (!(t >= 0 && t <= 1) || Math.abs(X(t) - x) > 1e-7) {
      let lo = 0, hi = 1;   // bisection: slow and certain
      for (let i = 0; i < 40; i++) {
        t = (lo + hi) / 2;
        if (X(t) < x) lo = t;
        else hi = t;
      }
    }
    return ((ay * t + by) * t + cy) * t;
  };
}
Design handoff · tokens + spec

box · x: 0px → 240px over 280ms (17 frames), cubic-bezier(0.2, 0.8, 0.2, 1).

Tokens in the W3C Design Tokens format (2025.10): durations as a value and a unit, curves as cubicBezier.

{
  "motion": {
    "box-x": {
      "duration": {
        "$type": "duration",
        "$value": {
          "value": 280,
          "unit": "ms"
        }
      },
      "delay": {
        "$type": "duration",
        "$value": {
          "value": 0,
          "unit": "ms"
        }
      },
      "easing": {
        "$type": "cubicBezier",
        "$value": [
          0.2,
          0.8,
          0.2,
          1
        ]
      },
      "from": {
        "$type": "dimension",
        "$value": {
          "value": 0,
          "unit": "px"
        }
      },
      "to": {
        "$type": "dimension",
        "$value": {
          "value": 240,
          "unit": "px"
        }
      }
    }
  }
}

The notation

The code here is the system itself, one snippet per layer. Read them top to bottom: the source, the tokens the build writes from it (tokens), then a primitive and the interruption rule (primitives).

24 · Motion architecture: the source, tokens.json Open in Lab
{
  "duration": {
    "quick": { "value": 180, "use": "hovers, small reveals" },
    "base": { "value": 280, "use": "panels, cards" }
  },
  "exitRatio": 0.7,
  "easing": {
    "out": { "value": [0.2, 0.8, 0.2, 1], "use": "enter" },
    "in": { "value": [0.4, 0, 1, 1], "use": "exit (exits are faster: ~0.7× enter)" }
  },
  "spring": {
    "snappy": { "response": 0.3, "bounce": 0, "use": "direct manipulation" }
  },
  "distance": {
    "rise": { "value": 4, "use": "page cut, figure reveal" }
  },
  "stagger": {
    "step": { "value": 30, "use": "delay between siblings (20–40ms reads as one gesture)" }
  }
}
24 · Motion architecture: what the build writes, tokens.css Open in Lab
/* GENERATED from src/motion/tokens.json by scripts/build-tokens.mjs. Do not edit by hand. */

@layer tokens {
  :root {
    /* Durations (§1.6). Exits run at 0.7× the enter. */
    --dur-quick: 180ms; /* hovers, small reveals */
    --dur-quick-exit: 126ms;
    --dur-base: 280ms; /* panels, cards */
    --dur-base-exit: 196ms;

    /* Curves */
    --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); /* enter */
    --ease-in: cubic-bezier(0.4, 0, 1, 1); /* exit (exits are faster: ~0.7× enter) */

    /* Springs, compiled to linear() by @inbetween/core */
    /* spring.snappy: response 0.3, bounce 0 (direct manipulation) */
    --spring-snappy: linear(0, 0.0046 1%, 0.0173 2%, 0.044 3.33%, 0.0793 4.67%,
      0.1097 5.67%, 0.154 7%, 0.3698 13%, 0.4605 15.67%, 0.5132 17.33%, 0.5623 19%,
      0.6415 22%, 0.6804 23.67%, 0.7158 25.33%, 0.7478 27%, 0.7766 28.67%,
      0.8025 30.33%, 0.8301 32.33%, 0.8717 36%, 0.9087 40.33%, 0.9372 45%,
      0.9595 50.33%, 0.9761 56.67%, 0.9868 63.67%, 0.9936 72%, 1);
    --spring-snappy-dur: 474ms;

    /* Distances */
    --dist-rise: 4px; /* page cut, figure reveal */

    /* Orchestration */
    --stagger-step: 30ms;
  }

  /* Policy: reduce, don't remove. Movement stops; fades and ghosts stay. */
  :root[data-motion="reduce"] {
    --dist-rise: 0px;
  }
  @media (prefers-reduced-motion: reduce) {
    :root:not([data-motion="full"]) {
      --dist-rise: 0px;
    }
  }
}

Both are excerpts: the real files hold every token, and the real motion.css has a nudge rule too. Then a primitive, both ways. The CSS form is declarative and covers entrances. The script form carries the interruption rule, and reads where the element is before it cancels anything.

24 · Motion architecture: declarative primitives, motion.css Open in Lab
/* The motion layer: no raw durations or curves, only tokens.
   Use: <p class="explain" data-motion="rise">…</p>
   It rises in on the first frame it is shown. Under reduced motion
   --dist-rise is 0px, so the same rule becomes a pure fade. */
@layer motion {
  [data-motion="fade"] {
    transition: opacity var(--dur-quick) var(--ease-out);
    @starting-style { opacity: 0; }
  }
  [data-motion="rise"] {
    transition: opacity var(--dur-base) var(--ease-out), translate var(--dur-base) var(--ease-out);
    @starting-style { opacity: 0; translate: 0 var(--dist-rise); }
  }
  [data-motion="scale-in"] {
    transition: opacity var(--dur-quick) var(--ease-out), scale var(--spring-snappy-dur) var(--spring-snappy);
    @starting-style { opacity: 0; scale: var(--scale-from, 0.96); }
  }

  /* Policy: under reduced motion, scale-ins start at full size. */
  :root[data-motion="reduce"] {
    --scale-from: 1;
  }
  @media (prefers-reduced-motion: reduce) {
    :root:not([data-motion="full"]) { --scale-from: 1; }
  }
}
24 · Motion architecture: a primitive and its interruption rule Open in Lab
// A primitive: keyframes, timing from tokens, and a rule for interruptions.
// Simplified from this site's src/motion/primitives.ts: one primitive, no height,
// and a shorter reduce().
import { duration, exitDuration, easingCss, distance } from './tokens';
import { prefersReducedMotion } from './policy';

export const rise = (dir = 'in', dist = 'rise') => {
  // dist is the NAME of a distance token; distance[dist] is its value in px.
  const hidden = { opacity: 0, transform: `translateY(${distance[dist]}px)` };
  const shown = { opacity: 1, transform: 'none' };
  return {
    name: `rise-${dir}`,
    keyframes: dir === 'in' ? [hidden, shown] : [shown, hidden],
    timing: dir === 'in'
      ? { duration: duration.base, easing: easingCss.out, fill: 'both' }
      : { duration: exitDuration.base, easing: easingCss.in, fill: 'both' },
    interrupt: 'reverse',
  };
};

// Policy: reduce, don't remove. Drop the movement, keep the fade.
const reduce = (p) => ({
  ...p,
  keyframes: p.keyframes.map(({ transform, ...rest }) => rest),
  timing: { ...p.timing, duration: Math.min(p.timing.duration, duration.quick) },
});

const running = new WeakMap();

export function play(el, primitive) {
  const p = prefersReducedMotion() ? reduce(primitive) : primitive;
  const prev = running.get(el);

  // Where is it now? Read this before cancelling anything: once the old
  // animation is cancelled its values are gone, and the element would jump.
  const { opacity, transform } = getComputedStyle(el);
  // continuing: an earlier animation exists and hasn't been cancelled or finished-and-cleared.
  const continuing = prev && prev.playState !== 'idle';

  // reverse and retarget: stop the old motion, go on from where it is.
  // (The site's version also handles 'finish'.)
  if (prev?.playState === 'running') prev.cancel();

  // Replace the first keyframe's opacity and transform with the live ones,
  // so the new motion starts where the old one was.
  const from = { ...p.keyframes[0] };
  if (continuing && 'opacity' in from) from.opacity = opacity;
  if (continuing && 'transform' in from) from.transform = transform;

  const anim = el.animate([from, ...p.keyframes.slice(1)], p.timing);
  running.set(el, anim);
  return anim;
}

Exercise: refactor a messy page

Now use the layers on a real mess. Here is a page the way most pages start. Every motion on it was written on the day it was needed. If it lived in this site’s UI, the token check would flag its raw durations and curves, line by line.

24 · Motion architecture: before, the CSS Open in Lab
/* Before: every motion was invented on the spot. */
.menu {
  transition: opacity .2s ease, transform .25s cubic-bezier(.3, .7, .4, 1);
}
.menu:not(.open) { opacity: 0; transform: translateY(-6px); }

.toast { animation: toast-in 400ms ease-in-out; }
@keyframes toast-in { from { opacity: 0; transform: translateY(20px); } }

.card:hover { transform: translateY(-3px); transition: transform 150ms ease-out; }

.dialog { transition: all 0.3s; }
24 · Motion architecture: before, the script Open in Lab
// Before: numbers typed inline, no reduced motion, no plan for interruptions.
items.forEach((li, i) => {
  li.animate(
    [{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'none' }],
    { duration: 350, delay: i * 75, easing: 'ease-out', fill: 'both' },
  );
});

openButton.onclick = () => {
  panel.animate(
    [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }],
    { duration: 500, easing: 'cubic-bezier(.17, .67, .83, .67)', fill: 'both' },
  );
};

What’s wrong with it, by eye and by rule:

  • Seven durations and five curves for six similar jobs. Nothing matches anything else on the page.
  • The menu has one transition for both directions, so it leaves as slowly as it arrives, on the same curve.
  • The card’s transition is only on :hover. A transition takes its timing from the rule the element is going to, so the lift animates and the drop snaps back.
  • transition: all animates whatever changes, including layout properties nobody meant to animate.
  • A 75ms step makes the list read as a queue, not a gesture.
  • The panel restarts from hidden on every tap, has no way to close, and ignores reduced motion.

The refactor takes three moves, one per layer (policy comes free, as the last move says):

  1. Tokens. Replace every number and curve with the token for its job. A menu is --dur-quick, a panel --dur-base. Enters use --ease-out; exits use --ease-in at their -exit durations.
  2. Primitives. Where a rule only describes an entrance, delete it and name the primitive instead. The toast becomes data-motion="rise", the dialog data-motion="scale-in".
  3. Patterns. Where script choreographs, call the pattern: listEnter for the list, panelOpen and panelClose for the panel. Reduced motion and the interruption rule come with them.
24 · Motion architecture: after, the CSS Open in Lab
/* After: every value is a token, and common enters are primitives. */
.menu {
  opacity: 0;
  translate: 0 calc(-1 * var(--dist-nudge));
  transition:
    opacity var(--dur-quick-exit) var(--ease-in),
    translate var(--dur-quick-exit) var(--ease-in);
}
.menu.open {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity var(--dur-quick) var(--ease-out),
    translate var(--dur-quick) var(--ease-out);
}

.card { transition: translate var(--dur-quick) var(--ease-out); }
.card:hover { translate: 0 calc(-1 * var(--dist-rise)); }

/* The toast and the dialog need no rules of their own:
   <div class="toast" data-motion="rise">…</div>
   <dialog class="dialog" data-motion="scale-in">…</dialog> */
24 · Motion architecture: after, the script Open in Lab
// After: a pattern for each moment. Patterns are made of primitives,
// primitives of tokens, and all of them ask the policy first.
import { listEnter, panelOpen, panelClose } from '~/motion/patterns';

// rise, one --stagger-step apart. Under reduced motion: fades, no delays.
listEnter(list);

// rise by --dist-nudge, with the reverse rule for interruptions.
let open = false;
openButton.onclick = () => {
  open = !open;
  (open ? panelOpen : panelClose)(panel);
};

The page now passes the token check. It is also shorter, and the next person to add a menu has nothing to decide. But someone had to decide once: which durations, which curves, what an exit does. In chapter 25 you make those decisions for a product of your own, alone, from a blank file.

Fix the feeling

Each round is one motion that broke a rule a system would have made for it: too slow for how often it is seen, the wrong curve for an arrival, a stagger that reads as a queue. Name the knob, then fix it in one change.

Eye trainer · choose Fix the feeling

It feels wrong. Name the cause, and fix it in one change.Critique: feeling → cause → parameter → change one thing.

5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.

Full rounds and your calibration in the Eye trainer