@ctxr/skill-frontend-excellence 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/SKILL.md +227 -0
- package/package.json +63 -0
- package/references/accessibility.md +396 -0
- package/references/audit-workflow.md +390 -0
- package/references/components.md +247 -0
- package/references/data-viz.md +457 -0
- package/references/defects.md +152 -0
- package/references/design.md +513 -0
- package/references/forms.md +485 -0
- package/references/lighthouse.md +242 -0
- package/references/motion.md +642 -0
- package/references/performance.md +416 -0
- package/references/pre-launch.md +342 -0
- package/references/responsive.md +519 -0
- package/references/seo.md +422 -0
- package/references/ui-ux.md +565 -0
- package/scripts/check-no-dashes.mjs +90 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
# Motion and Animation
|
|
2
|
+
|
|
3
|
+
Framework-agnostic principles for motion that conveys meaning, performs at 60fps, and respects user preferences.
|
|
4
|
+
|
|
5
|
+
## Why Motion
|
|
6
|
+
|
|
7
|
+
Motion is a tool, not decoration. It exists to:
|
|
8
|
+
|
|
9
|
+
1. **Communicate cause and effect.** A button press triggers a state change; the motion confirms it.
|
|
10
|
+
2. **Maintain spatial continuity.** Where did this element come from? Where does it go?
|
|
11
|
+
3. **Direct attention.** Animate what the user should look at next.
|
|
12
|
+
4. **Express brand personality.** Spring bounces feel playful; linear fades feel refined.
|
|
13
|
+
|
|
14
|
+
If a motion doesn't serve one of these, remove it.
|
|
15
|
+
|
|
16
|
+
## The Four Properties of UI Motion
|
|
17
|
+
|
|
18
|
+
| Property | Default | Range |
|
|
19
|
+
|----------|--------|-------|
|
|
20
|
+
| Duration | 200-250ms (UI), 300-400ms (modal/page transition) | 100-500ms; rarely above 500ms in UI |
|
|
21
|
+
| Easing | `ease-out` for entering, `ease-in` for exiting | Linear forbidden in UI |
|
|
22
|
+
| Trigger | User action or state change | Time alone (idle) is decorative |
|
|
23
|
+
| Interruptibility | Always interruptible | Never block input |
|
|
24
|
+
|
|
25
|
+
## Duration
|
|
26
|
+
|
|
27
|
+
| Interaction | Duration |
|
|
28
|
+
|-------------|----------|
|
|
29
|
+
| Hover, focus, press feedback | 100-150ms |
|
|
30
|
+
| Toggle, switch | 150-200ms |
|
|
31
|
+
| Disclosure, accordion | 200-300ms |
|
|
32
|
+
| Tab change, segmented control | 150-250ms |
|
|
33
|
+
| Modal/sheet entrance | 250-350ms |
|
|
34
|
+
| Modal/sheet exit | 200-250ms (faster than entrance) |
|
|
35
|
+
| Page/route transition | 300-400ms |
|
|
36
|
+
| Onboarding sequence | 400-600ms per beat |
|
|
37
|
+
| Skeleton shimmer | 1500ms loop, low contrast |
|
|
38
|
+
|
|
39
|
+
Above 500ms feels slow in UI. Reserve for one-off cinematic moments.
|
|
40
|
+
|
|
41
|
+
Exit motions should be 60-70% of entrance duration. The user already saw the content arrive; getting it out of the way faster feels responsive.
|
|
42
|
+
|
|
43
|
+
## Easing
|
|
44
|
+
|
|
45
|
+
Easing curves give motion personality.
|
|
46
|
+
|
|
47
|
+
| Curve | Use |
|
|
48
|
+
|-------|-----|
|
|
49
|
+
| `ease-out` (`cubic-bezier(0, 0, 0.2, 1)`) | Entering. Element decelerates as it arrives. |
|
|
50
|
+
| `ease-in` (`cubic-bezier(0.4, 0, 1, 1)`) | Exiting. Element accelerates as it leaves. |
|
|
51
|
+
| `ease-in-out` (`cubic-bezier(0.4, 0, 0.2, 1)`) | State change in place. Symmetric. |
|
|
52
|
+
| `ease` (`cubic-bezier(0.25, 0.1, 0.25, 1)`) | Default; usable but generic. |
|
|
53
|
+
| Spring | Playful or characterful. Use a physics-based library. |
|
|
54
|
+
| `linear` | NEVER for UI motion. Reserved for continuous loops (loading spinners) and progress bars. |
|
|
55
|
+
|
|
56
|
+
Material Design 3 standard curves (consider these defaults):
|
|
57
|
+
|
|
58
|
+
- Standard: `cubic-bezier(0.2, 0, 0, 1)`
|
|
59
|
+
- Decelerated (entering): `cubic-bezier(0, 0, 0, 1)`
|
|
60
|
+
- Accelerated (exiting): `cubic-bezier(0.3, 0, 1, 1)`
|
|
61
|
+
- Emphasized: `cubic-bezier(0.2, 0, 0, 1)` (longer durations, more dramatic)
|
|
62
|
+
|
|
63
|
+
Apple-style spring curves (CSS):
|
|
64
|
+
|
|
65
|
+
- Soft snap: `cubic-bezier(0.34, 1.56, 0.64, 1)` (slight overshoot, soft return)
|
|
66
|
+
- Quick snap: `cubic-bezier(0.4, 1.5, 0.5, 1)` (quicker overshoot)
|
|
67
|
+
- Reduced motion: `cubic-bezier(0.4, 0, 0.2, 1)` (no overshoot)
|
|
68
|
+
|
|
69
|
+
For genuinely physics-based motion, use a library (Motion / Framer Motion, React Spring, GSAP). Spring physics with stiffness, damping, mass produces natural-feeling motion that handcrafted bezier curves rarely match.
|
|
70
|
+
|
|
71
|
+
## What to Animate (and what NOT to animate)
|
|
72
|
+
|
|
73
|
+
### Animate
|
|
74
|
+
|
|
75
|
+
- `transform` (translate, rotate, scale)
|
|
76
|
+
- `opacity`
|
|
77
|
+
- `filter` (sparingly)
|
|
78
|
+
- `clip-path` (modern browsers)
|
|
79
|
+
|
|
80
|
+
These compositor-only properties run on the GPU and don't trigger layout or paint. They hold 60fps.
|
|
81
|
+
|
|
82
|
+
### NEVER animate
|
|
83
|
+
|
|
84
|
+
- `width`, `height`, `top`, `left`, `right`, `bottom`, `margin`, `padding`, `font-size`
|
|
85
|
+
- `box-shadow` size (only opacity is cheap)
|
|
86
|
+
- Anything that changes layout for surrounding elements
|
|
87
|
+
|
|
88
|
+
These trigger layout, then paint, then composite. Each is expensive. Animating them at 60fps is impossible on most devices.
|
|
89
|
+
|
|
90
|
+
For "expanding" or "collapsing" elements, use `transform: scale()` plus `clip-path`, or use the `view-transition` API for layout transitions.
|
|
91
|
+
|
|
92
|
+
### When you must animate layout
|
|
93
|
+
|
|
94
|
+
Use FLIP (First, Last, Invert, Play):
|
|
95
|
+
|
|
96
|
+
1. Measure the element's First position.
|
|
97
|
+
2. Apply the change.
|
|
98
|
+
3. Measure the Last position.
|
|
99
|
+
4. Compute the Invert transform.
|
|
100
|
+
5. Animate transform from inverted to identity (Play).
|
|
101
|
+
|
|
102
|
+
Libraries like Motion's layout animations and React's `useLayoutEffect` patterns implement this for you.
|
|
103
|
+
|
|
104
|
+
For full route or DOM transitions, the new `view-transition` API is the right tool.
|
|
105
|
+
|
|
106
|
+
## Reduced Motion
|
|
107
|
+
|
|
108
|
+
Always respect `prefers-reduced-motion: reduce`. The "global stomp" pattern is the floor:
|
|
109
|
+
|
|
110
|
+
```css
|
|
111
|
+
@media (prefers-reduced-motion: reduce) {
|
|
112
|
+
*, *::before, *::after {
|
|
113
|
+
animation-duration: 0.01ms !important;
|
|
114
|
+
animation-iteration-count: 1 !important;
|
|
115
|
+
transition-duration: 0.01ms !important;
|
|
116
|
+
scroll-behavior: auto !important;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Better practice: tier your animations.
|
|
122
|
+
|
|
123
|
+
- **Essential** (a loading spinner conveying activity, a progress bar showing percent): keep, even with reduced motion.
|
|
124
|
+
- **Helpful** (a slide-in modal, a fade-in card): replace with instant transitions.
|
|
125
|
+
- **Decorative** (parallax, scroll-triggered reveals, hero text staggered animations): remove entirely.
|
|
126
|
+
|
|
127
|
+
```css
|
|
128
|
+
.hero-letters span {
|
|
129
|
+
opacity: 0;
|
|
130
|
+
transform: translateY(20px);
|
|
131
|
+
animation: rise 600ms ease-out forwards;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@media (prefers-reduced-motion: reduce) {
|
|
135
|
+
.hero-letters span {
|
|
136
|
+
opacity: 1;
|
|
137
|
+
transform: none;
|
|
138
|
+
animation: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
For JS animations, check the user preference:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
147
|
+
if (!prefersReduced) {
|
|
148
|
+
// Run the animation
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Choreography
|
|
153
|
+
|
|
154
|
+
When multiple elements animate, choreograph them. Don't let everything animate at once.
|
|
155
|
+
|
|
156
|
+
### Stagger
|
|
157
|
+
|
|
158
|
+
For lists or grids, stagger entries by 30-50ms. The user sees a sequence rather than a flash.
|
|
159
|
+
|
|
160
|
+
```css
|
|
161
|
+
.list > * {
|
|
162
|
+
animation: rise 400ms ease-out backwards;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.list > *:nth-child(1) { animation-delay: 0ms; }
|
|
166
|
+
.list > *:nth-child(2) { animation-delay: 50ms; }
|
|
167
|
+
.list > *:nth-child(3) { animation-delay: 100ms; }
|
|
168
|
+
/* etc */
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Or programmatically:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
items.forEach((item, i) => {
|
|
175
|
+
item.style.animationDelay = `${i * 50}ms`;
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Cap stagger at 8-10 items so the last item isn't obviously waiting.
|
|
180
|
+
|
|
181
|
+
### Sequence
|
|
182
|
+
|
|
183
|
+
For a hero load: heading first, then sub-heading, then CTAs, then image. Each beat 100-200ms after the previous start.
|
|
184
|
+
|
|
185
|
+
### Co-ordinated entry/exit
|
|
186
|
+
|
|
187
|
+
Modal: backdrop fades in (200ms), then dialog scales up + fades in (250ms). On exit: dialog scales down + fades out (180ms), then backdrop fades out (150ms).
|
|
188
|
+
|
|
189
|
+
## Scroll-Triggered Motion
|
|
190
|
+
|
|
191
|
+
### CSS-only (preferred)
|
|
192
|
+
|
|
193
|
+
Modern CSS supports scroll-driven animations:
|
|
194
|
+
|
|
195
|
+
```css
|
|
196
|
+
.fade-in-on-scroll {
|
|
197
|
+
opacity: 0;
|
|
198
|
+
transform: translateY(20px);
|
|
199
|
+
animation: rise 600ms ease-out forwards;
|
|
200
|
+
animation-timeline: view();
|
|
201
|
+
animation-range: entry 0% cover 30%;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@keyframes rise {
|
|
205
|
+
to { opacity: 1; transform: translateY(0); }
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Browser support is improving. With `IntersectionObserver` as fallback, this covers most cases.
|
|
210
|
+
|
|
211
|
+
### Intersection Observer (universal)
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
const observer = new IntersectionObserver((entries) => {
|
|
215
|
+
entries.forEach((entry) => {
|
|
216
|
+
if (entry.isIntersecting) {
|
|
217
|
+
entry.target.classList.add('in-view');
|
|
218
|
+
observer.unobserve(entry.target);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}, { threshold: 0.2 });
|
|
222
|
+
|
|
223
|
+
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Then in CSS:
|
|
227
|
+
|
|
228
|
+
```css
|
|
229
|
+
.reveal {
|
|
230
|
+
opacity: 0;
|
|
231
|
+
transform: translateY(20px);
|
|
232
|
+
transition: opacity 600ms ease-out, transform 600ms ease-out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.reveal.in-view {
|
|
236
|
+
opacity: 1;
|
|
237
|
+
transform: translateY(0);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Anti-patterns
|
|
242
|
+
|
|
243
|
+
- Every section animates as you scroll. The page feels like it's still loading when you reach the bottom.
|
|
244
|
+
- Parallax on every section. Disorienting. Often hurts CLS.
|
|
245
|
+
- Long animations triggered by scroll. The user has already moved past the trigger point.
|
|
246
|
+
|
|
247
|
+
Animate selectively. One or two key sections per page.
|
|
248
|
+
|
|
249
|
+
## Page Transitions
|
|
250
|
+
|
|
251
|
+
For SPA route changes:
|
|
252
|
+
|
|
253
|
+
```css
|
|
254
|
+
::view-transition-old(root),
|
|
255
|
+
::view-transition-new(root) {
|
|
256
|
+
animation-duration: 200ms;
|
|
257
|
+
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
::view-transition-old(root) {
|
|
261
|
+
animation-name: fade-out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
::view-transition-new(root) {
|
|
265
|
+
animation-name: fade-in;
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
For shared element transitions (e.g., a card in a list expands into a detail view):
|
|
270
|
+
|
|
271
|
+
```css
|
|
272
|
+
.card-image {
|
|
273
|
+
view-transition-name: card-image;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.detail-image {
|
|
277
|
+
view-transition-name: card-image;
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The browser automatically morphs between them.
|
|
282
|
+
|
|
283
|
+
For frameworks without `view-transition` support, libraries like Motion provide layout animations.
|
|
284
|
+
|
|
285
|
+
## Loading and Progress Indicators
|
|
286
|
+
|
|
287
|
+
### Spinner
|
|
288
|
+
|
|
289
|
+
For < 1s waits. Linear rotation, infinite, 800-1000ms per revolution.
|
|
290
|
+
|
|
291
|
+
```css
|
|
292
|
+
@keyframes spin {
|
|
293
|
+
to { transform: rotate(360deg); }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.spinner {
|
|
297
|
+
animation: spin 800ms linear infinite;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Add `aria-label="Loading"` (or wrap in a container with one).
|
|
302
|
+
|
|
303
|
+
### Skeleton screen
|
|
304
|
+
|
|
305
|
+
For 300ms+ waits. Match the eventual layout. Animate a subtle shimmer.
|
|
306
|
+
|
|
307
|
+
```css
|
|
308
|
+
.skeleton {
|
|
309
|
+
background: linear-gradient(
|
|
310
|
+
90deg,
|
|
311
|
+
var(--surface-muted) 0%,
|
|
312
|
+
var(--surface) 50%,
|
|
313
|
+
var(--surface-muted) 100%
|
|
314
|
+
);
|
|
315
|
+
background-size: 200% 100%;
|
|
316
|
+
animation: shimmer 1500ms ease-in-out infinite;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@keyframes shimmer {
|
|
320
|
+
0% { background-position: 200% 0; }
|
|
321
|
+
100% { background-position: -200% 0; }
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Mark with `aria-busy="true"` and `aria-live="polite"` so SRs announce when content arrives.
|
|
326
|
+
|
|
327
|
+
### Progress bar
|
|
328
|
+
|
|
329
|
+
For determinate progress. Animate `transform: scaleX()` from 0 to 1, not `width`.
|
|
330
|
+
|
|
331
|
+
```css
|
|
332
|
+
.progress-bar {
|
|
333
|
+
transform-origin: left;
|
|
334
|
+
transform: scaleX(0);
|
|
335
|
+
transition: transform 200ms linear;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.progress-bar.at-50 { transform: scaleX(0.5); }
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Hover and Press Effects
|
|
342
|
+
|
|
343
|
+
### Button hover
|
|
344
|
+
|
|
345
|
+
Subtle: shift background by one tone, change border, raise by 1-2px.
|
|
346
|
+
|
|
347
|
+
```css
|
|
348
|
+
.btn {
|
|
349
|
+
transition: transform 150ms ease-out, background 150ms ease-out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.btn:hover {
|
|
353
|
+
transform: translateY(-1px);
|
|
354
|
+
background: var(--primary-hover);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.btn:active {
|
|
358
|
+
transform: translateY(0) scale(0.98);
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Card hover
|
|
363
|
+
|
|
364
|
+
Lift slightly, subtle shadow change.
|
|
365
|
+
|
|
366
|
+
```css
|
|
367
|
+
.card {
|
|
368
|
+
transition: transform 200ms ease-out, box-shadow 200ms ease-out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.card:hover {
|
|
372
|
+
transform: translateY(-2px);
|
|
373
|
+
box-shadow: var(--shadow-elevated);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Press scale
|
|
378
|
+
|
|
379
|
+
For tappable cards/buttons: subtle 0.95-0.98 scale on press, restore on release.
|
|
380
|
+
|
|
381
|
+
```css
|
|
382
|
+
.btn:active {
|
|
383
|
+
transform: scale(0.97);
|
|
384
|
+
transition-duration: 50ms;
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Mobile: rely on `:active`. The browser handles touch feedback.
|
|
389
|
+
|
|
390
|
+
## Modal/Sheet Entrance
|
|
391
|
+
|
|
392
|
+
### Modal (centered)
|
|
393
|
+
|
|
394
|
+
```css
|
|
395
|
+
.modal {
|
|
396
|
+
opacity: 0;
|
|
397
|
+
transform: scale(0.95);
|
|
398
|
+
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.modal.open {
|
|
402
|
+
opacity: 1;
|
|
403
|
+
transform: scale(1);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.modal-backdrop {
|
|
407
|
+
opacity: 0;
|
|
408
|
+
transition: opacity 200ms ease-out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.modal-backdrop.open {
|
|
412
|
+
opacity: 1;
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Order: backdrop fades in, then modal fades + scales in (50ms after).
|
|
417
|
+
|
|
418
|
+
### Sheet (slides from edge)
|
|
419
|
+
|
|
420
|
+
```css
|
|
421
|
+
.sheet-bottom {
|
|
422
|
+
transform: translateY(100%);
|
|
423
|
+
transition: transform 250ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.sheet-bottom.open {
|
|
427
|
+
transform: translateY(0);
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`translateY(100%)` puts it just below the visible area. iOS bottom sheets use a slight overshoot; the cubic-bezier above approximates that feel.
|
|
432
|
+
|
|
433
|
+
### Dropdown/popover
|
|
434
|
+
|
|
435
|
+
```css
|
|
436
|
+
.popover {
|
|
437
|
+
opacity: 0;
|
|
438
|
+
transform: translateY(-4px) scale(0.97);
|
|
439
|
+
transform-origin: top;
|
|
440
|
+
transition: opacity 150ms ease-out, transform 150ms ease-out;
|
|
441
|
+
pointer-events: none;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.popover.open {
|
|
445
|
+
opacity: 1;
|
|
446
|
+
transform: translateY(0) scale(1);
|
|
447
|
+
pointer-events: auto;
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
## Toast/Snackbar
|
|
452
|
+
|
|
453
|
+
Slide in from edge + fade. Auto-dismiss after 3-5s.
|
|
454
|
+
|
|
455
|
+
```css
|
|
456
|
+
.toast {
|
|
457
|
+
transform: translateY(100%);
|
|
458
|
+
opacity: 0;
|
|
459
|
+
transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1), opacity 200ms ease-out;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.toast.show {
|
|
463
|
+
transform: translateY(0);
|
|
464
|
+
opacity: 1;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.toast.hide {
|
|
468
|
+
transform: translateY(100%);
|
|
469
|
+
opacity: 0;
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Hero Animations
|
|
474
|
+
|
|
475
|
+
A well-orchestrated hero entrance can carry the entire page. Examples:
|
|
476
|
+
|
|
477
|
+
### Staggered text rise
|
|
478
|
+
|
|
479
|
+
Each word or letter rises and fades in sequentially.
|
|
480
|
+
|
|
481
|
+
```css
|
|
482
|
+
.hero-words span {
|
|
483
|
+
display: inline-block;
|
|
484
|
+
opacity: 0;
|
|
485
|
+
transform: translateY(0.5em);
|
|
486
|
+
animation: rise 600ms cubic-bezier(0.2, 0, 0, 1) forwards;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.hero-words span:nth-child(1) { animation-delay: 100ms; }
|
|
490
|
+
.hero-words span:nth-child(2) { animation-delay: 180ms; }
|
|
491
|
+
.hero-words span:nth-child(3) { animation-delay: 260ms; }
|
|
492
|
+
/* etc */
|
|
493
|
+
|
|
494
|
+
@keyframes rise {
|
|
495
|
+
to { opacity: 1; transform: translateY(0); }
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Cap at 8-10 items. Beyond that, the animation feels slow.
|
|
500
|
+
|
|
501
|
+
### Image reveal
|
|
502
|
+
|
|
503
|
+
A clip-path reveal that wipes from one edge.
|
|
504
|
+
|
|
505
|
+
```css
|
|
506
|
+
.hero-image {
|
|
507
|
+
clip-path: inset(0 100% 0 0);
|
|
508
|
+
animation: reveal 800ms cubic-bezier(0.6, 0, 0, 1) 200ms forwards;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@keyframes reveal {
|
|
512
|
+
to { clip-path: inset(0 0 0 0); }
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Subtle parallax on hero
|
|
517
|
+
|
|
518
|
+
The hero image drifts slowly as the user scrolls; capped to a few pixels:
|
|
519
|
+
|
|
520
|
+
```css
|
|
521
|
+
.parallax-hero {
|
|
522
|
+
transform: translateY(0);
|
|
523
|
+
transition: transform 100ms linear;
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
```js
|
|
528
|
+
window.addEventListener('scroll', () => {
|
|
529
|
+
const offset = Math.min(window.scrollY * 0.3, 100);
|
|
530
|
+
document.querySelector('.parallax-hero').style.transform = `translateY(${offset}px)`;
|
|
531
|
+
});
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Throttle to 16ms (one frame).
|
|
535
|
+
|
|
536
|
+
Anti-pattern: full-page parallax on every section. Disorienting and CLS-risky.
|
|
537
|
+
|
|
538
|
+
## Continuous / Ambient Motion
|
|
539
|
+
|
|
540
|
+
Subtle ongoing motion gives a page life. Use sparingly.
|
|
541
|
+
|
|
542
|
+
### Slow gradient shift
|
|
543
|
+
|
|
544
|
+
```css
|
|
545
|
+
.ambient-bg {
|
|
546
|
+
background: linear-gradient(120deg, #color1, #color2, #color3);
|
|
547
|
+
background-size: 200% 200%;
|
|
548
|
+
animation: shift 30s ease-in-out infinite;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
@keyframes shift {
|
|
552
|
+
0%, 100% { background-position: 0% 50%; }
|
|
553
|
+
50% { background-position: 100% 50%; }
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Floating decoration
|
|
558
|
+
|
|
559
|
+
A small element drifts up/down 4-8px continuously.
|
|
560
|
+
|
|
561
|
+
```css
|
|
562
|
+
.floating {
|
|
563
|
+
animation: float 4s ease-in-out infinite;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
@keyframes float {
|
|
567
|
+
0%, 100% { transform: translateY(0); }
|
|
568
|
+
50% { transform: translateY(-6px); }
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### When to disable
|
|
573
|
+
|
|
574
|
+
Continuous motion drains battery. Disable when:
|
|
575
|
+
|
|
576
|
+
- The page is not in the active tab (`document.visibilitychange`).
|
|
577
|
+
- The user prefers reduced motion.
|
|
578
|
+
- The element is off-screen.
|
|
579
|
+
|
|
580
|
+
```js
|
|
581
|
+
const observer = new IntersectionObserver((entries) => {
|
|
582
|
+
entries.forEach((entry) => {
|
|
583
|
+
entry.target.style.animationPlayState = entry.isIntersecting ? 'running' : 'paused';
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
## INP and Animation
|
|
589
|
+
|
|
590
|
+
INP is impacted by animations that block the main thread. Composited-only animations (transform, opacity) don't impact INP. Layout-triggering animations and JS-driven animations can.
|
|
591
|
+
|
|
592
|
+
If INP regresses after adding animation:
|
|
593
|
+
|
|
594
|
+
- Check if the animation triggers layout. Move to transform/opacity.
|
|
595
|
+
- Check if a JS animation is running on every frame. Use CSS animations or `web-animations-api` instead.
|
|
596
|
+
- Check if the animation runs during page load. Defer until after `load`.
|
|
597
|
+
|
|
598
|
+
## CLS and Animation
|
|
599
|
+
|
|
600
|
+
Animations that change element size cause layout shift. Avoid.
|
|
601
|
+
|
|
602
|
+
If you must animate size, use `transform: scale()` (compositor-only) and accept that it's a visual approximation.
|
|
603
|
+
|
|
604
|
+
For accordion/disclosure, pre-measure the content height and animate `max-height` to that exact value. Or use `interpolate-size: allow-keywords` (modern CSS) plus `transition: height` (now possible to animate to `auto`).
|
|
605
|
+
|
|
606
|
+
## Common Motion Mistakes
|
|
607
|
+
|
|
608
|
+
- Using `linear` for UI transitions.
|
|
609
|
+
- Animating `width`, `height`, `top`, `left`.
|
|
610
|
+
- 800ms+ durations on common UI.
|
|
611
|
+
- Animations that block input (modal opening can't be cancelled mid-animation).
|
|
612
|
+
- Different durations for opening and closing the same component.
|
|
613
|
+
- Decorative animation on every section.
|
|
614
|
+
- Parallax that causes CLS.
|
|
615
|
+
- Hover-only effects with no touch equivalent.
|
|
616
|
+
- `prefers-reduced-motion` ignored.
|
|
617
|
+
- Animating during page load on the LCP element.
|
|
618
|
+
- Continuous animation on a hero, draining battery.
|
|
619
|
+
- Spinner with no `aria-label`.
|
|
620
|
+
- Skeleton without `aria-busy`.
|
|
621
|
+
|
|
622
|
+
## Self-Healing for Motion
|
|
623
|
+
|
|
624
|
+
Before declaring work complete:
|
|
625
|
+
|
|
626
|
+
- [ ] All animations use `transform` and/or `opacity` (no layout-triggering)
|
|
627
|
+
- [ ] Durations 100-500ms with intentional choice per interaction
|
|
628
|
+
- [ ] Easing chosen per direction (ease-out for in, ease-in for out)
|
|
629
|
+
- [ ] Exit faster than entrance (60-70%)
|
|
630
|
+
- [ ] `prefers-reduced-motion` removes or shortens non-essential motion
|
|
631
|
+
- [ ] No animation blocks user input (modals are interruptible)
|
|
632
|
+
- [ ] Loading states have skeleton at 300ms, spinner before, progress for determinate
|
|
633
|
+
- [ ] Spinner / skeleton have accessible names / `aria-busy`
|
|
634
|
+
- [ ] Stagger capped at 8-10 items
|
|
635
|
+
- [ ] Continuous motion paused when off-screen and tab inactive
|
|
636
|
+
- [ ] No animation regresses LCP, INP, or CLS
|
|
637
|
+
|
|
638
|
+
## See Also
|
|
639
|
+
|
|
640
|
+
- [performance.md](performance.md) for animation cost
|
|
641
|
+
- [accessibility.md](accessibility.md) for reduced motion
|
|
642
|
+
- [ui-ux.md](ui-ux.md) for state transitions
|