@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.
@@ -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