Design system · Motion layer

Motion

A small, durable motion system for the Design Manager. It borrows the rigor of a premium motion foundation — easing by job, duration by size, reduced-motion always — and applies a soft, buttery feel: every move decelerates to a gentle stop rather than snapping. Motion here is a quiet instrument that tells the rep what just happened and where it came from.

The law

Motion is information. Every transition must answer one of two questions — what just happened? or where did this come from? If it answers neither, it’s decoration, and decoration gets cut. This is Principle 6 (“restrained motion — a workbench, not a dashboard demo”) made operational.

Concretely: a status flips instead of hard-swapping, a filtered row glides to its new spot so you don’t lose it, a reply rises from the composer so you know where it came from. The feel is buttery — most motion lands in 210–560ms on a soft decelerating curve, so nothing snaps and nothing drags. The only looping motion in the whole tool is the single live “now” step. No spring bounce on data, no hero flights, no blurred scrims — just smooth and dependable.

M1

Motion is information

It explains what happened or where something came from. If it does neither, remove it. No motion for delight’s sake.

M2

Smooth, never abrupt

Motion glides to a stop on a soft decelerate — 150–560ms by size. Buttery, not bouncy; generous, but never so slow it makes the rep wait.

M3

Ease by job, never linear

out for entering, in for leaving, in-out for moving between two on-screen states. Linear reads as cheap.

M4

Animate the change, not the element

When a chip flips Draft→Published, tween the colour. Don’t fly the whole card around. The smallest honest move wins.

M5

Restraint scales

A list of 100 rows animating fully is chaos. Cap stagger, reveal once, never loop decoration. Density is the priority.

M6

Reduced motion always

One media query, honored on every surface. Skipping it is careless and makes people motion-sick. Non-negotiable.

M7

Containers settle, never snap

When a panel’s contents change count, the box glides to its new height and everything below reflows with it. A container that jumps size reads as broken. Resize is motion too.

01

Motion speed

One knob, three paces. Everything below ships at a buttery default we call Smooth — but some reps want it out of the way, and some want it unhurried. So the whole motion layer is a single front-end preference: Fast, Smooth, or Calm. It’s remembered, and it carries across the tool.

Speed is a time axis, not a redesign. The easing curves never change — every move still decelerates to the same soft, buttery stop. All a pace does is stretch or compress how long the move takes, so the character is constant from Fast to Calm; only the tempo moves.

Smooth is the anchor — the locked default. Fast collapses durations toward instant so motion just confirms the change and gets out of your way. Calm stretches them out for a quieter, more deliberate pace. Pick once; it sticks.

Now set to Smooth — content swap 280ms · tab / route 560ms · list settle 400ms · per-row stagger 55ms. Your choice is saved and applied everywhere — refresh, or open another page, and it holds.

content swap 120ms

Fast

Almost no motion — lightning-fast, motion just confirms & leaves.

default · content swap 280ms

Smooth

The anchor. Buttery, generous but never sluggish. The locked default.

content swap 460ms

Calm

Unhurried — generous glides, a quieter and more deliberate pace.

The three scales

Pick duration by how BIG the move is. Anchored on the approved 280ms card-hover decelerate. Fast and Calm paces rescale every duration; easing stays constant.

TokenFast Smooth CalmUse
--dur-instant70ms 150ms 240msToggle, single dot flip
--dur-fast90ms 210ms 340msHover colour, focus, small state change
--dur-base120ms 280ms 460msChip morph, content swap, card hover
--dur-slow170ms 400ms 660msList settle (FLIP), larger surfaces
--dur-morph210ms 520ms 840msState-morph commit (publish) — deliberate
--dur-tag260ms 700ms 1120msStatus-tag colour crossfade — slow, even reveal
--dur-page200ms 560ms 900msRoute / tab transitions, smooth scroll
--dur-curve300ms 760ms 1180msEasing-player dot — long enough to FEEL the curve
--dur-grow420ms 1100ms 1700msShared-surface growth — slow enough to track the edges from card to detail
--dur-roll720ms 1800ms 2900msValue count roll — long dramatic settle
--dur-scroll-min220ms 520ms 800msIn-page scroll, short hops
--dur-scroll-max420ms 1100ms 1700msIn-page scroll, long hauls
--stagger20ms 55ms 95msStagger step for lists/grids (cap the count)

Easing is identical across all three paces — the seven curves never move. Only time scales, so the buttery character is preserved end to end. Smooth is the bare default in the generated stylesheet — it adds nothing, so it can never drift from the spec.

One attribute, one source of truth

  • The selector writes data-motion="fast | smooth | calm" on <html>.
  • The registry-generated stylesheet swaps the duration scale for that attribute. Smooth is the bare :root default.
  • Every CSS transition already reads var(--dur-*), and the JS-driven motions (value roll, smooth scroll) read the same tokens live via readDurationMs — so both stay in lockstep, no second code path.

Why a pure time axis

  • Changing easing per pace would change the character of the tool — a different feel, not a different speed. The curves stay fixed so Fast and Calm are unmistakably the same system.
  • The button press scales too: its dip-and-glide timing tracks the pace, so a click never feels out of step with the rest.
  • It’s a preference, set once and remembered — not a per-action setting. Quiet, like everything else here.
02

Easing — by job

Pick easing by what the element is DOING. The curve says what kind of move it is: out for entering, in for leaving, in-out for moving between two on-screen states. All curves decelerate for the buttery glide, none are linear, and none change with pace. Click a runner to feel its curve.

--ease-standard cubic-bezier(0.32, 0.72, 0, 1)

Workhorse — smooth, soft landing

--ease-out cubic-bezier(0.22, 1, 0.36, 1)

ENTERING — buttery quint decelerate

--ease-in cubic-bezier(0.64, 0, 0.78, 0)

LEAVING — eases up, accelerates away

--ease-in-out cubic-bezier(0.76, 0, 0.24, 1)

MOVING between two on-screen states

--ease-settle cubic-bezier(0.34, 1.40, 0.64, 1)

RARE tiny overshoot — confirm / commit only

--ease-glide cubic-bezier(0.16, 1, 0.30, 1)

SIGNATURE long expo decelerate — press & state-morph commits

--ease-smooth cubic-bezier(0.45, 0, 0.55, 1)

EVEN symmetric — colour crossfades that reveal gradually

out fast then settles — entrances · in eases up then accelerates away — exits · in-out symmetric — things repositioning on screen · standard the workhorse · settle a 1.4 overshoot reserved only for a successful confirm/commit, never for routine data · glide the signature long expo decelerate — press release & state-morph commits · smooth even and symmetric — colour crossfades that reveal gradually. The runners ride --dur-curve, a doc-only token long enough to FEEL each curve.

03

Duration — by size

Pick duration by how BIG the move is — anchored on the approved 280ms card-hover decelerate. Feedback is near-instant; bigger surfaces get more time. Click a row to watch its speed, change the pace above, and replay: every runner reads the same tokens, so they visibly rescale.

Compare them back-to-back — all ride the same soft decelerate.

560ms is the ceiling, not a default. Three tokenised exceptions sit above it — --dur-tag (700ms, the slow even colour reveal), --dur-scroll-max (1100ms, long in-page scroll hauls) and --dur-roll (1800ms, the value roll’s dramatic settle) — each a deliberate long reveal of a change in place, never a layout move. --dur-curve exists only for this page’s easing player. Live values above are read with readDurationMs and update the moment the pace changes.

04

Behaviours

The registry of every shippable motion recipe. Recipes only ever combine the tokens above — easing by job, duration by size. Where a behaviour is interactive, the real thing is embedded: click to play.

--dur-fast--ease-glide--ease-standard

Tactile press

Buttons & interactive cards dip on press — committing an ACTION. Asymmetric: a quick dip DOWN so it lands within the brief click, then a slow buttery glide BACK on release — never the mid-click reversal that reads as jitter. The press is for actions only: choosing between options is never a press — selection slides (see the sliding tab indicator).

Press them — a quick 210ms-class dip down, then a slow buttery glide back. The dip timing tracks the pace.

--dur-morph--ease-glide

State morph

A status flip (Draft → Published) animates the change, never hard-swaps: width morph, tint crossfade, label crossfade and dot pulse choreographed across three beats.

← click to publish / reset

Width morphs on --dur-morph / --ease-glide while the tint and label crossfade — the change animates, the card never moves.

--dur-tag--ease-smooth

Status-tag crossfade

The chip is a COLOUR crossfade, so it rides the even symmetric curve over a long duration — a gradual reveal that never front-loads (which would read as an instant snap).

Draft

The flip is one state change — CSS on .km-chip rides --dur-tag (700ms) / --ease-smooth for the gradual colour reveal.

--dur-slow--ease-in-out--ease-out--ease-in

FLIP list settle

Filter transitions move surviving rows to their new homes (FLIP), with entering rows rising in and leaving rows accelerating away. Container height settles smoothly.

--dur-base--stagger--ease-out

Stagger + skeleton

Loading skeleton crossfades into content arriving as a quick cascade. Cap the stagger at 6 items — beyond that the tail feels broken, not choreographed.

Montréal — Plateau LOC-0421 Published
Laval — Centre LOC-0388 Draft
Québec — Sainte-Foy LOC-0356 Published
Gatineau — Hull LOC-0334 Awaiting review
Sherbrooke — Centre LOC-0302 Draft
Longueuil — Vieux LOC-0288 Published

Rows rise in via km-rise, 55ms apart, capped at 6 — beyond that the tail feels broken, not choreographed.

--dur-page--ease-in-out

Sliding tab indicator

The active-tab underline travels to the clicked tab — one marker moving between two resting states. The segmented control thumb rides the identical recipe, including the ink view-toggle (Table | Cards) where the BLACK portion glides between options. Selection SLIDES, press DIPS.

One marker slides & resizes over --dur-page (560ms) with --ease-in-out — not three borders blinking on/off. The Seg thumb above rides the identical recipe.

--dur-roll--ease-out

Value roll

A figure that changes counts to its new value — gentle launch, long settle. The roll lands the moment the remainder falls under half a digit, so the figure never looks stopped and then flips once more.

312 locations

Counts over --dur-roll (1800ms) read live with readDurationMs, on a long power-out so the last figures crawl to a restful stop. Tabular figures — nothing shifts width.

--dur-scroll-min--dur-scroll-max--ease-in-out

Smooth scroll

In-page travel is distance-scaled between the min and max scroll durations, easing in-out so the viewport glides rather than jumps.

--dur-base--ease-out--ease-settle

Dialog entrance

The scrim fades to its wash while the dialog rises (or zooms, or settles with the rare overshoot) — entering, so it decelerates in.

--dur-base--ease-out

Bubble entrance

Chat replies rise a few px and fade in (km-rise).

--ease-standard

Live step breathing

The live "now" step in a checklist breathes quietly so the eye finds it without it shouting.

--dur-base--ease-out

Card lift

On hover an interactive card lifts off the surface — scale 1→1.015, rise −3px, shadow deepens, border brightens. A physical object becoming active, never a bounce. PHANTOM-HOVER GUARD: a card resting under a stationary cursor (after a surface collapses) never lifts — hover is muted until the pointer actually moves, and the just-viewed card carries the lift instead.

--dur-base--ease-out--ease-in

Route transition

Views hand over like panels: the old page eases out (fade + 8px drop), the new one rises in. Rides the View Transitions API where available; cuts cleanly where not.

--dur-grow--ease-in-out

Shared layout morph

A card LITERALLY grows into its detail surface: one continuous element, FLIP-morphed from the small card’s exact footprint to the panel’s — the boundary is visible the whole way. Rides --dur-grow, the slowest UI move in the system; content fades in only after the surface is most of the way there.

--dur-fast--ease-standard

3D tilt card

The card tilts ±4.5° toward the cursor with a touch of scale and a hover lift in the shadow — depth on demand, now clearly perceptible. Strictly rationed: one hero per view, never in dense lists.

--dur-tag--ease-out

Meter fill with inertia

Progress fills from zero with a long ease-out instead of snapping, plus an optional whisper of sheen across the filled portion. The hero metric meter rides this on load.

--dur-base--stagger--ease-out--ease-smooth

Skeleton with depth

Loading reads calm and confident: layered skeleton surfaces with a very soft shimmer, cards arriving on the stagger cascade, real content crossfading in, numbers rolling after.

--dur-base--ease-out

Command palette spawn

The palette spawns as a layer: glass panel scales 0.96→1 and rises 8px while the ink wash fades in. The scrim stays SHARP — the system’s no-blurred-spotlight law holds; only the floating glass itself frosts.

--dur-page--ease-in-out

Drawer with parallax

The drawer slides in over the page while the page itself concedes the space — scaling to 0.985, shifting −12px, dimming a touch. Two real layers, honest depth, no blur.

--dur-morph--ease-settle

Status celebration

A tiny, contained reward on completion: the check draws itself, the badge settles in from 0.8 scale on the rare-overshoot curve, a soft tint halo fades. Never confetti.

--dur-fast--ease-standard

Cursor-follow glow

A barely-there radial wash tracks the pointer inside a premium surface — a glass-highlight core (derived from --glass-hi) blended into a faint brand tone, so the glow and the glass material read as one family. If you notice the glow before the content, it is too strong.

--dur-fast--dur-base--ease-standard--ease-settle

Icon micro-motions

Every glyph owns one signature move with one grammar: HOVER previews it (bell swings, refresh half-turns, trash leans, upload rises, the accordion caret nudges where it will go), the glyph’s own EVENT plays it in full (wiggle on arrival, spin while syncing, hop on send, caret turn on open). Custom accent per glyph, identical behaviour everywhere.

--dur-base--ease-out

Spatial modal

Opt-in depth for the dialog: as the glass arrives, the page behind concedes a breath of scale (0.98). The dialog reads as a layer above a physical surface, not a sticker on it.

--dur-roll--ease-in-out

Ambient empty state

An empty state breathes instead of staring: the glyph drifts on a slow visible loop and the watch-dot pulses with an expanding ring — alive at a glance, quiet enough to live on screen all day.

--dur-tag--dur-roll--ease-smooth

AI generation motion

Generating never shows a plain spinner: a soft brand-tone border sweep, a streaming text shimmer, a pulsing caret, and the button label morphing Generate → Thinking → Ready. Brand tones only — no rainbow gradients.

--dur-slow--ease-settle--ease-in-out

Snap into place

Dragged or displaced items settle into their slot on the rare-overshoot spring — released cards snap home, displaced neighbours glide aside via FLIP. Physical, decisive, brief.

05

What we don’t animate

Knowing what to leave still is what keeps the tool calm. These are good patterns in a consumer app and wrong here — included so the restraint is a decision, not an omission.

Spring overshoot on data

Bouncy springs are great for a draggable sheet; on a status chip or a row they make the tool feel toy-like and uncertain. Operational state lands flat. --ease-settle exists only for a committed confirm.

Hero image flights between routes

The “tap a card, watch it grow into the detail” move is lovely on a listings app. Here it draws attention to chrome instead of content, and slows a rep clearing twenty locations. Detail opens directly.

Full-page blur & dimmed spotlight scrims

Never frost or dim the whole page behind an overlay — it’s expensive, distracting, and off-tone for a workbench. The page stays sharp under a light wash. The floating surface itself may be translucent glass material — that’s contained material, not a page-wide spotlight.

Scroll-triggered reveals & parallax

This is a tool, not a landing page. Content is present when you scroll to it. Reveal-on-scroll belongs to design; here it just adds latency to reading a list.

Decorative loops

Exactly one thing loops: the live “now” step. Spinners, pulsing CTAs, shimmering banners — all banned. If something loops for attention, it had better be the single thing happening right now.

06

Reduced motion

Non-negotiable, and one media query. It ships in the component layer scoped to every product surface — animations collapse to an instant cut and nothing repositions, while the end state stays identical.

@media (prefers-reduced-motion: reduce) {
  .km *, .km *::before, .km *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
  • The shipped product gates on it. In the real Design Manager, JS-driven motion checks prefers-reduced-motion and resolves to its end state — lists rearrange instantly, values set without rolling, bubbles appear without rising.
  • This spec page is the exception. Because its entire job is to show motion, the demos play on an explicit click even when the OS setting is on — clicking is consent. The product itself stays strict.
  • End state is never sacrificed. Reduced motion removes movement, not meaning — the chip is still Published, the count is still right, the list is still sorted.
  • Test it. macOS: System Settings → Accessibility → Display → Reduce motion.

Fast is not “reduced motion”

An easy thing to conflate, and worth keeping straight — they answer different questions and both stay honored.

preference

Fast — a preference

A taste choice for people who want motion minimal and snappy. It’s still motion: a 90–200ms glide that confirms the change and leaves. The thumb still slides, the chip still morphs — just quickly.

accessibility

Reduced motion — an accessibility need

The OS-level prefers-reduced-motion setting, for people who get motion-sick. It always wins: the component layer collapses animation to an instant cut on every surface, regardless of the pace chosen here. Meaning is never removed — only movement.

Order of precedence: reduced-motion (accessibility) → then the chosen pace. A Fast user with reduced-motion on still gets instant cuts; the pace only governs how the system moves when it is allowed to move at all.

Motion layer · motion is information; restraint scales — and Fast / Smooth / Calm scales the tempo without touching the character. Generated from the registry: behaviours in registry/motion.ts, tokens in registry/tokens.ts.