293 lines
9.6 KiB
Markdown
293 lines
9.6 KiB
Markdown
# Motion — Restraint, Intent, Feel
|
||
|
||
> Animation is feedback, not decoration. Every motion must communicate: state changed, content arrived, attention needed. If it doesn't communicate — remove it.
|
||
|
||
---
|
||
|
||
## The Three Questions
|
||
|
||
Before adding any animation, ask:
|
||
|
||
1. **What does this motion communicate?** ("The button is now active" / "This content is new" / "Loading finished")
|
||
2. **What happens if I remove it?** (Usually: nothing — and that's the test)
|
||
3. **Is it accessible?** (Does it respect `prefers-reduced-motion`?)
|
||
|
||
If you can't answer #1, delete it.
|
||
|
||
---
|
||
|
||
## Principles
|
||
|
||
### 1. Less motion, more meaning
|
||
A page with 12 different entrance animations feels unstable. A page with ONE entrance system feels considered.
|
||
|
||
**Pick one entrance system. Pick one hover system. Pick one page-transition pattern. Use them throughout.**
|
||
|
||
### 2. Easing is everything
|
||
- **`ease-out`** for things arriving (decals landing on screen, modals opening, content appearing)
|
||
- **`ease-in`** for things leaving (modals closing, content dismissed)
|
||
- **`ease-in-out`** for things that loop or oscillate
|
||
- **`linear`** for things that are continuous and infinite (rare — loading spinners)
|
||
- **Custom cubic-bezier** for character: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple-style, "expressive out") or `cubic-bezier(0.4, 0, 0.2, 1)` (Material standard)
|
||
|
||
**Avoid:** `ease` (default — the default is rarely the right answer for important moments).
|
||
|
||
### 3. Duration is the dial
|
||
Faster = more responsive. Slower = more dramatic.
|
||
|
||
| Type | Duration |
|
||
|---|---|
|
||
| Hover state change | 80–150ms |
|
||
| Button press | 60–100ms |
|
||
| Modal open | 150–250ms |
|
||
| Modal close | 100–200ms |
|
||
| Tooltip appear | 100–150ms |
|
||
| Content fade in | 200–400ms |
|
||
| Page transition | 250–500ms |
|
||
| Hero entrance | 500–800ms (one moment — not every section) |
|
||
| Skeleton shimmer loop | 1500ms |
|
||
| Marquee / infinite scroll | 30000–60000ms (very slow) |
|
||
|
||
**Rule of thumb:** the smaller the change, the faster the transition. The bigger the change, the longer it can take.
|
||
|
||
### 4. Distance is small
|
||
Things should move **a little**. A modal opening from `scale(0.9) → scale(1)` (10% growth) feels elegant. From `scale(0.5) → scale(1)` (50% growth) feels cartoonish.
|
||
|
||
**Default:** content shifts 4–12px. Modals scale 0.96–1. Cards lift 2–4px. Hover scale 1.02–1.05 max.
|
||
|
||
---
|
||
|
||
## Entrance Animations
|
||
|
||
### The system
|
||
Choose ONE entrance pattern. Apply to:
|
||
- Hero elements (on load)
|
||
- Content sections (on scroll into view)
|
||
- Modal/dialog content
|
||
- Toast notifications
|
||
|
||
**Options:**
|
||
|
||
**Fade** — most subtle, almost universal
|
||
```css
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
.fade-in {
|
||
animation: fadeIn 400ms ease-out both;
|
||
}
|
||
```
|
||
|
||
**Fade + rise** — slightly more dramatic, good for text
|
||
```css
|
||
@keyframes fadeRise {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
```
|
||
|
||
**Fade + slide** — for sidebar items, list items
|
||
```css
|
||
@keyframes fadeSlide {
|
||
from { opacity: 0; transform: translateX(-12px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
```
|
||
|
||
**Stagger** — for groups of items (lists, grids)
|
||
```css
|
||
.stagger-item { opacity: 0; animation: fadeRise 400ms ease-out forwards; }
|
||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||
.stagger-item:nth-child(2) { animation-delay: 60ms; }
|
||
.stagger-item:nth-child(3) { animation-delay: 120ms; }
|
||
.stagger-item:nth-child(4) { animation-delay: 180ms; }
|
||
/* etc — or use CSS variables for delay */
|
||
```
|
||
|
||
### Entrance anti-patterns
|
||
- ❌ Every section animating in as you scroll (exhausting)
|
||
- ❌ Long durations (1s+) for routine content
|
||
- ❌ Bouncy easing (`cubic-bezier(0.68, -0.55, 0.265, 1.55)`) on serious interfaces
|
||
- ❌ Slide-in from random directions (left, right, top, bottom — pick one)
|
||
- ❌ Different animation types per section (no system)
|
||
|
||
---
|
||
|
||
## Hover Animations
|
||
|
||
### The system
|
||
Pick a hover pattern. Apply to all interactive elements of a kind.
|
||
|
||
**Buttons**
|
||
- Background color change: 120ms
|
||
- Optional: subtle scale `transform: scale(1.02)` — only on prominent CTAs
|
||
|
||
**Cards**
|
||
- Border color strengthens OR background tints slightly OR 2px lift via translateY
|
||
- Pick ONE. Don't combine.
|
||
|
||
**Links**
|
||
- Underline grows from left (preferred) OR color change
|
||
- 150ms
|
||
|
||
**Icons**
|
||
- Slight rotate (5–10deg) OR slight scale (1.1)
|
||
- Pick the right direction for the meaning (e.g., arrow rotates forward, chevron rotates down)
|
||
|
||
### Hover anti-patterns
|
||
- ❌ `transform: scale(1.1)` on every hover — feels unstable
|
||
- ❌ Color shifts that don't match the palette (random bright colors)
|
||
- ❌ Multiple property changes at once (color + size + shadow + rotate)
|
||
- ❌ Long durations on hover (anything > 200ms feels laggy)
|
||
|
||
---
|
||
|
||
## Scroll-triggered Animations
|
||
|
||
**Default:** don't animate on scroll. Content appears when it appears.
|
||
|
||
**When scroll animations ARE appropriate:**
|
||
- Long-form editorial pages (sections reveal as you read)
|
||
- Image galleries (lazy reveal as you scroll)
|
||
- Data visualizations (animate in as they enter viewport)
|
||
- Storytelling / product tours
|
||
|
||
**How to do it well:**
|
||
- Trigger once (`IntersectionObserver`, threshold 0.1–0.2)
|
||
- Animation is subtle (fade + small rise)
|
||
- Don't replay on scroll back
|
||
- Provide a no-JS fallback (content visible by default)
|
||
|
||
```js
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.classList.add('in-view');
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { threshold: 0.15 });
|
||
|
||
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
|
||
```
|
||
|
||
```css
|
||
.reveal {
|
||
opacity: 0;
|
||
transform: translateY(12px);
|
||
transition: opacity 600ms ease-out, transform 600ms ease-out;
|
||
}
|
||
.reveal.in-view {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
```
|
||
|
||
### Scroll animation anti-patterns
|
||
- ❌ Parallax everywhere (rarely adds value)
|
||
- ❌ Horizontal scroll as the only way to navigate a section
|
||
- ❌ Content invisible until JS loads (broken without JS)
|
||
- ❌ Replaying animations on every scroll back through
|
||
- ❌ "Scroll to discover" with no clear signal of what comes next
|
||
|
||
---
|
||
|
||
## Page Transitions
|
||
|
||
For SPAs and multi-page sites with shared chrome.
|
||
|
||
**The rule:** fast, consistent, and barely noticeable.
|
||
|
||
- **Fade transition:** 200ms cross-fade between pages
|
||
- **Slide (subtle):** outgoing content slides 20px left, incoming slides in from right — only if the navigation is forward/back in a clear sequence
|
||
- **Duration:** 200–300ms max
|
||
|
||
**Avoid:**
|
||
- ❌ Heavy transitions that delay content (users notice delay as "broken")
|
||
- ❌ Different transition styles for different navigation actions
|
||
- ❌ Animated logos or brand marks on every page load
|
||
|
||
---
|
||
|
||
## Micro-interactions Worth Their Weight
|
||
|
||
- **Toggle switches** — smooth slide with color change
|
||
- **Checkbox check** — satisfying tick animation
|
||
- **Drag handles** — feedback as user drags
|
||
- **Form validation** — color change + small shake on error (subtle, not aggressive)
|
||
- **Toast notifications** — slide in from corner, auto-dismiss with progress bar
|
||
- **Number counters** — counting up to value (data viz, hero stats)
|
||
- **Progress bars** — width transitions smoothly, not jumps
|
||
- **Loading completion** — content fades in smoothly, skeleton → real
|
||
|
||
---
|
||
|
||
## Performance
|
||
|
||
### Animate these properties (cheap, GPU-accelerated):
|
||
- `transform` (translate, scale, rotate)
|
||
- `opacity`
|
||
|
||
### Avoid animating these (expensive, layout-thrashing):
|
||
- `width`, `height`
|
||
- `top`, `left`, `right`, `bottom`
|
||
- `margin`, `padding`
|
||
- `border-width`
|
||
- `box-shadow` (acceptable for small elements; expensive for large)
|
||
|
||
### Tips
|
||
- Use `will-change: transform` sparingly (only on elements about to animate)
|
||
- Use `transform: translateZ(0)` or `will-change` to promote to GPU layer
|
||
- Use `requestAnimationFrame` for JS animations
|
||
- For long-running animations, use `transform` and `opacity` only
|
||
|
||
---
|
||
|
||
## Accessibility — `prefers-reduced-motion`
|
||
|
||
**Required.** Some users get nauseous, dizzy, or worse from motion. Respect their setting.
|
||
|
||
```css
|
||
@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;
|
||
}
|
||
}
|
||
```
|
||
|
||
Or be more selective — only kill the heavy stuff:
|
||
|
||
```css
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.reveal { opacity: 1; transform: none; transition: none; }
|
||
.parallax { transform: none !important; }
|
||
}
|
||
```
|
||
|
||
**Always test:** toggle "Reduce motion" in your OS settings. Visit the site. Does it still work? Is content still visible?
|
||
|
||
---
|
||
|
||
## Motion Anti-Patterns
|
||
|
||
| ❌ Don't | ✅ Do |
|
||
|---|---|
|
||
| Animate every section on scroll | Animate selectively, or none |
|
||
| `transition: all` on every element | Specify which properties animate |
|
||
| Bouncy spring physics on every UI | Most UI should be linear-feeling ease-out |
|
||
| Parallax on every image | Parallax only when it adds to the narrative |
|
||
| Long page transitions (>400ms) | Keep page transitions under 300ms |
|
||
| `infinite` animations | Animations should have a clear end |
|
||
| Hover scale of 1.1+ | Subtle scale (1.02–1.05) if any |
|
||
| Different motion styles in different sections | One system, applied consistently |
|
||
| Animations that require JS to see content | Content visible by default; animations enhance |
|
||
| Skipping `prefers-reduced-motion` | Always honor it |
|
||
| Animated gradient backgrounds | Static backgrounds or no backgrounds |
|
||
| Marquee text scrolling fast | Slow, considered (or don't) |
|
||
| Number counting from 0 with 4s duration | Count quickly (1–2s) or show the final value |
|
||
| Loading spinner that takes 30s | Show progress, not just a spinner |
|
||
| Toast that bounces in | Slide in, fade out |
|