Motion System
Motion you tune by eye, not by number. Springs described as how long and how bouncy, a playground where every token races its siblings, and a small set of rules that keep the feel once it's right.
Everyone can feel bad motion. Nobody can name it.
A menu that lands too hard, a toast that leaves slower than it arrived, a bounce that reads as cartoonish: people notice, but there are no words to fix it with. So motion gets magic numbers, a 300ms here and an ease-in-out there, tuned by whoever built that screen, and every screen feels slightly different.
I wanted motion you could talk about and judge by eye: names like snappy and
gentle instead of stiffness values, and a place to watch the options side by side before
choosing one.
Rules about how things should feel.
- Springs are "how long" and "how bouncy."Not stiffness and damping. Designers can picture "400ms, a little bounce"; nobody can feel "stiffness 170." It also means springs slow down with the playground's time scale like everything else.
- Only springs bounce.Easing curves are held inside 0 to 1, so they can't overshoot. Springs are the only thing that goes past its target, and bounce is capped at 0.5, because past that it reads as cartoonish. Each token family has one job.
- Exits are faster than entrances.People care about what's arriving, not what's
leaving. Things enter on a spring and leave on the
quickduration. - Reduced motion is handled once.One hook decides for every primitive. Nothing travels or scales, but fades stay. Reducing motion should remove movement, not feedback, or the interface feels broken.
- Left out on purpose.No delay token, since stagger covers sequencing. No named animations like "fadeInUp," since those are combinations and belong in primitives. Only four durations, so choices stay consistent.
I tried a knob for every token, then removed it. Early on, every
token had its own control. I took them out. Easing tokens are roles (standard,
enter, exit), so letting a viewer swap the exit curve for something else
broke what the token means. Editing came back later behind an Edit button. This time it changes values,
not roles, and it sits right next to the race it affects.
The first version looked like every docs site.
v1 was neutral on purpose, with Inter on off-white, one card per token, and a temporary switcher previewing four visual directions. It worked, but it was generic. It had the docs-site look the brief listed as the first thing to avoid.
- Races instead of cards.Thirteen separate cards became four panels, where every token in a family runs on the same track at the same moment. You see the difference instead of comparing numbers.
- Color means token type.Duration is orange, easing blue, spring yellow, stagger green, from the hero keys to every demo. Every color pair passes WCAG AA.
- One type family.Space Grotesk at two weights carries the whole hierarchy. A second sans would have looked like a near miss.
- Quiet gray, loud tokens.Three steps of gray separate the screen, the page and the panels, and outlines are hairlines. The four token colors are the brightest things on the page, so the motion carries the personality.
Components ask for a name, never a number.
- 1 · TokensDurations, easings, springs and stagger in one
tokens.json(W3C format). Token names become types, so a typo like'bouncyy'fails to compile. - 2 · PrimitivesReveal, Presence, Stagger, Move and Shuttle turn tokens into movement. They're the only code allowed to import the animation library.
- 3 · PatternsA modal, a list and a toast, built only from primitives, ask for
'quick', never160.
The layers exist for one reason: once a feel is approved, no screen can quietly tune its own. A component can only ask for a name.
Measure it, don't trust it.
- Bouncy springs flew off their cards.At high bounce, race dots overshot the end of their track by up to 318px and left the card entirely. Clipping them would have hidden the bounce, so I held easing curves inside 0 to 1 and built a new primitive to keep spring dots on their tracks. Now no dot leaves its card, and the bounce is still visible.
- The exports had to match the source exactly.Before I shipped export, I checked every format. The JSON download matches the source file byte for byte, and the TypeScript compiles in strict mode with the token-name types intact. For the CSS, I loaded it in a browser and checked the values. If you've edited tokens, the export includes those edits and nothing else, so the time-scale preview never ends up in a file.
- An accessibility audit found three fixes.Touch targets grow to 44px on touch screens, the toast's screen-reader announcement moved to an element that's allowed to carry it, and every breakpoint uses rem units so the layout responds to a reader's text-size setting.
Tuned by eye, then locked in.
The playground is where a feel gets decided: run the races, edit a spring until it looks right, then export it as JSON, TypeScript or CSS. From there it ships as a name, so the motion you approved is the motion every screen gets.
- A full-page modal.The modal demo traps focus but stays inside its card. Rebuilding it on an accessible dialog component would show the motion system layered onto a real component library.
- A "when not to animate" guide.The rules say how things should move. The next page says when they shouldn't, like data that updates every second, or text someone is trying to read.
- Check the names against people.Show viewers the springs side by side and ask which one feels "snappy" or "gentle," to see whether the names match what people expect.