@its-thepoe/design-motion-principles 1.0.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/SKILL.md +334 -0
- package/audit-checklist.md +137 -0
- package/package.json +18 -0
- package/references/accessibility.md +52 -0
- package/references/common-mistakes.md +158 -0
- package/references/emil-kowalski.md +355 -0
- package/references/jakub-krehel.md +317 -0
- package/references/jhey-tompkins.md +367 -0
- package/references/performance.md +82 -0
- package/references/philosophy.md +108 -0
- package/references/technical-principles.md +527 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# Technical Principles
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## 1. Enter & Exit Animations
|
|
6
|
+
|
|
7
|
+
### Enter Animation Recipe (Jakub)
|
|
8
|
+
A standard enter animation combines three properties:
|
|
9
|
+
- **Opacity**: 0 → 1
|
|
10
|
+
- **TranslateY**: ~8px → 0 (or calc(-100% - 4px) for full container slides)
|
|
11
|
+
- **Blur**: 4px → 0px
|
|
12
|
+
|
|
13
|
+
```jsx
|
|
14
|
+
initial={{ opacity: 0, translateY: "calc(-100% - 4px)", filter: "blur(4px)" }}
|
|
15
|
+
animate={{ opacity: 1, translateY: 0, filter: "blur(0px)" }}
|
|
16
|
+
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Why blur?** It creates a "materializing" effect that feels more physical than opacity alone. The element appears to come into focus, not just fade in.
|
|
20
|
+
|
|
21
|
+
### Exit Animation Subtlety (Jakub)
|
|
22
|
+
**Key Insight**: Exit animations should be subtler than enter animations.
|
|
23
|
+
|
|
24
|
+
When a component exits, it doesn't need the same amount of movement or attention as when entering. The user's focus is moving to what comes next, not what's leaving.
|
|
25
|
+
|
|
26
|
+
```jsx
|
|
27
|
+
// Instead of full exit movement:
|
|
28
|
+
exit={{ translateY: "calc(-100% - 4px)" }}
|
|
29
|
+
|
|
30
|
+
// Use a subtle fixed value:
|
|
31
|
+
exit={{ translateY: "-12px", opacity: 0, filter: "blur(4px)" }}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Why this works**: Exits become softer, less jarring, and don't compete for attention with whatever is entering or remaining.
|
|
35
|
+
|
|
36
|
+
**When NOT to use subtle exits**:
|
|
37
|
+
- When the exit itself is meaningful (user-initiated dismissal)
|
|
38
|
+
- When you need to emphasize something leaving (error clearing, item deletion)
|
|
39
|
+
- Full-page transitions where directional continuity matters
|
|
40
|
+
|
|
41
|
+
### Fill Mode for Persistence (Jhey)
|
|
42
|
+
Use `animation-fill-mode` to prevent jarring visual resets:
|
|
43
|
+
- `forwards`: Element retains animation styling after completion
|
|
44
|
+
- `backwards`: Element retains style from first keyframe before animation starts
|
|
45
|
+
- `both`: Retains styling in both directions
|
|
46
|
+
|
|
47
|
+
**Critical for**: Fade-in sequences with delays. Without `backwards`, elements flash at full opacity before their delayed animation starts, then pop to invisible, then fade in.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 2. Easing & Timing
|
|
52
|
+
|
|
53
|
+
### Duration Impacts Naturalness
|
|
54
|
+
> "Duration is all about timing, and timing has a big impact on the movement's naturalness." — Jhey Tompkins
|
|
55
|
+
|
|
56
|
+
### Custom Easing is Essential (Emil)
|
|
57
|
+
> "Easing is the most important part of any animation. It can make a bad animation feel great."
|
|
58
|
+
|
|
59
|
+
Built-in CSS easing (`ease`, `ease-in-out`) lacks strength. Always use custom Bézier curves for professional results. Resources: easing.dev, easings.co
|
|
60
|
+
|
|
61
|
+
### Easing Selection Guidelines (Jhey)
|
|
62
|
+
Each easing curve communicates something to the viewer. **Context matters more than rules.**
|
|
63
|
+
|
|
64
|
+
| Easing | Feel | Good For |
|
|
65
|
+
|--------|------|----------|
|
|
66
|
+
| `ease-out` | Fast start, gentle stop | Elements entering view (arriving) |
|
|
67
|
+
| `ease-in` | Gentle start, fast exit | Elements leaving view (departing) |
|
|
68
|
+
| `ease-in-out` | Gentle both ends | Elements changing state while visible |
|
|
69
|
+
| `linear` | Constant speed | Continuous loops, progress indicators |
|
|
70
|
+
| `spring` | Natural deceleration | Interactive elements, professional UI |
|
|
71
|
+
|
|
72
|
+
**The Context Rule**:
|
|
73
|
+
> "You wouldn't use 'Elastic' for a bank's website, but it might work perfectly for an energetic site for children."
|
|
74
|
+
|
|
75
|
+
Brand personality should drive easing choices. A playful brand can use bouncy, elastic easing. A professional brand should use subtle springs or ease-out.
|
|
76
|
+
|
|
77
|
+
**When NOT to use bouncy/elastic easing**:
|
|
78
|
+
- Professional/enterprise applications
|
|
79
|
+
- Frequently repeated interactions (gets tiresome)
|
|
80
|
+
- Error states or serious UI
|
|
81
|
+
- When users need to complete tasks quickly
|
|
82
|
+
|
|
83
|
+
### Spring Animations (Jakub)
|
|
84
|
+
Prefer spring animations over linear/ease for more natural-feeling motion:
|
|
85
|
+
```jsx
|
|
86
|
+
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
|
|
87
|
+
transition={{ type: "spring", duration: 0.55, bounce: 0.1 }}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Why `bounce: 0`?** It gives smooth deceleration without overshoot—professional and refined. Reserve bounce > 0 for playful contexts.
|
|
91
|
+
|
|
92
|
+
### The linear() Function (Jhey)
|
|
93
|
+
CSS `linear()` enables bounce, elastic, and spring effects in pure CSS:
|
|
94
|
+
```css
|
|
95
|
+
:root {
|
|
96
|
+
--bounce-easing: linear(
|
|
97
|
+
0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141 13.6%, 0.25, 0.391, 0.563, 0.765,
|
|
98
|
+
1, 0.891 40.9%, 0.848, 0.813, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785,
|
|
99
|
+
0.813, 0.848, 0.891 68.2%, 1 72.7%, 0.973, 0.953, 0.941, 0.938, 0.941, 0.953,
|
|
100
|
+
0.973, 1, 0.988, 0.984, 0.988, 1
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use Jake Archibald's linear() generator for custom curves: https://linear-easing-generator.netlify.app/
|
|
106
|
+
|
|
107
|
+
### Stagger Techniques (Jhey)
|
|
108
|
+
`animation-delay` only applies once (not per iteration). Approaches:
|
|
109
|
+
|
|
110
|
+
1. **Different delays with finite iterations** — Works for one-time sequences
|
|
111
|
+
2. **Pad keyframes** to create stagger within the animation:
|
|
112
|
+
```css
|
|
113
|
+
@keyframes spin {
|
|
114
|
+
0%, 50% { transform: rotate(0deg); }
|
|
115
|
+
100% { transform: rotate(360deg); }
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
3. **Negative delays** for "already in progress" effects:
|
|
120
|
+
```css
|
|
121
|
+
.element { animation-delay: calc(var(--index) * -0.2s); }
|
|
122
|
+
```
|
|
123
|
+
This makes animations appear mid-flight from the start—useful for staggered continuous animations.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 3. Visual Effects
|
|
128
|
+
|
|
129
|
+
### Shadows Instead of Borders (Jakub)
|
|
130
|
+
In light mode, prefer subtle multi-layer box-shadows over solid borders:
|
|
131
|
+
```css
|
|
132
|
+
.card {
|
|
133
|
+
box-shadow:
|
|
134
|
+
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
|
|
135
|
+
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
|
|
136
|
+
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Slightly darker on hover */
|
|
140
|
+
.card:hover {
|
|
141
|
+
box-shadow:
|
|
142
|
+
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
|
|
143
|
+
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
|
|
144
|
+
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Why shadows over borders?**
|
|
149
|
+
- Shadows adapt to any background (images, gradients, varied colors) because they use transparency
|
|
150
|
+
- Borders are solid colors that may clash with dynamic backgrounds
|
|
151
|
+
- Multi-layer shadows create depth; single borders feel flat
|
|
152
|
+
- Shadows can be transitioned smoothly with `transition: box-shadow`
|
|
153
|
+
|
|
154
|
+
**When borders are fine**:
|
|
155
|
+
- Dark mode (shadows less visible anyway)
|
|
156
|
+
- When you need hard edges intentionally
|
|
157
|
+
- Simple interfaces where depth isn't needed
|
|
158
|
+
|
|
159
|
+
### Gradients & Color Spaces (Jakub)
|
|
160
|
+
- Use `oklch` for gradients to avoid muddy midpoints:
|
|
161
|
+
```css
|
|
162
|
+
element { background: linear-gradient(in oklch, blue, red); }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- **Color hints** control where the blend midpoint appears (different from color stops)
|
|
166
|
+
- Layer gradients with `background-blend-mode` for unique effects
|
|
167
|
+
|
|
168
|
+
**Why oklch?** It interpolates through perceptually uniform color space, avoiding the gray/muddy zone that sRGB hits when blending complementary colors.
|
|
169
|
+
|
|
170
|
+
### Blur as a Signal (Jakub)
|
|
171
|
+
Blur (via `filter: blur()`) combined with opacity and translate creates a "materializing" effect. Use blur to signal:
|
|
172
|
+
- **Entering focus**: blur → sharp
|
|
173
|
+
- **Losing relevance**: sharp → blur
|
|
174
|
+
- **State transitions**: blur during, sharp after
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 4. Optical Alignment
|
|
179
|
+
|
|
180
|
+
### Geometric vs. Optical (Jakub)
|
|
181
|
+
> "Sometimes it's necessary to break out of geometric alignment to make things feel visually balanced."
|
|
182
|
+
|
|
183
|
+
**Buttons with icons**: Reduce padding on the icon side so content appears centered:
|
|
184
|
+
```
|
|
185
|
+
[ Icon Text ] ← Geometric (mathematically centered, feels off)
|
|
186
|
+
[ Icon Text ] ← Optical (visually centered, feels right)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Play button icons**: The triangle points right, creating visual weight on the left. Shift it slightly right to appear centered.
|
|
190
|
+
|
|
191
|
+
**Icons in general**: Many icon packs account for optical balance, but asymmetric shapes (arrows, play, chevrons) may need manual margin/padding adjustment.
|
|
192
|
+
|
|
193
|
+
**The rule**: If it looks wrong despite being mathematically correct, trust your eyes and adjust.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 5. Icon & State Animations (Jakub)
|
|
198
|
+
|
|
199
|
+
### Contextual Icon Transitions
|
|
200
|
+
When icons change contextually (copy → check, loading → done), animate:
|
|
201
|
+
- Opacity
|
|
202
|
+
- Scale
|
|
203
|
+
- Blur
|
|
204
|
+
|
|
205
|
+
```jsx
|
|
206
|
+
<AnimatePresence mode="wait">
|
|
207
|
+
{isCopied ? (
|
|
208
|
+
<motion.div
|
|
209
|
+
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
|
|
210
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
211
|
+
exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
|
|
212
|
+
>
|
|
213
|
+
<CheckIcon />
|
|
214
|
+
</motion.div>
|
|
215
|
+
) : (
|
|
216
|
+
<motion.div ...>
|
|
217
|
+
<CopyIcon />
|
|
218
|
+
</motion.div>
|
|
219
|
+
)}
|
|
220
|
+
</AnimatePresence>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Why animate icon swaps?** Instant swaps feel jarring and can be missed. Animated transitions:
|
|
224
|
+
- Draw attention to the state change
|
|
225
|
+
- Feel responsive and polished
|
|
226
|
+
- Give the user confidence their action registered
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## 6. Shared Layout Animations (Jakub)
|
|
231
|
+
|
|
232
|
+
### FLIP Technique via layoutId
|
|
233
|
+
Motion's `layoutId` prop enables smooth transitions between completely different components:
|
|
234
|
+
```jsx
|
|
235
|
+
// In one location:
|
|
236
|
+
<motion.div layoutId="card" className="small-card" />
|
|
237
|
+
|
|
238
|
+
// In another location:
|
|
239
|
+
<motion.div layoutId="card" className="large-card" />
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Motion automatically animates between them using the FLIP technique (First, Last, Inverse, Play).
|
|
243
|
+
|
|
244
|
+
### Best Practices
|
|
245
|
+
- Keep elements with `layoutId` **outside** of `AnimatePresence` to avoid conflicts
|
|
246
|
+
- If inside `AnimatePresence`, the initial/exit animations will trigger during layout animation (looks bad with opacity)
|
|
247
|
+
- Multiple elements can animate if each has a unique `layoutId`
|
|
248
|
+
- Works for different heights, widths, positions, and even component types (card → modal)
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 7. CSS Custom Properties & @property (Jhey)
|
|
253
|
+
|
|
254
|
+
### Type Specification Unlocks Animation
|
|
255
|
+
The `@property` rule lets you declare types for CSS variables, enabling smooth interpolation:
|
|
256
|
+
|
|
257
|
+
```css
|
|
258
|
+
@property --hue {
|
|
259
|
+
initial-value: 0;
|
|
260
|
+
inherits: false;
|
|
261
|
+
syntax: '<number>';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@keyframes rainbow {
|
|
265
|
+
to { --hue: 360; }
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Available types**: length, number, percentage, color, angle, time, integer, transform-list
|
|
270
|
+
|
|
271
|
+
**Why this matters**: Without `@property`, CSS sees custom properties as strings. Strings can't interpolate—they just swap. With a declared type, the browser knows how to smoothly transition between values.
|
|
272
|
+
|
|
273
|
+
### Decompose Complex Transforms
|
|
274
|
+
Instead of animating a monolithic transform (which can't interpolate curved paths), split into typed properties:
|
|
275
|
+
|
|
276
|
+
```css
|
|
277
|
+
@property --x { syntax: '<percentage>'; initial-value: 0%; inherits: false; }
|
|
278
|
+
@property --y { syntax: '<percentage>'; initial-value: 0%; inherits: false; }
|
|
279
|
+
|
|
280
|
+
.ball {
|
|
281
|
+
transform: translateX(var(--x)) translateY(var(--y));
|
|
282
|
+
animation: throw 1s;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@keyframes throw {
|
|
286
|
+
0% { --x: -500%; }
|
|
287
|
+
50% { --y: -250%; }
|
|
288
|
+
100% { --x: 500%; }
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
This creates curved motion paths that would be impossible with standard transform animation—the ball arcs through space rather than moving in straight lines.
|
|
293
|
+
|
|
294
|
+
### Scoped Variables for Dynamic Behavior (Jhey)
|
|
295
|
+
CSS custom properties respect scope, enabling powerful patterns:
|
|
296
|
+
```css
|
|
297
|
+
.item { --delay: 0; animation-delay: calc(var(--delay) * 100ms); }
|
|
298
|
+
.item:nth-child(1) { --delay: 0; }
|
|
299
|
+
.item:nth-child(2) { --delay: 1; }
|
|
300
|
+
.item:nth-child(3) { --delay: 2; }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Use scoped variables to create varied behavior from a single animation definition.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## 8. 3D CSS (Jhey)
|
|
308
|
+
|
|
309
|
+
### Think in Cuboids
|
|
310
|
+
> "Think in cubes instead of boxes" — Jhey Tompkins
|
|
311
|
+
|
|
312
|
+
Complex 3D scenes are assemblies of cube-shaped elements (like LEGO). Decompose any 3D object into cuboids.
|
|
313
|
+
|
|
314
|
+
### Essential Setup
|
|
315
|
+
```css
|
|
316
|
+
.scene {
|
|
317
|
+
transform-style: preserve-3d;
|
|
318
|
+
perspective: 1000px;
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Responsive 3D
|
|
323
|
+
Use CSS variables for dimensions and `vmin` units:
|
|
324
|
+
```css
|
|
325
|
+
.cube {
|
|
326
|
+
--size: 10vmin;
|
|
327
|
+
width: var(--size);
|
|
328
|
+
height: var(--size);
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## 9. Clip-Path Animations (Emil)
|
|
335
|
+
|
|
336
|
+
### Why clip-path?
|
|
337
|
+
- Hardware-accelerated rendering
|
|
338
|
+
- No layout shifts
|
|
339
|
+
- No additional DOM elements needed
|
|
340
|
+
- Smoother than width/height animations
|
|
341
|
+
|
|
342
|
+
### Basic Syntax
|
|
343
|
+
```css
|
|
344
|
+
clip-path: inset(top right bottom left);
|
|
345
|
+
clip-path: circle(radius at x y);
|
|
346
|
+
clip-path: polygon(coordinates);
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Image Reveal Effect
|
|
350
|
+
```css
|
|
351
|
+
.reveal {
|
|
352
|
+
clip-path: inset(0 0 100% 0); /* Hidden */
|
|
353
|
+
animation: reveal 1s forwards cubic-bezier(0.77, 0, 0.175, 1);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
@keyframes reveal {
|
|
357
|
+
to { clip-path: inset(0 0 0 0); } /* Fully visible */
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Tab Transitions
|
|
362
|
+
Duplicate tab lists with different styling. Animate the overlay's clip-path to reveal only the active tab—creates smooth color transitions without timing issues.
|
|
363
|
+
|
|
364
|
+
### Scroll-Driven with clip-path
|
|
365
|
+
```javascript
|
|
366
|
+
const clipPathY = useTransform(scrollYProgress, [0, 1], ["100%", "0%"]);
|
|
367
|
+
const motionClipPath = useMotionTemplate`inset(0 0 ${clipPathY} 0)`;
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Text Mask Effect
|
|
371
|
+
Stack elements with complementary clip-paths:
|
|
372
|
+
```css
|
|
373
|
+
.top { clip-path: inset(0 0 50% 0); } /* Shows top half */
|
|
374
|
+
.bottom { clip-path: inset(50% 0 0 0); } /* Shows bottom half */
|
|
375
|
+
```
|
|
376
|
+
Adjust values on mouse interaction for seamless transitions.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## 10. Button & Interactive Feedback (Emil)
|
|
381
|
+
|
|
382
|
+
### Scale on Press
|
|
383
|
+
Add immediate tactile feedback:
|
|
384
|
+
```css
|
|
385
|
+
button:active {
|
|
386
|
+
transform: scale(0.97);
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Don't Animate from scale(0)
|
|
391
|
+
```jsx
|
|
392
|
+
// BAD: Unnatural motion
|
|
393
|
+
initial={{ scale: 0 }}
|
|
394
|
+
|
|
395
|
+
// GOOD: Natural, gentle motion
|
|
396
|
+
initial={{ scale: 0.9, opacity: 0 }}
|
|
397
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Tooltip Delay Pattern
|
|
401
|
+
First tooltip in a group: delay + animation. Subsequent tooltips: instant.
|
|
402
|
+
```css
|
|
403
|
+
[data-instant] {
|
|
404
|
+
transition-duration: 0ms;
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Blur as a Bridge
|
|
409
|
+
When state transitions aren't smooth enough, add blur to mask imperfections:
|
|
410
|
+
```css
|
|
411
|
+
.transitioning {
|
|
412
|
+
filter: blur(2px);
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## 11. CSS Transitions vs Keyframes (Emil)
|
|
419
|
+
|
|
420
|
+
### Interruptibility Problem
|
|
421
|
+
CSS keyframes can't be interrupted mid-animation. When users rapidly trigger actions, elements "jump" to new positions rather than smoothly retargeting.
|
|
422
|
+
|
|
423
|
+
**Solution**: Use CSS transitions with state-driven classes:
|
|
424
|
+
```jsx
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
setMounted(true);
|
|
427
|
+
}, []);
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
```css
|
|
431
|
+
.element {
|
|
432
|
+
transform: translateY(100%);
|
|
433
|
+
transition: transform 400ms ease;
|
|
434
|
+
}
|
|
435
|
+
.element.mounted {
|
|
436
|
+
transform: translateY(0);
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Direct Style Updates for Performance
|
|
441
|
+
CSS variables cause style recalculation across all children. For frequent updates (drag operations), update styles directly:
|
|
442
|
+
|
|
443
|
+
```javascript
|
|
444
|
+
// BAD: CSS variable (expensive cascade)
|
|
445
|
+
element.style.setProperty('--drag-y', `${y}px`);
|
|
446
|
+
|
|
447
|
+
// GOOD: Direct style (no cascade)
|
|
448
|
+
element.style.transform = `translateY(${y}px)`;
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Momentum-Based Dismissal
|
|
452
|
+
Use velocity (distance / time) instead of distance thresholds:
|
|
453
|
+
```javascript
|
|
454
|
+
const velocity = dragDistance / elapsedTime;
|
|
455
|
+
if (velocity > 0.11) dismiss();
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Fast, short gestures should work—users shouldn't need to drag far.
|
|
459
|
+
|
|
460
|
+
### Damping for Natural Boundaries
|
|
461
|
+
When dragging past boundaries, reduce movement progressively. Things in real life slow down before stopping.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## 12. Spring Physics (Emil)
|
|
466
|
+
|
|
467
|
+
### Key Parameters
|
|
468
|
+
| Parameter | Effect |
|
|
469
|
+
|-----------|--------|
|
|
470
|
+
| **Stiffness** | How quickly spring reaches target (higher = faster) |
|
|
471
|
+
| **Damping** | How quickly oscillations settle (higher = less bounce) |
|
|
472
|
+
| **Mass** | Weight of object (higher = more momentum) |
|
|
473
|
+
|
|
474
|
+
### Spring for Mouse Position
|
|
475
|
+
```javascript
|
|
476
|
+
const springConfig = { stiffness: 300, damping: 30 };
|
|
477
|
+
const x = useSpring(mouseX, springConfig);
|
|
478
|
+
const y = useSpring(mouseY, springConfig);
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Use `useSpring` for any value that should interpolate smoothly rather than snap—nothing in the real world changes instantly.
|
|
482
|
+
|
|
483
|
+
### Interruptibility
|
|
484
|
+
Great animations can be interrupted mid-play:
|
|
485
|
+
- Framer Motion supports interruption natively
|
|
486
|
+
- CSS transitions allow smooth interruption before completion
|
|
487
|
+
- Test by clicking rapidly—animations should blend, not queue
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## 12. Origin-Aware Animations (Emil)
|
|
492
|
+
|
|
493
|
+
Animations should originate from their logical source:
|
|
494
|
+
|
|
495
|
+
```css
|
|
496
|
+
/* Dropdown from button should expand from button, not center */
|
|
497
|
+
.dropdown {
|
|
498
|
+
transform-origin: top center;
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Component library support:**
|
|
503
|
+
- Base UI: `--transform-origin` CSS variable
|
|
504
|
+
- Radix UI: `--radix-dropdown-menu-content-transform-origin`
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## 13. Scroll-Driven Animations (Jhey)
|
|
509
|
+
|
|
510
|
+
### The Core Problem
|
|
511
|
+
Scroll-driven animations are tied to scroll **speed**. If users scroll slowly, animations play slowly. This feels wrong for most UI—you want animations to trigger at a scroll position, not be controlled by scroll speed.
|
|
512
|
+
|
|
513
|
+
### Duration Control Pattern
|
|
514
|
+
Use two coordinated animations:
|
|
515
|
+
1. **Trigger animation**: Scroll-driven, toggles a custom property when element enters view
|
|
516
|
+
2. **Main animation**: Traditional duration-based, activated via Style Query
|
|
517
|
+
|
|
518
|
+
This severs the connection between scroll speed and animation timing—the animation runs over a fixed duration once triggered, regardless of how fast the user scrolled.
|
|
519
|
+
|
|
520
|
+
### Progressive Enhancement
|
|
521
|
+
Always provide fallbacks:
|
|
522
|
+
```javascript
|
|
523
|
+
// IntersectionObserver fallback for browsers without scroll-driven animation support
|
|
524
|
+
if (!CSS.supports('animation-timeline', 'scroll()')) {
|
|
525
|
+
// Use IntersectionObserver instead
|
|
526
|
+
}
|
|
527
|
+
```
|