Skip to content
HyperDesign System

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.

  1. Seen a hundred times a day, or started from the keyboard? Don’t animate it.
  2. Entering or leaving? ease-out-quart, enter at --duration-enter, exit faster at --duration-exit.
  3. Already on screen and moving? ease-out-layout at --duration-move, or ease-in-out for a symmetric move.
  4. Only colour or a hover fill? ease-out or ease at --duration-fast to --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.

Durations
ExampleTokenUsage
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.

Easings
ExampleTokenUsage
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.

Interaction values
ExampleTokenUsage
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.

Overshoot
ExampleTokenUsage
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.

Keyframe animations
ExampleTokenUsage
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:, as ui/button does.
  • Wrap a motion/react tree in <MotionConfig reducedMotion="user">, or branch on useReducedMotion() 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 DURATION and EASE in JS.