Skip to content

Foundations

Motion

Motion here is functional. It explains where a surface came from, confirms that a press registered, and connects one state to the next — and then it gets out of the way.

PrinciplesPermalink to this section

Six rules. They rule out most of what makes interfaces feel busy.

  • Animate transform and opacity

    Both are composited on the GPU. Animating width, height, top or margin forces layout on every frame and drops the animation to the main thread.

  • Movement is small

    Entrances travel 8–12px and scale from 97%. A surface that flies in from off-screen takes attention that belongs to the content.

  • Exits are faster than entrances

    Arriving deserves a moment; leaving does not. Entrances use 240ms and an ease-out curve, exits 150ms and an ease-in.

  • Nothing loops

    The only repeating animation in the system is a loading spinner, and it stops when the load does. Decorative perpetual motion is never justified.

  • Stagger is rare

    Sequencing helps someone parse a group they have not seen before. Applied to a list they scroll past every day, it is just delay.

  • Reduced motion removes movement

    Not shortens it. A 20ms slide is still a slide, and it can still trigger vestibular symptoms.

DurationsPermalink to this section

Five steps. If an interaction needs a duration that is not on this list, the interaction has not been thought through.

instant
90ms
fast
150ms
normal
240ms
slow
360ms
reveal
520ms
InteractionRangeTokenWhy
Hover120–180msduration-fastFast enough to feel attached to the cursor.
Pressed feedback80–120msduration-instantConfirms the press without delaying the result.
Dropdown and tooltip180–260msduration-normalLong enough to trace where the surface came from.
Dialog200–280msduration-normalPaired with an overlay fade so focus movement reads clearly.
Section reveal360–520msduration-slow / duration-revealOnly on first view, never on repeat scroll.

EasingPermalink to this section

Asymmetric curves. Things decelerate as they arrive and accelerate as they leave, which is what makes movement read as physical rather than mechanical.

TokenCurveUse for
--ease-standardcubic-bezier(0.22, 0, 0.12, 1)Default for hover, colour and border changes
--ease-entercubic-bezier(0.08, 0.72, 0.16, 1)Elements arriving on screen
--ease-exitcubic-bezier(0.3, 0, 0.8, 0.15)Elements leaving the screen
--ease-emphasizedcubic-bezier(0.2, 0, 0, 1)Larger, more deliberate movements
--ease-springcubic-bezier(0.34, 1.3, 0.64, 1)Slight overshoot — the switch thumb only

EntrancesPermalink to this section

Three ways something can arrive. Every one of them includes an opacity change, so the animation still reads when transforms are removed.

Fade

Opacity only. The safest entrance — it survives reduced motion unchanged.

Slide

Translate plus fade. The movement is short — 12px, not 60.

Scale

From 97%, never from zero. A surface should feel like it arrived, not inflated.

Sequencing and layoutPermalink to this section

Stagger, expansion and shared layout — the three cases where motion carries information rather than polish.

Stagger

60ms apart, and only where the sequence helps someone parse a new group.

Expand and collapse

Height is the one place the system animates layout — there is no transform equivalent.

Overlay enter and exit

Entrances are gentle and slower; exits are decisive and faster.

Shared layout transition

One indicator moves between positions instead of two fading past each other.

Height is the one exception

Expand and collapse animates height, which does force layout. There is no transform equivalent, and the alternative — an instant jump — is genuinely worse for comprehension. It is the only place the system accepts that cost.

Reduced motionPermalink to this section

Around one in three people report some sensitivity to motion. The setting is not a preference for less polish; it is an accessibility requirement.

prefers-reduced-motion: no-preference

Reduced-motion fallback

You have reduced motion off — the block fades and slides.

src/app/globals.css
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    /* Colour and opacity transitions are kept, so state
       changes stay perceivable rather than snapping. */
    transition-property: opacity, background-color, border-color, color !important;
    transition-duration: 0.01ms !important;
  }

  /* Movement lives in transforms — remove it rather than
     speed it up. A 20ms slide is still a slide. */
  [data-motion] {
    transform: none !important;
  }
}

Shortening is not reducing

The common mistake is to divide every duration by ten. The movement is still there, just faster — which for someone with vestibular sensitivity can be worse. Remove the transform, keep the fade.

TokensPermalink to this section

Motion tokens exist twice: as CSS custom properties for transitions, and as TypeScript constants for JavaScript-driven animation. Both come from the same numbers.

src/lib/motion.ts
// Seconds, for Motion for React
export const duration = {
  instant: 0.09,
  fast: 0.15,
  normal: 0.24,
  slow: 0.36,
  reveal: 0.52,
} as const;

export const easing = {
  standard: [0.22, 0, 0.12, 1],
  enter: [0.08, 0.72, 0.16, 1],
  exit: [0.3, 0, 0.8, 0.15],
  emphasized: [0.2, 0, 0, 1],
  spring: [0.34, 1.3, 0.64, 1],
} as const;