fdb/docs

[§] /docs/rules/motion-tokens

Motion tokens

Four durations, four curves and one reduced-motion switch. Name them once in CSS and never type a raw millisecond value in a component again.

TL;DR

  • Four curves: --ease-out (the default), --ease-in-out, --ease-in (exits), linear.
  • Four durations. UI state changes stay at or under 400ms.
  • Never type a raw millisecond value in a component.
  • Springs for interruptible UI, with bounce: 0 for functional parts.
  • Reduced motion removes movement and stops loops.

Motion needs tokens for the same reason colour does. If every component picks its own timing, the interface feels slightly off everywhere and nobody can say why. Define a small set of durations and curves, name them by role, and make reduced motion a single switch.

The how-to on picking an ease explains how motion should feel. This page is the copyable part: the names, the values and the rules for using them.

The curves

ease-out

UI enters & responds

ease-in-out

moves across screen

ease-in

exits only

overshoot

playful, use rarely

Time runs left to right, progress bottom to top. A curve that rises fast early (ease-out) feels responsive, because the change is visible immediately.
The curves: table (4 rows)
TokenValueUse for
--ease-outcubic-bezier(0.22, 1, 0.36, 1)Anything entering or responding: menus, toasts, hover, press. The default.
--ease-in-outcubic-bezier(0.65, 0, 0.35, 1)Something already on screen moving to a new place: a drawer, a reordered card.
--ease-incubic-bezier(0.55, 0, 1, 0.45)Exits only, and only when the element leaves the screen entirely.
--ease-linearlinearContinuous motion: progress bars, spinners, marquees. Never for UI state changes.
  • MUST use --ease-out unless you can name the reason for a different curve.
  • MUST NOT use ease-in for anything the user triggered. It starts slowly, which reads as lag.
  • SHOULD NOT use the browser keyword ease. It is a mild ease-in-out and makes responses feel soft.
  • SHOULD keep overshoot curves such as cubic-bezier(0.34, 1.56, 0.64, 1) for one playful moment per page, not every button.

The durations

--dur-instant 100
--dur-fast 150
--dur-base 250
--dur-slow 400
The duration ladder, drawn to scale. Each bar is the time one token takes. Most UI lives in the first two rows.
The durations: table (4 rows)
TokenValueUse for
--dur-instant100msColour and opacity on hover, press feedback, checkbox ticks.
--dur-fast150msTooltips, small popovers, focus rings, toggles.
--dur-base250msMenus, dropdowns, toasts, tabs, accordions.
--dur-slow400msModals, drawers, sheets, full-section reveals.
  • MUST take every UI duration from these four tokens.
  • MUST keep UI state changes at or under 400ms. Longer than that and people wait for the interface.
  • SHOULD make exits faster than entrances. Use the next token down: a modal opens at --dur-slow and closes at --dur-base.
  • SHOULD scale duration with distance. A 12px nudge takes --dur-fast; a drawer crossing the screen takes --dur-slow.
  • SHOULD keep stagger steps between 30ms and 60ms, and cap the total stagger at about 300ms, however many items there are.

Copy this

Copy this: copy the css (34 lines)
:root {
  /* Durations */
  --dur-instant: 100ms;
  --dur-fast: 150ms;
  --dur-base: 250ms;
  --dur-slow: 400ms;

  /* Curves */
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --ease-in: cubic-bezier(0.55, 0, 1, 0.45);
  --ease-linear: linear;

  /* How far things travel when they enter */
  --motion-distance: 8px;
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-distance: 0px; /* nothing slides, things still fade */
  }
}

.menu {
  opacity: 0;
  transform: translateY(var(--motion-distance));
  transition:
    opacity var(--dur-base) var(--ease-out),
    transform var(--dur-base) var(--ease-out);
}
.menu[data-open] {
  opacity: 1;
  transform: none;
}

In Tailwind v4, put the same values in @theme so they become utilities. This site does exactly that with --ease-out-quint and --ease-in-out-cubic in app/global.css.

@theme {
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);   /* gives ease-out */
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* gives ease-in-out */
}
<div class="transition-opacity duration-150 ease-out">...</div>

For JavaScript (GSAP, Motion, the Web Animations API), mirror the tokens in one TypeScript file so the numbers never drift.

// lib/motion.ts
export const dur = { instant: 0.1, fast: 0.15, base: 0.25, slow: 0.4 } as const; // seconds
export const ease = {
  out: [0.22, 1, 0.36, 1],
  inOut: [0.65, 0, 0.35, 1],
  in: [0.55, 0, 1, 0.45],
} as const;

Naming

Naming: table (4 rows)
DoDon'tWhy
--dur-fast--dur-150The value can change without renaming every use.
--ease-out--ease-smoothName the shape, so people can predict it.
--dur-base--transitionOne token holds one value, not a shorthand.
dur.fast in JS0.15 inlineOne source of truth for CSS and scripts.
  • MUST prefix durations with --dur- and curves with --ease-.
  • MUST NOT add a fifth duration because one component "needs" 320ms. Pick the nearest token.

Springs

Spring or curve?

if the user drags, flicks or throws something

→ spring, it keeps their velocity

if a gesture can be interrupted mid-way

→ spring, it retargets smoothly

if a menu, modal or toast opens

→ curve token, predictable timing

if it must sync with other CSS

→ curve token, springs have no fixed end

Springs are for physical, interruptible motion. Everything else uses a duration and a curve.
  • SHOULD use springs for drag, swipe, sheets that follow a finger and layout animations that can be interrupted.
  • SHOULD set springs by perceived duration and bounce, not raw physics, so they stay comparable with the duration tokens.
  • MUST keep bounce at 0 for functional UI. Use 0.1 to 0.25 only where playfulness is the point.
import { motion } from 'motion/react';

// A sheet that follows the finger, then settles in about the time of --dur-base.
<motion.div
  drag="y"
  transition={{ type: 'spring', visualDuration: 0.25, bounce: 0 }}
/>

Reduced motion

A card that enters with motion, or with a fade when motion is reduced.
With reduced motion on, the card fades instead of flying. Same information, no vestibular cost.

prefers-reduced-motion: reduce does not mean "no feedback". It means "don't move things around". Keep state changes visible; remove the travel.

Reduced motion: table (6 rows)
NormalReduced
Slide and fade inFade in only (--motion-distance: 0px)
Parallax, scroll-linked movementStatic position
Looping background, shader, marqueeOne still frame, paused
Auto-playing videoPoster image, play button
Page transition that zoomsCross-fade at --dur-fast
Spring with bounceSame end state, no overshoot
  • MUST remove translation, scale, rotation and parallax under reduce.
  • MUST stop anything that loops or auto-plays. WCAG 2.2 (2.2.2 Pause, Stop, Hide) requires a way to stop any motion that starts automatically and lasts more than 5 seconds.
  • SHOULD keep opacity and colour transitions, because they still tell people something changed.
  • SHOULD read the setting in JS too, and listen for changes.
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
let reduce = mq.matches;
mq.addEventListener('change', (e) => (reduce = e.matches));
import { useReducedMotion } from 'motion/react';

const reduce = useReducedMotion();
const enter = reduce ? { opacity: 1 } : { opacity: 1, y: 0 };

A global kill switch like the one at the bottom of app/global.css is a safety net, not the design. It stops CSS animations everywhere, but it cannot pause a canvas loop or a GSAP timeline. Those need their own check.

Why it works

Four durations and four curves are few enough to remember and enough to cover every UI case. Naming them by role means a reviewer can spot ease-in on a button or 600ms on a tooltip at a glance. Making reduced motion a token (--motion-distance) rather than a separate code path means every component gets it for free.

Sources

2 sources

On this page