@hegemonart/get-design-done 1.16.0 → 1.18.0

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.
Files changed (49) hide show
  1. package/.claude-plugin/marketplace.json +7 -5
  2. package/.claude-plugin/plugin.json +17 -5
  3. package/CHANGELOG.md +84 -0
  4. package/README.md +20 -2
  5. package/agents/design-auditor.md +60 -1
  6. package/agents/design-doc-writer.md +21 -0
  7. package/agents/design-executor.md +22 -4
  8. package/agents/design-pattern-mapper.md +61 -0
  9. package/agents/motion-mapper.md +74 -9
  10. package/agents/token-mapper.md +8 -0
  11. package/package.json +10 -2
  12. package/reference/components/README.md +27 -23
  13. package/reference/components/alert.md +198 -0
  14. package/reference/components/badge.md +202 -0
  15. package/reference/components/breadcrumbs.md +198 -0
  16. package/reference/components/chip.md +209 -0
  17. package/reference/components/command-palette.md +228 -0
  18. package/reference/components/date-picker.md +227 -0
  19. package/reference/components/file-upload.md +219 -0
  20. package/reference/components/list.md +217 -0
  21. package/reference/components/menu.md +212 -0
  22. package/reference/components/navbar.md +211 -0
  23. package/reference/components/pagination.md +205 -0
  24. package/reference/components/progress.md +210 -0
  25. package/reference/components/rich-text-editor.md +226 -0
  26. package/reference/components/sidebar.md +211 -0
  27. package/reference/components/skeleton.md +197 -0
  28. package/reference/components/slider.md +208 -0
  29. package/reference/components/stepper.md +220 -0
  30. package/reference/components/table.md +229 -0
  31. package/reference/components/toast.md +200 -0
  32. package/reference/components/tree.md +225 -0
  33. package/reference/css-grid-layout.md +835 -0
  34. package/reference/external/NOTICE.hyperframes +28 -0
  35. package/reference/image-optimization.md +582 -0
  36. package/reference/motion-advanced.md +754 -0
  37. package/reference/motion-easings.md +381 -0
  38. package/reference/motion-interpolate.md +282 -0
  39. package/reference/motion-spring.md +234 -0
  40. package/reference/motion-transition-taxonomy.md +155 -0
  41. package/reference/motion.md +20 -0
  42. package/reference/output-contracts/motion-map.schema.json +135 -0
  43. package/reference/registry.json +183 -0
  44. package/reference/registry.schema.json +4 -0
  45. package/reference/variable-fonts-loading.md +532 -0
  46. package/scripts/lib/easings.cjs +280 -0
  47. package/scripts/lib/parse-contract.cjs +220 -0
  48. package/scripts/lib/spring.cjs +160 -0
  49. package/scripts/tests/test-motion-provenance.sh +64 -0
@@ -0,0 +1,754 @@
1
+ <!-- Source: Phase 18 — get-design-done -->
2
+ <!-- Extends: reference/motion.md (for advanced patterns) -->
3
+ <!-- See also: reference/framer-motion-patterns.md, reference/motion-easings.md, reference/motion-spring.md -->
4
+
5
+ # Motion Advanced Patterns
6
+
7
+ ## Spring Physics
8
+
9
+ ### The Stiffness / Damping / Mass Triad
10
+
11
+ Spring animations are governed by three parameters that model a physical spring:
12
+
13
+ - **stiffness** — how tightly wound the spring is; higher = faster, snappier response
14
+ - **damping** — friction applied to the oscillation; higher = settles faster with less bounce
15
+ - **mass** — inertia of the object; higher = slower start and more overshoot
16
+
17
+ The damping ratio `ζ = damping / (2 * Math.sqrt(stiffness * mass))` determines behavior:
18
+
19
+ | Condition | ζ value | Behavior |
20
+ |-----------|---------|----------|
21
+ | Underdamped | ζ < 1 | Oscillates past target, settles with bounce |
22
+ | Critically damped | ζ = 1 | Reaches target exactly once, no overshoot |
23
+ | Overdamped | ζ > 1 | Approaches target slowly, no oscillation |
24
+
25
+ For UI: critically-damped or slightly underdamped (ζ ≈ 0.7–0.9) is almost always correct. Reserve underdamped (bouncy) for playful drag-dismiss moments only.
26
+
27
+ ### Framer Motion Spring Config
28
+
29
+ ```tsx
30
+ // Snappy, no bounce — good for menus, drawers
31
+ <motion.div
32
+ animate={{ x: 0 }}
33
+ transition={{ type: "spring", stiffness: 400, damping: 40, mass: 1 }}
34
+ />
35
+
36
+ // Gentle settle — good for page-level transitions
37
+ <motion.div
38
+ animate={{ opacity: 1, y: 0 }}
39
+ transition={{ type: "spring", stiffness: 120, damping: 20, mass: 1 }}
40
+ />
41
+
42
+ // Underdamped bounce — drag-to-dismiss return snap only
43
+ <motion.div
44
+ animate={{ x: 0 }}
45
+ transition={{ type: "spring", stiffness: 500, damping: 15, mass: 0.8 }}
46
+ />
47
+
48
+ // Using bounce shorthand (0 = no bounce, 1 = maximum bounce)
49
+ <motion.div
50
+ animate={{ scale: 1 }}
51
+ transition={{ type: "spring", bounce: 0, duration: 0.4 }}
52
+ />
53
+ ```
54
+
55
+ ### CSS `linear()` Spring Approximation
56
+
57
+ For environments without a spring library, `linear()` can approximate spring curves by sampling the curve at intervals:
58
+
59
+ ```css
60
+ /* Approximated spring: stiffness 300, damping 30 */
61
+ .spring-in {
62
+ transition: transform 0.6s linear(
63
+ 0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%, 0.938 16.7%,
64
+ 1.017, 1.077, 1.104 24%, 1.121, 1.121, 1.106, 1.089 30.3%, 1.042 34.2%,
65
+ 1.013 38.3%, 0.995 42.9%, 0.988 46.9%, 0.984 50.8%, 0.985 55%,
66
+ 0.991 59.6%, 0.998 65.1%, 1.001 70.1%, 1.002 75.1%, 1 100%
67
+ );
68
+ }
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Stagger Patterns
74
+
75
+ ### Index × Delay Formula
76
+
77
+ The simplest stagger: each item delays by `index * baseDelay`.
78
+
79
+ ```tsx
80
+ // Framer Motion stagger via variants
81
+ const container = {
82
+ hidden: {},
83
+ show: {
84
+ transition: {
85
+ staggerChildren: 0.06,
86
+ delayChildren: 0.1,
87
+ },
88
+ },
89
+ };
90
+
91
+ const item = {
92
+ hidden: { opacity: 0, y: 12 },
93
+ show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 28 } },
94
+ };
95
+
96
+ function List({ items }: { items: string[] }) {
97
+ return (
98
+ <motion.ul variants={container} initial="hidden" animate="show">
99
+ {items.map((text, i) => (
100
+ <motion.li key={i} variants={item}>{text}</motion.li>
101
+ ))}
102
+ </motion.ul>
103
+ );
104
+ }
105
+ ```
106
+
107
+ ### Exponential Easing for Natural Cascade
108
+
109
+ Linear stagger feels mechanical past ~5 items. Use an exponential curve so earlier items feel snappier:
110
+
111
+ ```ts
112
+ // delay = base * index^0.7 — compresses stagger for large lists
113
+ function staggerDelay(index: number, base = 0.05): number {
114
+ return base * Math.pow(index, 0.7);
115
+ }
116
+ ```
117
+
118
+ ### Directional Stagger
119
+
120
+ ```tsx
121
+ // Enter from bottom — items stagger upward into place
122
+ const enterFromBottom = {
123
+ hidden: { opacity: 0, y: 20 },
124
+ show: (i: number) => ({
125
+ opacity: 1,
126
+ y: 0,
127
+ transition: { delay: i * 0.05, type: "spring", stiffness: 260, damping: 24 },
128
+ }),
129
+ };
130
+
131
+ // Exit to top — reverse order
132
+ const exitToTop = {
133
+ show: { opacity: 1, y: 0 },
134
+ hidden: (i: number) => ({
135
+ opacity: 0,
136
+ y: -16,
137
+ transition: { delay: i * 0.03 },
138
+ }),
139
+ };
140
+
141
+ // Usage with custom prop
142
+ <motion.li custom={index} variants={enterFromBottom} initial="hidden" animate="show" exit="hidden" />
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Scroll-Driven Animation
148
+
149
+ ### CSS `animation-timeline: scroll()`
150
+
151
+ Ties an animation's progress to the scroll position of a scroll container.
152
+
153
+ ```css
154
+ .progress-bar {
155
+ animation: grow-width linear;
156
+ animation-timeline: scroll(root block);
157
+ animation-range: 0% 100%;
158
+ }
159
+
160
+ @keyframes grow-width {
161
+ from { transform: scaleX(0); }
162
+ to { transform: scaleX(1); }
163
+ }
164
+ ```
165
+
166
+ ### CSS `animation-timeline: view()`
167
+
168
+ Ties progress to an element's visibility within the viewport.
169
+
170
+ ```css
171
+ .fade-in-card {
172
+ animation: reveal linear both;
173
+ animation-timeline: view();
174
+ animation-range: entry 0% entry 40%;
175
+ }
176
+
177
+ @keyframes reveal {
178
+ from { opacity: 0; transform: translateY(24px); }
179
+ to { opacity: 1; transform: translateY(0); }
180
+ }
181
+ ```
182
+
183
+ `animation-range` accepts: `entry`, `exit`, `cover`, `contain` + percentage offset.
184
+
185
+ ### IntersectionObserver Fallback
186
+
187
+ ```ts
188
+ function observeReveal(selector: string) {
189
+ const els = document.querySelectorAll<HTMLElement>(selector);
190
+ const io = new IntersectionObserver(
191
+ (entries) => {
192
+ entries.forEach((e) => {
193
+ if (e.isIntersecting) {
194
+ e.target.classList.add("revealed");
195
+ io.unobserve(e.target);
196
+ }
197
+ });
198
+ },
199
+ { threshold: 0.15 }
200
+ );
201
+ els.forEach((el) => io.observe(el));
202
+ }
203
+ ```
204
+
205
+ ### `ScrollTimeline` JS API
206
+
207
+ ```ts
208
+ const timeline = new ScrollTimeline({
209
+ source: document.scrollingElement!,
210
+ axis: "block",
211
+ });
212
+
213
+ el.animate(
214
+ [{ opacity: 0, transform: "translateY(20px)" }, { opacity: 1, transform: "none" }],
215
+ { duration: 1, fill: "both", timeline }
216
+ );
217
+ ```
218
+
219
+ ---
220
+
221
+ ## FLIP (First / Last / Invert / Play)
222
+
223
+ ### The Four Steps
224
+
225
+ 1. **First** — record the element's current bounding rect (`getBoundingClientRect()`)
226
+ 2. **Last** — apply the DOM change, then record the new rect
227
+ 3. **Invert** — set a CSS transform that moves the element back to its "First" position
228
+ 4. **Play** — animate the transform to identity (`0, 0, scale(1)`)
229
+
230
+ ```ts
231
+ function flip(el: HTMLElement, applyChange: () => void) {
232
+ // First
233
+ const first = el.getBoundingClientRect();
234
+
235
+ // Last
236
+ applyChange();
237
+ const last = el.getBoundingClientRect();
238
+
239
+ // Invert
240
+ const dx = first.left - last.left;
241
+ const dy = first.top - last.top;
242
+ const sx = first.width / last.width;
243
+ const sy = first.height / last.height;
244
+
245
+ el.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
246
+ el.style.transformOrigin = "top left";
247
+
248
+ // Play — use rAF to ensure paint
249
+ requestAnimationFrame(() => {
250
+ el.style.transition = "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)";
251
+ el.style.transform = "";
252
+
253
+ el.addEventListener("transitionend", () => {
254
+ el.style.transition = "";
255
+ el.style.transformOrigin = "";
256
+ }, { once: true });
257
+ });
258
+ }
259
+ ```
260
+
261
+ ### Framer Motion `layoutId` as FLIP Abstraction
262
+
263
+ ```tsx
264
+ // The layoutId prop handles FLIP automatically across re-renders and AnimatePresence
265
+ function Tabs({ tabs, active, setActive }: TabsProps) {
266
+ return (
267
+ <div className="tabs">
268
+ {tabs.map((tab) => (
269
+ <button key={tab.id} onClick={() => setActive(tab.id)} className="tab">
270
+ {tab.label}
271
+ {active === tab.id && (
272
+ <motion.span
273
+ layoutId="active-pill"
274
+ className="active-pill"
275
+ transition={{ type: "spring", stiffness: 380, damping: 36 }}
276
+ />
277
+ )}
278
+ </button>
279
+ ))}
280
+ </div>
281
+ );
282
+ }
283
+ ```
284
+
285
+ ---
286
+
287
+ ## View Transitions API
288
+
289
+ ### Same-Document Transitions
290
+
291
+ ```ts
292
+ function navigateTo(newContent: () => void) {
293
+ if (!document.startViewTransition) {
294
+ newContent();
295
+ return;
296
+ }
297
+ document.startViewTransition(newContent);
298
+ }
299
+ ```
300
+
301
+ ### Cross-Document Transitions
302
+
303
+ ```css
304
+ /* In both pages — opt in */
305
+ @view-transition {
306
+ navigation: auto;
307
+ }
308
+ ```
309
+
310
+ ### `view-transition-name` for Shared Elements
311
+
312
+ ```css
313
+ .hero-image {
314
+ view-transition-name: hero-image;
315
+ }
316
+
317
+ /* Customize the cross-fade */
318
+ ::view-transition-old(hero-image) {
319
+ animation: fade-out 0.3s ease-out;
320
+ }
321
+ ::view-transition-new(hero-image) {
322
+ animation: fade-in 0.3s ease-in;
323
+ }
324
+ ```
325
+
326
+ ### Progressive Enhancement Pattern
327
+
328
+ ```ts
329
+ async function transitionTo(url: string) {
330
+ if (!document.startViewTransition) {
331
+ window.location.href = url;
332
+ return;
333
+ }
334
+ await document.startViewTransition(async () => {
335
+ const res = await fetch(url);
336
+ const html = await res.text();
337
+ const doc = new DOMParser().parseFromString(html, "text/html");
338
+ document.body.replaceWith(doc.body);
339
+ history.pushState({}, "", url);
340
+ });
341
+ }
342
+ ```
343
+
344
+ ---
345
+
346
+ ## Route-Level Animation Orchestration
347
+
348
+ ### Exit → Enter Sequencing
349
+
350
+ The fundamental rule: the exiting page must fully finish before the entering page starts, or both must overlap with a crossfade. Avoid flash by keeping the exiting element mounted until its animation completes.
351
+
352
+ ### AnimatePresence in Next.js (App Router)
353
+
354
+ ```tsx
355
+ // app/layout.tsx
356
+ "use client";
357
+ import { AnimatePresence } from "framer-motion";
358
+ import { usePathname } from "next/navigation";
359
+
360
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
361
+ const pathname = usePathname();
362
+ return (
363
+ <html>
364
+ <body>
365
+ <AnimatePresence mode="wait">
366
+ <motion.main
367
+ key={pathname}
368
+ initial={{ opacity: 0, y: 8 }}
369
+ animate={{ opacity: 1, y: 0 }}
370
+ exit={{ opacity: 0, y: -8 }}
371
+ transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
372
+ >
373
+ {children}
374
+ </motion.main>
375
+ </AnimatePresence>
376
+ </body>
377
+ </html>
378
+ );
379
+ }
380
+ ```
381
+
382
+ `mode="wait"` ensures exit completes before enter begins. `mode="sync"` runs both simultaneously for crossfades.
383
+
384
+ ---
385
+
386
+ ## Gesture & Drag Mechanics
387
+
388
+ ### Momentum-Based Dismissal
389
+
390
+ ```ts
391
+ const FLICK_THRESHOLD = 0.11; // px/ms — dismiss regardless of distance
392
+
393
+ let startX = 0;
394
+ let startTime = 0;
395
+
396
+ el.addEventListener("pointerdown", (e) => {
397
+ el.setPointerCapture(e.pointerId); // keep events when pointer leaves bounds
398
+ startX = e.clientX;
399
+ startTime = performance.now();
400
+ if ((e as TouchEvent).touches?.length > 1) return; // multi-touch guard
401
+ });
402
+
403
+ el.addEventListener("pointermove", (e) => {
404
+ const dx = e.clientX - startX;
405
+ el.style.transform = `translateX(${dx}px)`;
406
+ });
407
+
408
+ el.addEventListener("pointerup", (e) => {
409
+ const dx = e.clientX - startX;
410
+ const elapsed = performance.now() - startTime;
411
+ const velocity = Math.abs(dx) / elapsed; // px/ms
412
+
413
+ if (velocity > FLICK_THRESHOLD || Math.abs(dx) > el.offsetWidth * 0.5) {
414
+ dismiss(dx > 0 ? "right" : "left");
415
+ } else {
416
+ snapBack();
417
+ }
418
+ });
419
+ ```
420
+
421
+ ### Boundary Damping with Increasing Friction
422
+
423
+ ```ts
424
+ function dampedPosition(raw: number, limit: number): number {
425
+ if (Math.abs(raw) <= limit) return raw;
426
+ const overflow = Math.abs(raw) - limit;
427
+ const sign = raw > 0 ? 1 : -1;
428
+ // Logarithmic damping — resistance grows as overflow grows
429
+ const dampedOver = Math.log1p(overflow) * 18;
430
+ return sign * (limit + dampedOver);
431
+ }
432
+ ```
433
+
434
+ ### Swipe-to-Dismiss Pattern (Framer Motion)
435
+
436
+ ```tsx
437
+ function SwipeCard({ onDismiss }: { onDismiss: () => void }) {
438
+ const x = useMotionValue(0);
439
+ const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0]);
440
+ const rotate = useTransform(x, [-200, 200], [-15, 15]);
441
+
442
+ return (
443
+ <motion.div
444
+ style={{ x, opacity, rotate }}
445
+ drag="x"
446
+ dragConstraints={{ left: 0, right: 0 }}
447
+ dragElastic={0.15} // built-in boundary damping
448
+ onDragEnd={(_, info) => {
449
+ const velocity = Math.abs(info.velocity.x);
450
+ const offset = Math.abs(info.offset.x);
451
+ if (velocity > 400 || offset > 120) onDismiss();
452
+ }}
453
+ />
454
+ );
455
+ }
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Clip-Path Animation Patterns
461
+
462
+ ### `inset()` Morphing
463
+
464
+ ```css
465
+ .panel {
466
+ clip-path: inset(0 100% 0 0); /* fully clipped right */
467
+ transition: clip-path 0.4s cubic-bezier(0.4, 0, 0.2, 1);
468
+ }
469
+ .panel.open {
470
+ clip-path: inset(0 0% 0 0); /* fully revealed */
471
+ }
472
+ ```
473
+
474
+ ### Hold-to-Delete Fill
475
+
476
+ ```ts
477
+ let timer: ReturnType<typeof setTimeout> | null = null;
478
+ let startTime = 0;
479
+ let raf = 0;
480
+
481
+ btn.addEventListener("pointerdown", () => {
482
+ startTime = performance.now();
483
+ function tick() {
484
+ const progress = Math.min((performance.now() - startTime) / 2000, 1);
485
+ const right = 100 - progress * 100;
486
+ fill.style.clipPath = `inset(0 ${right}% 0 0)`;
487
+ if (progress < 1) raf = requestAnimationFrame(tick);
488
+ else triggerDelete();
489
+ }
490
+ raf = requestAnimationFrame(tick);
491
+ });
492
+
493
+ btn.addEventListener("pointerup", () => {
494
+ cancelAnimationFrame(raf);
495
+ fill.style.transition = "clip-path 0.2s ease-out";
496
+ fill.style.clipPath = "inset(0 100% 0 0)";
497
+ fill.addEventListener("transitionend", () => { fill.style.transition = ""; }, { once: true });
498
+ });
499
+ ```
500
+
501
+ ### Image Reveal on Scroll
502
+
503
+ ```ts
504
+ const io = new IntersectionObserver((entries) => {
505
+ entries.forEach((e) => {
506
+ if (!e.isIntersecting) return;
507
+ const img = e.target as HTMLElement;
508
+ img.style.transition = "clip-path 0.7s cubic-bezier(0.4, 0, 0.2, 1)";
509
+ img.style.clipPath = "inset(0 0 0% 0)";
510
+ io.unobserve(img);
511
+ });
512
+ }, { threshold: 0.1 });
513
+
514
+ document.querySelectorAll<HTMLElement>(".reveal-image").forEach((img) => {
515
+ img.style.clipPath = "inset(0 0 100% 0)";
516
+ io.observe(img);
517
+ });
518
+ ```
519
+
520
+ ### Tab Active-State Color Mask
521
+
522
+ ```tsx
523
+ // Two stacked lists: default style below, active style above, clipped to active tab width
524
+ function MaskedTabs({ tabs, active }: { tabs: Tab[]; active: string }) {
525
+ const activeTab = tabs.find((t) => t.id === active);
526
+
527
+ return (
528
+ <div className="relative">
529
+ {/* Base layer */}
530
+ <ul className="tabs text-neutral-500">{tabs.map(renderTab)}</ul>
531
+
532
+ {/* Active layer — clipped to active tab bounds */}
533
+ <motion.ul
534
+ className="tabs text-brand absolute inset-0 pointer-events-none"
535
+ style={{ clipPath: `inset(0 ${/* right offset */ 0}px 0 ${activeTab?.left ?? 0}px)` }}
536
+ animate={{ clipPath: `inset(0 ${activeTab?.right ?? 0}px 0 ${activeTab?.left ?? 0}px)` }}
537
+ transition={{ type: "spring", stiffness: 380, damping: 36 }}
538
+ >
539
+ {tabs.map(renderTab)}
540
+ </motion.ul>
541
+ </div>
542
+ );
543
+ }
544
+ ```
545
+
546
+ ### Drag-Comparison Slider
547
+
548
+ ```tsx
549
+ function CompareSlider({ before, after }: { before: string; after: string }) {
550
+ const [pos, setPos] = useState(50); // percent
551
+
552
+ return (
553
+ <div
554
+ className="relative select-none overflow-hidden"
555
+ onPointerMove={(e) => {
556
+ const rect = e.currentTarget.getBoundingClientRect();
557
+ setPos(((e.clientX - rect.left) / rect.width) * 100);
558
+ }}
559
+ >
560
+ <img src={after} className="w-full" alt="after" />
561
+ <img
562
+ src={before}
563
+ className="absolute inset-0 w-full h-full object-cover"
564
+ style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
565
+ alt="before"
566
+ />
567
+ <div
568
+ className="absolute top-0 bottom-0 w-0.5 bg-white cursor-ew-resize"
569
+ style={{ left: `${pos}%` }}
570
+ />
571
+ </div>
572
+ );
573
+ }
574
+ ```
575
+
576
+ ---
577
+
578
+ ## Blur-to-Mask Crossfades
579
+
580
+ Use a short `filter: blur()` during state transitions to bridge the visual gap between two overlapping states — it softens the hard edge that appears when opacity alone creates a ghost.
581
+
582
+ ```tsx
583
+ <motion.div
584
+ animate={isLoading ? "loading" : "ready"}
585
+ variants={{
586
+ loading: { filter: "blur(2px)", scale: 0.98, opacity: 0.7 },
587
+ ready: { filter: "blur(0px)", scale: 1, opacity: 1 },
588
+ }}
589
+ transition={{ duration: 0.22, ease: "easeOut" }}
590
+ />
591
+ ```
592
+
593
+ **Rules:**
594
+ - Cap blur under 20px on non-animated elements — Safari allocates GPU memory per blurred layer, causing stutter at high values
595
+ - Pair with `scale(0.97)` for press feedback; the scale signals physical depth while blur softens content churn
596
+ - Use for: skeleton → content, loading → loaded image, optimistic update → confirmed state
597
+ - Do NOT use for layout shifts — blur does not mask reflow artifacts
598
+
599
+ ---
600
+
601
+ ## CSS Transitions vs Keyframes for Interruptible UI
602
+
603
+ **Transitions** retarget mid-flight: if you change the target value while a transition is running, the animation smoothly redirects from its current position to the new target.
604
+
605
+ **Keyframes** restart from zero: interrupting a keyframe animation jumps to the start of the keyframe sequence, causing a visual pop.
606
+
607
+ **Critical rule:** Always use transitions for toasts, toggles, drag handles, and optimistic-UI state flips.
608
+
609
+ ```css
610
+ /* CORRECT — transition retargets smoothly when toggled rapidly */
611
+ .toggle-thumb {
612
+ transform: translateX(0);
613
+ transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
614
+ }
615
+ .toggle-thumb.checked {
616
+ transform: translateX(20px);
617
+ }
618
+
619
+ /* WRONG for interruptible UI — keyframe restarts from translateX(0) on interrupt */
620
+ .toggle-thumb.checked {
621
+ animation: slide-right 0.2s forwards;
622
+ }
623
+ @keyframes slide-right {
624
+ from { transform: translateX(0); }
625
+ to { transform: translateX(20px); }
626
+ }
627
+ ```
628
+
629
+ Use keyframes for: looping indicators, attention animations (shake, pulse), entrance sequences that must always play from the beginning.
630
+
631
+ ---
632
+
633
+ ## WAAPI (Web Animations API) for Programmatic CSS
634
+
635
+ Hardware-accelerated, interruptible, no library required.
636
+
637
+ ```ts
638
+ // Basic syntax
639
+ const anim = el.animate(
640
+ [
641
+ { opacity: 0, transform: "translateY(8px)" },
642
+ { opacity: 1, transform: "translateY(0)" },
643
+ ],
644
+ {
645
+ duration: 280,
646
+ easing: "cubic-bezier(0.4, 0, 0.2, 1)",
647
+ fill: "forwards",
648
+ }
649
+ );
650
+
651
+ // Cancel mid-flight (e.g., element removed before animation ends)
652
+ anim.cancel();
653
+
654
+ // Reverse mid-flight (e.g., hover-out before hover-in finished)
655
+ anim.reverse();
656
+
657
+ // Await completion
658
+ await anim.finished;
659
+ ```
660
+
661
+ **When to reach for WAAPI over Framer Motion:**
662
+ - Vanilla JS components (no React)
663
+ - Imperative animations triggered by scroll/pointer math
664
+ - Cases where bundle size matters and you need only one or two animations
665
+ - Animations that must be cancelled/reversed programmatically based on external state
666
+
667
+ ---
668
+
669
+ ## Framer Motion Hardware-Acceleration Gotcha
670
+
671
+ ### The Problem
672
+
673
+ `motion.div` with shorthand props (`x`, `y`, `scale`) computes values on the **main thread via rAF** and writes to `style.transform`. This is fine at rest but causes jank during heavy renders (page load, data fetching, React Suspense boundaries resolving).
674
+
675
+ Passing a plain string via the `style` prop (`transform: "translateX(100px)"`) sets the value directly as a CSS property, allowing the **GPU compositor** to handle it without main-thread involvement.
676
+
677
+ ```tsx
678
+ // Main thread — can jank during heavy renders
679
+ <motion.div animate={{ x: 100 }} />
680
+
681
+ // GPU compositor — unaffected by main-thread load
682
+ <motion.div style={{ transform: "translateX(100px)" }} />
683
+
684
+ // Hybrid: use CSS variables for dynamic values that stay on compositor
685
+ <motion.div style={{ "--x": x } as React.CSSProperties} className="translate-x-[--x]" />
686
+ ```
687
+
688
+ ### When This Matters
689
+
690
+ - Initial page loads with concurrent data fetching
691
+ - Lists with 50+ animated items
692
+ - Shared layout animations during route transitions
693
+ - Any animation that must feel smooth during React's reconciliation work
694
+
695
+ ### Canonical Example: Vercel Shared-Layout → CSS Migration
696
+
697
+ Vercel's site previously used Framer Motion `layoutId` for shared layout animations on their nav. Under heavy page-load conditions, the animations janked because motion values were being computed on the same thread as hydration. They migrated to CSS `view-transition-name` + `::view-transition-old/new`, which runs entirely off the main thread, eliminating the jank.
698
+
699
+ ---
700
+
701
+ ## Motion Cohesion & Personality
702
+
703
+ Motion values are a **design decision**, not a technical default. They communicate the personality of the product.
704
+
705
+ | Context | Recommended style | Why |
706
+ |---------|------------------|-----|
707
+ | Data dashboards, admin UIs | Crisp, fast `ease-out` (150–200ms) | Respects user's task focus; no distraction |
708
+ | Consumer apps, notifications | Slightly slower `ease` (220–280ms) | Feels polished; Sonner toast is the reference |
709
+ | Drag-to-dismiss, physical affordances | Underdamped spring with bounce | Mimics real physics; satisfying snap-back |
710
+ | Interruptible UI (toggles, toasts) | `bounce: 0`, transitions not keyframes | Must retarget without pop |
711
+ | Height + opacity combos | Trial and error per library | `height: auto` is not animatable in CSS; each library handles it differently |
712
+
713
+ **Do not mix** snappy dashboard animations with bouncy spring animations in the same product — the conflicting personalities create a sense that the UI was assembled from parts.
714
+
715
+ ---
716
+
717
+ ## Next-Day Slow-Motion Review Process
718
+
719
+ Fresh eyes catch what in-the-moment iteration misses. Animations feel correct when you are building them because your brain fills in the intent.
720
+
721
+ ### Process
722
+
723
+ 1. Come back the next day before reopening the feature branch
724
+ 2. Temporarily multiply all durations by 2–5× in a local override
725
+ 3. Open DevTools → Animations panel → step frame by frame
726
+ 4. Test on a real device via USB (Safari remote devtools for iOS; Chrome remote debugger for Android)
727
+
728
+ ### Checklist
729
+
730
+ - [ ] Color transitions are smooth with no intermediate hue shift
731
+ - [ ] Easing feel matches the intended personality (crisp vs playful)
732
+ - [ ] `transform-origin` is correct (elements scale/rotate from the right anchor point)
733
+ - [ ] Multiple properties animating together stay in sync (opacity and translate should peak together)
734
+ - [ ] Touch and gesture animations respond correctly to mid-gesture interruption
735
+ - [ ] Animation does not interfere with screen readers (`prefers-reduced-motion` respected)
736
+
737
+ ```css
738
+ /* Local slow-motion override — remove before commit */
739
+ *, *::before, *::after {
740
+ animation-duration: 4s !important;
741
+ transition-duration: 4s !important;
742
+ }
743
+ ```
744
+
745
+ ---
746
+
747
+ ## Disney's 12 Principles — UX Mapping
748
+
749
+ <!-- STUB: Disney's 12 Principles UX mapping — Phase 19.6 will author this section -->
750
+ <!-- Cross-reference: reference/motion-easings.md, reference/motion-spring.md -->
751
+
752
+ *This section is reserved for Phase 19.6 (Design Philosophy Layer). A full UX mapping of all 12 principles will be authored there and this stub will be replaced.*
753
+
754
+ See also: `reference/motion-easings.md`, `reference/motion-spring.md`, `reference/framer-motion-patterns.md`