Skip to content

Interaction and motion

Motion is functional: it shows where something came from, where it went, or what changed. It is short, eased and calm. Exits are always faster than entrances.

Tokens and when to use them

Token Value For
--motion-duration-fast 150ms hover color, focus, tooltip/caption fade
--motion-duration-moderate 200ms toggles, menu icons, arrow nudges, control state
--motion-duration-page 280ms page/view/sheet enter
--motion-duration-page-exit 180ms page/view exit
--motion-duration-reveal 450ms image or media fading in once loaded
--motion-ease-standard (0.4, 0, 0.2, 1) element changes in place (color, opacity, size)
--motion-ease-enter (0.22, 1, 0.36, 1) arriving: decelerate into place, plus nudges
--motion-ease-exit (0.4, 0, 1, 1) leaving: accelerate away
--motion-ease-spring (0.34, 1.56, 0.64, 1) small overshoot. Rare, playful details only

Rules:

  • Pair easing with direction. Entering uses enter, leaving uses exit, changing in place uses standard. Never use linear or the browser default ease for UI.
  • Keep UI transitions at 300ms or under. Anything longer is a demo, not chrome.
  • Animate opacity and transform by default. Animate width/height/grid rows only when content really reflows (expanding a field), and only with standard.
  • Distances are small: 3–4px nudges, 12px sheet rise, 4vw page slide. Nothing flies across the screen.
  • Use one property list per element. Write transition: color 150ms …, transform 200ms …, never transition: all.

Signature patterns

Hover = color shift. Idle links and nav are secondary and go to primary on hover, at 150ms standard. No underline, no background, no scale. (Inline links in prose may underline.)

Arrow nudge. Linked rows end with → (internal) or ↗ (external) in secondary. On hover or focus the arrow turns primary and moves: translateX(3px) for →, translate(1px, -1px) for ↗, at 200ms enter. The row itself doesn't change.

Rising caption. Tile captions are pills that start at opacity 0 and 4px low, then rise and fade in on hover and :focus-within (opacity uses standard, transform uses enter). On touch or narrow screens they're always visible.

Direction-aware page transitions. With the View Transitions API (or your router's equivalent), the outgoing view slides 4vw and fades out over 180ms with exit easing. The incoming view slides in from the opposite side over 280ms with enter easing, in nav order (going right in the nav means content moves left). Persistent chrome (the navbar) doesn't animate.

Sheet rise. Floating panels enter with opacity 0 → 1 and translateY(12px) → 0 over 280ms, enter easing.

Morphing toggle. The hamburger's three bars rotate into an X over 200ms standard. Prefer morphing an icon over swapping it.

Count reels. When a number changes, scroll a column of digits (translateY by one row) instead of crossfading, so digits never overlap.

Interaction rules

  • Focus is always visible: a 2px solid primary-color outline, offset 2px. Use 3px/3px on large tiles and 4px offset on full-width rows. Use :focus-visible, not :focus.
  • Touch targets: 44×44px minimum under 768px (icon buttons, menu links, footer links).
  • Hover is never the only path. Anything revealed on hover is also revealed on focus and shown by default on touch.
  • Whole-surface links only when the surface has no other controls. If a tile has interactive content, make its caption the link.
  • Clicking a link to the page you're on does nothing. No reload, no transition.
  • Mobile menu: a full-screen panel below the nav that locks body scroll and closes when you pick a link.
  • External links open in a new tab with rel="noopener noreferrer" and show ↗.
  • Fields: the border strengthens and a 3px soft halo appears on :focus-within. Errors are short, in danger text, under the field.

Reduced motion and looping demos

  • Respect prefers-reduced-motion: reduce. Collapse durations to about 1ms (tokens.css does this globally) and remove looping or transform-heavy animation, showing a meaningful static end state instead.
  • Looping animations (demos, hero text rotators) must pause when off-screen (IntersectionObserver), when the tab is hidden, and under reduced motion. If a loop runs longer than 5s, give it a pause/play icon button.
  • Describe animated demos for screen readers with an sr-only sentence, and mark the animated stage aria-hidden.
  • Wait for fonts (document.fonts.ready) before measuring text for animations.