Motion
Short, ease-out and on purpose. Ten durations and a stagger, eight easings and two press scales, defined in brand.css and mirrored for JavaScript in src/lib/motion.ts. Every specimen below waits for you to play it.
Choosing a curve
Four questions decide almost every transition. Work down the list and stop at the first yes.
- Seen a hundred times a day, or started from the keyboard? Don’t animate it.
- Entering or leaving? ease-out-quart, enter at
--duration-enter, exit faster at--duration-exit. - Already on screen and moving? ease-out-layout at
--duration-move, or ease-in-out for a symmetric move. - Only colour or a hover fill? ease-out or ease at
--duration-fastto--duration-normal.
Durations
Each dot travels on the easing that duration is paired with in the app. Product UI stays at or under 300ms; the last three are for reveals and first paint.
| Example | Token | Usage |
|---|---|---|
| 90ms · Anything leaving: menus, popovers, toasts, chips. Exits are always faster than enters. | ||
| 100ms · Toggles and press feedback. | ||
| 140ms · Menus, popovers and chips appearing. | ||
| 150ms · Buttons and inputs: colour, ring and press. The most used duration in the app. | ||
| 200ms · Chips, tabs, colour changes, dialog scrim, a view cross-fading in. | ||
| 220ms · Layout and position shifts: a sliding tab indicator, a reflowing card, the sheet. | ||
| 300ms · Hover shadows. The ceiling for product UI. | ||
| 400ms · Icon and composer reveals only. | ||
| 480ms · Large transform moves across the screen. | ||
| 500ms · Staggered first-paint entrances only. | ||
| 80ms · The delay between staggered siblings, on first paint only. |
Easing
Every dot here takes the same 480ms (--duration-slide) so the curves can be compared side by side. In the app each easing keeps the duration in its usage note.
| Example | Token | Usage |
|---|---|---|
| cubic-bezier(0.165, 0.84, 0.44, 1) Buttons, inputs, quick enters. Pairs with 150ms. | ||
| cubic-bezier(0, 0, 0.2, 1) Chips, tiles, generic enter and exit. | ||
| cubic-bezier(0.23, 1, 0.32, 1) Layout and position shifts. Pairs with --duration-move. | ||
| cubic-bezier(0.22, 1, 0.36, 1) Large transform moves. Pairs with 480ms. | ||
| cubic-bezier(0.16, 1, 0.3, 1) Reveals and first-paint entrances (400 to 500ms). | ||
| cubic-bezier(0.4, 0, 0.2, 1) Something already on screen moving to a new place. | ||
| ease Hover and colour. | ||
| linear Constant motion only: spinners, the typing dots. | ||
| cubic-bezier(0.34, 1.56, 0.64, 1) The one overshoot: animate-card-enter and the landing thread pop. Not for product UI. |
Press and enter
Press feedback is a scale, not a colour flash. Things that appear start at 95% and fade in; they never grow from zero.
| Example | Token | Usage |
|---|---|---|
| 0.98 · :active on buttons and chips, under motion-safe. | ||
| 0.95 · :active on round icon buttons. A small target needs a deeper press to read. | ||
| 0.95 · The scale a menu or popover enters from. Never scale(0). | ||
| 16px · The enter-from rise for content arriving from below. |
In a real overlay
On a custom control
// Every ui/button already does this; reach for it only on a custom control.<div className="transition-[scale] duration-(--duration-fast) ease-out-quart motion-safe:active:scale-(--scale-press)" /> // Round icon buttons press deeper:motion-safe:active:scale-(--scale-press-icon)Springs
The app runs no physics springs. Every motion/react transition is a duration and a cubic-bezier from src/lib/motion.ts, so CSS and JS motion share one clock. The only overshoot is ease-spring, used by animate-card-enter and the landing thread’s pop.
| Example | Token | Usage |
|---|---|---|
| Rises 16px from 98% on --duration-slow with ease-spring. Marketing surfaces only. |
Keyframe animations
The named animations. The two loops run continuously because they do in the product; reduced motion stops them after one cycle.
| Example | Token | Usage |
|---|---|---|
| 1s linear, looping, 150ms apart. The agent is typing (composer status). | ||
| 1.2s pulse, looping. ui/skeleton while content loads. | ||
| An SVG arc drawing to its value on --duration-reveal, ease-out-expo. Set --ring-from and --ring-to. | ||
| Fade and a 4px rise on 300ms ease-out-expo. Signup chat turns arriving. |
In code
CSS reads the custom properties; motion/react reads the same values as numbers from src/lib/motion.ts. Change a value in one and change it in the other.
Tailwind
/* Durations and scales are plain custom properties: wrap the var. */className="transition-colors duration-(--duration-fast) ease-out-quart" /* Easings are Tailwind ease-* utilities directly. */className="ease-out-expo" /* Name the properties that move; never transition-all. */className="transition-[opacity,scale] duration-(--duration-enter)"motion/react
import { MotionConfig, motion } from "motion/react";import { DURATION, EASE, LAYOUT_TRANSITION } from "@/lib/motion"; // Seconds and arrays: motion takes numbers, not var()s.<MotionConfig reducedMotion="user"> <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, transition: { duration: DURATION.exit, ease: EASE.out } }} transition={{ duration: DURATION.enter, ease: EASE.outQuart }} /> <motion.span layoutId="tab-indicator" transition={LAYOUT_TRANSITION} /></MotionConfig>Reduced motion
brand.css ends with one rule that collapses every animation and transition to 0.01ms and one iteration when the visitor asks for reduced motion. Things still change state; they stop travelling.
- Put press scales behind
motion-safe:, asui/buttondoes. - Wrap a
motion/reacttree in<MotionConfig reducedMotion="user">, or branch onuseReducedMotion()where a transform carries meaning. - Looping artwork renders straight to its rest pose: no travel, no loop.
Best practices
When to animate
- Feedback for something the person just did: a press, an open, a close.
- Continuity when something moves or changes size, so the eye can follow it.
- Not the sidebar, not a command menu, not a keyboard shortcut: frequency kills delight.
Behaviour
- Exits are faster than enters (90ms against 140ms).
- Things that move together share one duration and one easing.
- Animate opacity, scale and translate. Name the properties that move, never all of them.
- Nothing in product UI past 300ms. Reveals and first-paint entrances are the exception.
Accessibility
- Every animation survives the reduced-motion rule and still shows its end state.
- Loops are status (typing, loading), never decoration.
Tokens
- Durations:
duration-(--duration-*); easings:ease-*; press:scale-(--scale-press). - No literal milliseconds or cubic-beziers in components; import
DURATIONandEASEin JS.