Two things can take exactly the same length of time and feel nothing alike. One leaps out and settles; another hangs back and then rushes at you. That difference is the curve, and Odin has four of them — one for arriving, one for leaving, one for crossing the screen, and one for meaning it literally.
These are the values the product already used, collapsed onto a scale. Nothing shifted by more than 40ms in the process, which is the test of whether a scale is describing a codebase or arguing with it.
| Token | At default | When |
|---|---|---|
| --fc-duration-instant | 100ms | A state change you should not notice happening. Hover, focus, a checkbox. |
| --fc-duration-quick | 150ms | Something small appearing or leaving in place. A tooltip, a menu item. |
| --fc-duration-base | 200ms | The default. If you are unsure which to use, it is this one. |
| --fc-duration-moderate | 300ms | Something with size to it. A drawer, a panel, an expanding row. |
| --fc-duration-slow | 400ms | A full-width or full-height move. Rare. |
| --fc-duration-slower | 500ms | One element the eye must follow across the screen. Rarer. |
Six is enough because the ends are rare. Most of the product is quick and base; if a change wants slower, it is usually a change that wants rethinking.
Each one is the middle ground of the values it replaced. Two of them come from collapsing near-duplicates: ease and ease-in-out became standard, ease-out and two decelerating beziers became out. Linear kept its exact value, because it means a constant rate rather than a feel and there was nothing to average.
| Token | Value | When |
|---|---|---|
| --fc-ease-standard | cubic-bezier(0.4, 0, 0.2, 1) | Both ends eased. Anything that moves from one place on screen to another. |
| --fc-ease-out | cubic-bezier(0.22, 1, 0.36, 1) | Fast then settling. Anything arriving: a panel opening, a toast appearing. |
| --fc-ease-in | cubic-bezier(0.4, 0, 1, 1) | Slow then away. Anything leaving, and only that — it is wrong for arrivals. |
| --fc-ease-linear | linear | Constant rate. Progress and spinners, where a curve would be a lie about the rate. |
The same four exist in JavaScript, generated from the same control points and tested against an independently computed reference. A Svelte transition and the CSS around it move on the same curve because they are the same numbers, not because someone matched them by eye.
Each duration token is calc(base × --fc-motion-scale). A component that uses a token responds to the scale without knowing the scale exists, and so does the layout's view-transition choreography.
What a person changes is their operating system setting, not this number. The four steps below are a development knob — somewhere to slow motion down and look at it — and the product ships at 1, or at 0 when the browser asks for reduced motion. They are listed because the code has them, not because anyone is offered them.
Zero is a zero-length transition, not a skipped one. It still starts and ends, so nothing has to branch on whether motion is enabled and anything waiting for the end still fires. A skipped transition would make every caller handle two shapes of the same event.
Svelte's transitions are the one thing that needs code for this: they run on the Web Animations API, which cannot read a CSS variable. They are wrapped so their durations are multiplied too, and a lint rule rejects importing them unwrapped — because an unwrapped transition type-checks, animates correctly at the default speed, and silently ignores every other setting.
Someone who has asked their operating system to reduce motion is saying something stronger than a preference, so prefers-reduced-motion: reduce overrides the in-app choice entirely — including an explicit Slow.
The CSS reset collapses every transition and animation under that query. The JavaScript scale returns zero under the same query, so Svelte's transitions and any one-shot element.animate() match, rather than being the one kind of motion that ignores it.
A component therefore does not need its own reduced-motion check around a transition. Using the tokens already covers it.
Reduced does not remove the transition. Each one still runs, still ends, and still tells whatever was waiting on it that it finished — it simply takes no time.
| Surface | How it scales |
|---|---|
| CSS transitions | Free, if it uses a duration token. |
| View transitions | Free. The layout choreography derives from the same scale. |
| Svelte transitions | Through the wrapped exports. |
| element.animate() | Multiply by the scale, var(--fc-ease-standard) with the exported curves. |
Continuous ambient motion is deliberately excluded: spinners, shimmers and the canvas loops. At Off they would freeze mid-cycle rather than stop gracefully, which reads as a hung interface rather than a calm one.
The rule is short. Scale one-shot motion. Leave loops alone.
This page documents what the product already did. The durations came from the codebase and were rounded onto a scale; the four curves replaced eleven beziers that had accumulated one component at a time. Nothing was designed here that was not already running.
These pages were the last thing not using it. They were written before the tokens existed and had drifted to thirty-one durations and nine curves between them — the same accumulation, one page at a time. They now use the tokens above, and the table below is what that collapse looked like.
| Was | Became | Count |
|---|---|---|
| 160ms, 180ms, 220ms | quick, base | 17 declarations |
| 260 – 350ms | moderate | 18 |
| 380 – 440ms | slow | 5 |
| 500 – 700ms | slower | 10 |
| ease, ease-in-out, .5,0,.5,1 | standard | 45 |
| seven decelerating beziers | out | 28 |
Two things are deliberately left alone. Delays are not tokenised: a stagger is a composition, not a length, and the nav's 45ms cascade is doing something the six steps are not for. And loops keep their own timings for the reason stated above — the icons scene turns in 80 seconds and the colour field drifts for eleven, and neither is a state change.
The film is the one remaining exception. Its JavaScript curves are choreography rather than interface, and the four product curves exist to make a button feel like the button next to it. A title sequence has no such problem.