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 usesexit, changing in place usesstandard. Never uselinearor the browser defaulteasefor UI. - Keep UI transitions at 300ms or under. Anything longer is a demo, not chrome.
- Animate
opacityandtransformby default. Animate width/height/grid rows only when content really reflows (expanding a field), and only withstandard. - 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 …, nevertransition: 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-onlysentence, and mark the animated stagearia-hidden. - Wait for fonts (
document.fonts.ready) before measuring text for animations.