Motion
outstandardlinearin

Arrival has a shape.

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.

01 — The scale

Six lengths

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.

instant100ms
quick150ms
base200ms
moderate300ms
slow400ms
slower500ms
TokenAt defaultWhen
--fc-duration-instant100msA state change you should not notice happening. Hover, focus, a checkbox.
--fc-duration-quick150msSomething small appearing or leaving in place. A tooltip, a menu item.
--fc-duration-base200msThe default. If you are unsure which to use, it is this one.
--fc-duration-moderate300msSomething with size to it. A drawer, a panel, an expanding row.
--fc-duration-slow400msA full-width or full-height move. Rare.
--fc-duration-slower500msOne 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.

02 — The curves

Four curves

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.

standard
out
in
linear
TokenValueWhen
--fc-ease-standardcubic-bezier(0.4, 0, 0.2, 1)Both ends eased. Anything that moves from one place on screen to another.
--fc-ease-outcubic-bezier(0.22, 1, 0.36, 1)Fast then settling. Anything arriving: a panel opening, a toast appearing.
--fc-ease-incubic-bezier(0.4, 0, 1, 1)Slow then away. Anything leaving, and only that — it is wrong for arrivals.
--fc-ease-linearlinearConstant 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.

03 — One knob

Every duration multiplies one number

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.

off×0Zero-length, not skipped. Where reduced motion lands.
fast×0.5Development only
default×1What ships
slow×2Development only — for looking at a transition properly

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.

04 — The browser wins

The system setting overrides the app's

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.

The same three transitions, both ways.
quick
base
slower

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.

SurfaceHow it scales
CSS transitionsFree, if it uses a duration token.
View transitionsFree. The layout choreography derives from the same scale.
Svelte transitionsThrough the wrapped exports.
element.animate()Multiply by the scale, var(--fc-ease-standard) with the exported curves.
05 — What is not scaled

Loops are left alone

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.

06 — What moves

What moves

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.

WasBecameCount
160ms, 180ms, 220msquick, base17 declarations
260 – 350msmoderate18
380 – 440msslow5
500 – 700msslower10
ease, ease-in-out, .5,0,.5,1standard45
seven decelerating beziersout28

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.