Motion and Interaction
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Overview
Motion is communication. Every animation MUST answer “what just happened?” or “what will happen?” Animation without purpose is visual noise.
Quick Reference
| Duration | Use Case | Token |
|---|---|---|
| 150ms | Micro-interactions (hover, focus) | --duration-fast |
| 250ms | Standard transitions | --duration-normal |
| 400ms | Complex animations | --duration-slow |
| 600ms | Page transitions | --duration-slower |
Motion Principles
1. Purpose Over Decoration
Every animation MUST serve a purpose:
- Feedback: Confirm an action occurred
- Orientation: Show where something came from or went
- Guidance: Direct attention to important changes
- Continuity: Maintain spatial relationships
/* ✓ DO: Animation provides feedback */
.button:active {
transform: scale(0.98); /* Confirms click */
}
/* ✗ DON'T: Animation is decorative */
.logo {
animation: bounce 2s infinite; /* No purpose */
}
2. Swift Over Slow
Users perceive animations >400ms as slow. Keep motion fast:
| Perception | Duration | Use For |
|---|---|---|
| Instant | <100ms | Cursor feedback |
| Fast | 100-200ms | Hovers, toggles |
| Moderate | 200-400ms | Reveals, transitions |
| Slow | >400ms | Special emphasis only |
3. Natural Over Mechanical
Use easing curves that mimic physical motion:
:root {
/* Avoid linear - feels robotic */
--ease-linear: linear;
/* Standard easing */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* Spring easing - natural bounce */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Emphasis - dramatic entrance */
--ease-emphasis: cubic-bezier(0.2, 0, 0, 1);
}
When to use each:
| Easing | Use Case |
|---|---|
ease-out | Elements entering (appear, expand) |
ease-in | Elements exiting (disappear, collapse) |
ease-in-out | Elements changing state |
ease-spring | Playful interactions, bouncy feedback |
4. Respect Over Imposition
Always respect user preferences:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Motion Tokens
Duration Tokens
:root {
--duration-instant: 50ms; /* Immediate feedback */
--duration-fast: 150ms; /* Micro-interactions */
--duration-normal: 250ms; /* Standard transitions */
--duration-slow: 400ms; /* Complex animations */
--duration-slower: 600ms; /* Page transitions */
}
Easing Tokens
:root {
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-bounce: cubic-bezier(0.34, 1.8, 0.64, 1);
--ease-emphasis: cubic-bezier(0.2, 0, 0, 1);
}
Common Animation Patterns
Fade
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.fade-in {
animation: fade-in var(--duration-normal) var(--ease-out);
}
Scale
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.scale-in {
animation: scale-in var(--duration-normal) var(--ease-spring);
}
Slide
@keyframes slide-in-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-down {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-in-up {
animation: slide-in-up var(--duration-normal) var(--ease-out);
}
Stagger
Animate list items sequentially:
.stagger-item {
animation: fade-in var(--duration-normal) var(--ease-out) backwards;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 50ms; }
.stagger-item:nth-child(3) { animation-delay: 100ms; }
.stagger-item:nth-child(4) { animation-delay: 150ms; }
.stagger-item:nth-child(5) { animation-delay: 200ms; }
Loading Spinner
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 0.6s linear infinite;
}
Skeleton Shimmer
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--surface-inset) 25%,
var(--surface-elevated) 50%,
var(--surface-inset) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
Pulse (Status Indicator)
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-active {
animation: pulse 2s ease-in-out infinite;
}
Interaction Feedback
Hover States
.card {
transition:
transform var(--duration-fast) var(--ease-spring),
box-shadow var(--duration-fast) var(--ease-out);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
Click/Active States
.button {
transition: transform var(--duration-fast) var(--ease-spring);
}
.button:active {
transform: scale(0.98);
}
Focus States
Focus indication MUST NOT be animated. The indicator MUST be painted in
the same frame the control takes focus, so no transition or animation may
target focus styles. Use an opaque :focus-visible outline of at least 2 CSS
pixels, offset from the control.
Why: SC 2.4.7 Focus Visible (Level AA) requires a visible keyboard focus indicator, and SC 2.4.13 Focus Appearance (Level AAA) sets the target adopted here: an area at least as large as a 2 CSS pixel perimeter of the unfocused control, at 3:1 contrast between the focused and unfocused states. Fading that indicator in delays the one signal keyboard and switch users navigate by.
/* ✓ DO: Opaque outline, painted the instant focus lands */
.input:focus-visible {
outline: 2px solid var(--interactive-default);
outline-offset: 2px;
}
/* ✗ DON'T: var() has no alpha modifier, so this halo never renders */
.input {
transition: box-shadow var(--duration-fast) var(--ease-out);
}
.input:focus {
box-shadow: 0 0 0 3px var(--interactive-default / 0.2); /* Parse error */
}
var() accepts a custom-property name and an optional comma-separated
fallback and nothing else, so / 0.2 is a parse error and the browser
discards the whole declaration
(CSS Custom Properties §3).
A translucent halo MUST be written with color-mix(), and MUST
supplement the opaque outline rather than replace it:
.input:focus-visible {
outline: 2px solid var(--interactive-default);
outline-offset: 2px;
box-shadow: 0 0 0 6px color-mix(in oklch, var(--interactive-default) 20%, transparent);
}
Both :focus-visible and
color-mix() are Baseline Widely
available, so neither needs a fallback.
Success Feedback
@keyframes success-check {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.success-icon {
animation: success-check var(--duration-slow) var(--ease-spring);
}
Error Feedback
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.error-shake {
animation: shake var(--duration-normal) var(--ease-out);
}
Page Transitions
Enter Transition
.page-enter {
animation: fade-in var(--duration-normal) var(--ease-out);
}
Exit Transition
.page-exit {
animation: fade-out var(--duration-fast) var(--ease-in);
}
Shared Element Transitions
For connected animations between pages, use the View Transitions API:
// Simple view transition
document.startViewTransition(() => {
updateDOM();
});
/* Define transition behavior */
::view-transition-old(root) {
animation: fade-out var(--duration-fast) var(--ease-in);
}
::view-transition-new(root) {
animation: fade-in var(--duration-normal) var(--ease-out);
}
Performance
GPU Acceleration
Only animate properties that trigger GPU acceleration:
| GPU Accelerated (Good) | Causes Layout/Paint (Avoid) |
|---|---|
transform | width, height |
opacity | margin, padding |
filter | top, left |
/* ✓ DO: GPU-accelerated */
.slide {
transform: translateX(100%);
}
/* ✗ DON'T: Triggers layout */
.slide {
left: 100%;
}
Will-Change
Use sparingly for complex animations:
.complex-animation {
will-change: transform, opacity;
}
/* Remove after animation completes */
.complex-animation.done {
will-change: auto;
}
Contain
Isolate animated elements:
.animated-container {
contain: layout paint;
}
Animation Playback Control
Pause animations when not visible:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const animation = entry.target.getAnimations()[0];
if (animation) {
entry.isIntersecting ? animation.play() : animation.pause();
}
});
});
document.querySelectorAll('.animated').forEach(el => observer.observe(el));
Testing Motion
Reduced Motion Testing
- Enable reduced motion in OS settings
- Verify animations are disabled or minimal
- Verify all functionality works without animation
macOS: System Preferences → Accessibility → Display → Reduce Motion
Windows: Settings → Ease of Access → Display → Show animations
Performance Testing
- Open DevTools → Performance
- Enable “CPU throttling” to simulate slower devices
- Record animation performance
- Look for dropped frames (should maintain 60fps)
Motion Sickness Testing
Have team members with motion sensitivity test:
- Large movements
- Parallax effects
- Continuous animations
- Screen transitions
Anti-Patterns
Animation for Animation’s Sake
/* ✗ DON'T: Decorative animation */
.logo {
animation: bounce 2s infinite;
}
Too Slow
/* ✗ DON'T: Feels sluggish */
.modal {
transition: opacity 800ms ease;
}
/* ✓ DO: Snappy */
.modal {
transition: opacity 200ms ease-out;
}
Linear Easing
/* ✗ DON'T: Robotic */
.card {
transition: transform 200ms linear;
}
/* ✓ DO: Natural */
.card {
transition: transform 200ms ease-out;
}
Animating Layout Properties
/* ✗ DON'T: Triggers layout recalculation */
.expand {
transition: height 300ms ease;
}
/* ✓ DO: Use transform */
.expand {
transition: transform 300ms ease;
transform-origin: top;
}
.expand.collapsed {
transform: scaleY(0);
}
Blocking Interactions
/* ✗ DON'T: Animation blocks interaction */
.button {
pointer-events: none;
animation: entrance 2s ease;
}
/* ✓ DO: Keep interactive */
.button {
animation: entrance 300ms ease;
}
Checklist
- All animations serve a purpose (feedback, orientation, guidance)
- Durations are appropriate (<400ms for most interactions)
- Easing feels natural (not linear)
-
prefers-reduced-motionis respected - Only transform/opacity animated for performance
- Animations tested at 60fps
- Complex animations use
will-change(removed after) - No animation blocks interactivity
See Also
- Design Systems - Motion tokens
- Components - Component animation patterns
- Accessibility - Reduced motion support
- CSS - CSS animation syntax