@hegemonart/get-design-done 1.16.0 → 1.19.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.
- package/.claude-plugin/marketplace.json +12 -4
- package/.claude-plugin/plugin.json +22 -4
- package/CHANGELOG.md +111 -0
- package/README.md +27 -2
- package/agents/design-auditor.md +65 -1
- package/agents/design-context-builder.md +6 -1
- package/agents/design-doc-writer.md +21 -0
- package/agents/design-executor.md +22 -4
- package/agents/design-pattern-mapper.md +62 -0
- package/agents/design-phase-researcher.md +1 -1
- package/agents/motion-mapper.md +74 -9
- package/agents/token-mapper.md +8 -0
- package/package.json +16 -2
- package/reference/components/README.md +27 -23
- package/reference/components/alert.md +198 -0
- package/reference/components/badge.md +202 -0
- package/reference/components/breadcrumbs.md +198 -0
- package/reference/components/chip.md +209 -0
- package/reference/components/command-palette.md +228 -0
- package/reference/components/date-picker.md +227 -0
- package/reference/components/file-upload.md +219 -0
- package/reference/components/list.md +217 -0
- package/reference/components/menu.md +212 -0
- package/reference/components/navbar.md +211 -0
- package/reference/components/pagination.md +205 -0
- package/reference/components/progress.md +210 -0
- package/reference/components/rich-text-editor.md +226 -0
- package/reference/components/sidebar.md +211 -0
- package/reference/components/skeleton.md +197 -0
- package/reference/components/slider.md +208 -0
- package/reference/components/stepper.md +220 -0
- package/reference/components/table.md +229 -0
- package/reference/components/toast.md +200 -0
- package/reference/components/tree.md +225 -0
- package/reference/css-grid-layout.md +835 -0
- package/reference/data-visualization.md +333 -0
- package/reference/external/NOTICE.hyperframes +28 -0
- package/reference/form-patterns.md +245 -0
- package/reference/image-optimization.md +582 -0
- package/reference/information-architecture.md +255 -0
- package/reference/motion-advanced.md +754 -0
- package/reference/motion-easings.md +381 -0
- package/reference/motion-interpolate.md +282 -0
- package/reference/motion-spring.md +234 -0
- package/reference/motion-transition-taxonomy.md +155 -0
- package/reference/motion.md +20 -0
- package/reference/onboarding-progressive-disclosure.md +250 -0
- package/reference/output-contracts/motion-map.schema.json +135 -0
- package/reference/platforms.md +346 -0
- package/reference/registry.json +445 -220
- package/reference/registry.schema.json +4 -0
- package/reference/rtl-cjk-cultural.md +353 -0
- package/reference/user-research.md +360 -0
- package/reference/variable-fonts-loading.md +532 -0
- package/scripts/lib/easings.cjs +280 -0
- package/scripts/lib/parse-contract.cjs +220 -0
- package/scripts/lib/spring.cjs +160 -0
- 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`
|