liteagents 2.5.3 → 2.6.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/CHANGELOG.md +31 -0
- package/README.md +51 -45
- package/installer/cli.js +1 -1
- package/installer/installation-engine.js +82 -21
- package/installer/package-manager.js +34 -3
- package/package.json +3 -2
- package/packages/ampcode/AGENT.md +1 -0
- package/packages/ampcode/commands/friction/friction.js +19 -4
- package/packages/ampcode/commands/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/ampcode/commands/live-canvas/README.md +273 -0
- package/packages/ampcode/commands/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/ampcode/commands/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/ampcode/commands/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/ampcode/commands/live-canvas.md +1104 -0
- package/packages/claude/CLAUDE.md +1 -0
- package/packages/claude/commands/friction/friction.js +19 -4
- package/packages/claude/plugins/live-canvas-marketplace/.claude-plugin/marketplace.json +14 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/.claude-plugin/plugin.json +18 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/README.md +89 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +1142 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package.json +17 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/schema.json +37 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/server.js +179 -0
- package/packages/claude/plugins/live-canvas-marketplace/setup.sh +61 -0
- package/packages/claude/skills/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/claude/skills/live-canvas/INTEGRATION_NOTES.md +210 -0
- package/packages/claude/skills/live-canvas/README.md +273 -0
- package/packages/claude/skills/live-canvas/SKILL.md +1119 -0
- package/packages/claude/skills/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/claude/skills/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/claude/skills/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/claude/variants.json +2 -1
- package/packages/droid/AGENTS.md +1 -0
- package/packages/droid/commands/friction/friction.js +19 -4
- package/packages/droid/commands/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/droid/commands/live-canvas/README.md +273 -0
- package/packages/droid/commands/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/droid/commands/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/droid/commands/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/droid/commands/live-canvas.md +1104 -0
- package/packages/opencode/AGENTS.md +1 -0
- package/packages/opencode/command/friction/friction.js +19 -4
- package/packages/opencode/command/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/opencode/command/live-canvas/README.md +273 -0
- package/packages/opencode/command/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/opencode/command/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/opencode/command/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/opencode/command/live-canvas.md +1104 -0
- package/packages/opencode/opencode.jsonc +4 -0
- package/packages/subagentic-manual.md +15 -12
|
@@ -0,0 +1,2140 @@
|
|
|
1
|
+
# Design Principles Reference
|
|
2
|
+
|
|
3
|
+
This document contains curated best practices from world-class designers and design systems. Reference these principles when generating design variations.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Part 1: UX Foundations
|
|
8
|
+
|
|
9
|
+
### Jakob Nielsen's 10 Usability Heuristics
|
|
10
|
+
|
|
11
|
+
1. **Visibility of system status** - Always keep users informed through appropriate feedback within reasonable time
|
|
12
|
+
2. **Match between system and real world** - Use familiar language, concepts, and conventions
|
|
13
|
+
3. **User control and freedom** - Provide clear "emergency exits" (undo, cancel, back)
|
|
14
|
+
4. **Consistency and standards** - Follow platform conventions; same words mean same things
|
|
15
|
+
5. **Error prevention** - Eliminate error-prone conditions or ask for confirmation
|
|
16
|
+
6. **Recognition over recall** - Minimize memory load; make options visible
|
|
17
|
+
7. **Flexibility and efficiency** - Provide accelerators for expert users (shortcuts, defaults)
|
|
18
|
+
8. **Aesthetic and minimalist design** - Remove irrelevant information; every element competes
|
|
19
|
+
9. **Help users recover from errors** - Plain language errors with constructive solutions
|
|
20
|
+
10. **Help and documentation** - Provide concise, task-focused help when needed
|
|
21
|
+
|
|
22
|
+
### Don Norman's Design Principles
|
|
23
|
+
|
|
24
|
+
- **Affordances** - Design elements should suggest their usage
|
|
25
|
+
- **Signifiers** - Visual cues that indicate where actions should happen
|
|
26
|
+
- **Mapping** - Controls should relate spatially to their effects
|
|
27
|
+
- **Feedback** - Every action needs a perceivable response
|
|
28
|
+
- **Conceptual model** - Users should understand how the system works
|
|
29
|
+
|
|
30
|
+
### Cognitive Load Principles
|
|
31
|
+
|
|
32
|
+
- **Limit choices** - 5-7 items max in navigation; 3-4 options in decisions
|
|
33
|
+
- **Progressive disclosure** - Show only what's needed at each step
|
|
34
|
+
- **Chunking** - Group related items; break long forms into steps
|
|
35
|
+
- **Visual hierarchy** - Guide attention with size, color, contrast, position
|
|
36
|
+
- **Reduce cognitive friction** - Minimize decisions, clicks, and reading
|
|
37
|
+
|
|
38
|
+
### URL & State Principles
|
|
39
|
+
|
|
40
|
+
- **URL state reflection** - Important UI state (filters, tabs, pagination) should be in the URL
|
|
41
|
+
- **Shareable links** - Users should be able to share/bookmark the current view
|
|
42
|
+
- **Browser navigation** - Back/forward buttons should work as expected
|
|
43
|
+
|
|
44
|
+
### Destructive Actions
|
|
45
|
+
|
|
46
|
+
- **Confirmation required** - Delete, remove, and irreversible actions need explicit confirmation
|
|
47
|
+
- **Clear consequences** - State exactly what will happen ("This will permanently delete 5 files")
|
|
48
|
+
- **Recovery path** - Prefer soft delete with undo over immediate permanent deletion
|
|
49
|
+
- **Visual distinction** - Destructive buttons use warning colors (red) and distinct styling
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Part 2: Visual Design Systems
|
|
54
|
+
|
|
55
|
+
### Typography (from iA, Stripe, Linear)
|
|
56
|
+
|
|
57
|
+
**Hierarchy:**
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Display: 32-48px, -0.02em tracking, 700 weight
|
|
61
|
+
Heading 1: 24-32px, -0.02em tracking, 600 weight
|
|
62
|
+
Heading 2: 20-24px, -0.01em tracking, 600 weight
|
|
63
|
+
Heading 3: 16-18px, normal tracking, 600 weight
|
|
64
|
+
Body: 14-16px, normal tracking, 400 weight
|
|
65
|
+
Caption: 12-13px, +0.01em tracking, 400-500 weight
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Best practices:**
|
|
69
|
+
|
|
70
|
+
- Max 60-75 characters per line for readability
|
|
71
|
+
- Line height: 1.4-1.6 for body text, 1.2-1.3 for headings
|
|
72
|
+
- Use weight contrast (400 vs 600) more than size contrast
|
|
73
|
+
- Limit to 2 font families maximum
|
|
74
|
+
- System fonts for performance: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
|
|
75
|
+
|
|
76
|
+
**Typographic details:**
|
|
77
|
+
|
|
78
|
+
- Use proper ellipsis `…` not `...` (three dots)
|
|
79
|
+
- Use curly quotes `"` `"` not straight quotes `"`
|
|
80
|
+
- Non-breaking spaces for values: `10 MB`, `5 items` (use ` ` or `\u00A0`)
|
|
81
|
+
- `font-variant-numeric: tabular-nums` for numbers in tables, counters, prices
|
|
82
|
+
- `text-wrap: balance` for headings (prevents orphans/widows)
|
|
83
|
+
- `text-wrap: pretty` for body text (better line breaks)
|
|
84
|
+
|
|
85
|
+
### Spacing System (8px grid)
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
4px - Tight: icon padding, inline spacing
|
|
89
|
+
8px - Base: related elements, form field padding
|
|
90
|
+
12px - Comfortable: between form fields
|
|
91
|
+
16px - Standard: section padding, card padding
|
|
92
|
+
24px - Relaxed: between sections
|
|
93
|
+
32px - Spacious: major section breaks
|
|
94
|
+
48px - Generous: page section separation
|
|
95
|
+
64px+ - Hero: landing page sections
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Spacing principles:**
|
|
99
|
+
|
|
100
|
+
- Related items closer together (Gestalt proximity)
|
|
101
|
+
- Consistent internal padding (all sides equal, or vertical > horizontal)
|
|
102
|
+
- White space is not wasted space—it creates focus
|
|
103
|
+
- Touch targets minimum 44x44px (Apple HIG)
|
|
104
|
+
|
|
105
|
+
### Color (from Stripe, Linear, Vercel)
|
|
106
|
+
|
|
107
|
+
**Neutral foundation:**
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
Background: #FFFFFF / #000000 (dark)
|
|
111
|
+
Surface: #FAFAFA / #111111 (dark)
|
|
112
|
+
Border: #E5E5E5 / #333333 (dark)
|
|
113
|
+
Text primary: #171717 / #EDEDED (dark)
|
|
114
|
+
Text secondary: #737373 / #A3A3A3 (dark)
|
|
115
|
+
Text tertiary: #A3A3A3 / #737373 (dark)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Accent usage:**
|
|
119
|
+
|
|
120
|
+
- Primary action: single brand color, used sparingly
|
|
121
|
+
- Interactive elements: consistent color for all clickable items
|
|
122
|
+
- Semantic colors: red (error), green (success), yellow (warning), blue (info)
|
|
123
|
+
- Hover states: 10% darker or add subtle background
|
|
124
|
+
- Focus states: 2px ring with offset, high contrast
|
|
125
|
+
|
|
126
|
+
**Color principles:**
|
|
127
|
+
|
|
128
|
+
- WCAG AA minimum: 4.5:1 for text, 3:1 for UI elements
|
|
129
|
+
- One primary accent color; avoid rainbow interfaces
|
|
130
|
+
- Use opacity for secondary states (hover, disabled)
|
|
131
|
+
- Dark mode: don't just invert—reduce contrast, use darker surfaces
|
|
132
|
+
|
|
133
|
+
**Dark mode setup:**
|
|
134
|
+
|
|
135
|
+
```html
|
|
136
|
+
<!-- On <html> element -->
|
|
137
|
+
<html class="dark" style="color-scheme: dark">
|
|
138
|
+
|
|
139
|
+
<!-- Theme color matching page background -->
|
|
140
|
+
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
|
141
|
+
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Content Handling
|
|
145
|
+
|
|
146
|
+
**Text truncation:**
|
|
147
|
+
|
|
148
|
+
```css
|
|
149
|
+
/* Single line truncation */
|
|
150
|
+
.truncate {
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
text-overflow: ellipsis;
|
|
153
|
+
white-space: nowrap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* Multi-line truncation */
|
|
157
|
+
.line-clamp-2 {
|
|
158
|
+
display: -webkit-box;
|
|
159
|
+
-webkit-box-orient: vertical;
|
|
160
|
+
-webkit-line-clamp: 2;
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Break long words */
|
|
165
|
+
.break-words {
|
|
166
|
+
overflow-wrap: break-word;
|
|
167
|
+
word-break: break-word;
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Flex children with text:**
|
|
172
|
+
|
|
173
|
+
```css
|
|
174
|
+
/* IMPORTANT: Flex children with text need min-w-0 to truncate properly */
|
|
175
|
+
.flex-child-with-text {
|
|
176
|
+
min-width: 0; /* Allows text to shrink below content size */
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Empty states:**
|
|
181
|
+
|
|
182
|
+
- Always design the empty state—it's the first thing users see
|
|
183
|
+
- Include helpful message + primary action
|
|
184
|
+
- Use illustration or icon to add visual interest
|
|
185
|
+
|
|
186
|
+
**Images:**
|
|
187
|
+
|
|
188
|
+
```jsx
|
|
189
|
+
// Always include explicit dimensions to prevent layout shift
|
|
190
|
+
<img
|
|
191
|
+
src="/image.jpg"
|
|
192
|
+
width={800}
|
|
193
|
+
height={600}
|
|
194
|
+
alt="Description"
|
|
195
|
+
loading="lazy" // Defer off-screen images
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
// For above-the-fold images
|
|
199
|
+
<img src="/hero.jpg" width={1200} height={800} alt="Hero" priority />
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Border Radius (from modern SaaS)
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
None (0px): Tables, dividers, full-bleed images
|
|
206
|
+
Small (4px): Buttons, inputs, tags, badges
|
|
207
|
+
Medium (8px): Cards, modals, dropdowns
|
|
208
|
+
Large (12px): Feature cards, hero elements
|
|
209
|
+
Full (9999px): Avatars, pills, toggle tracks
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Principles:**
|
|
213
|
+
|
|
214
|
+
- Consistency: pick 2-3 radius values and stick to them
|
|
215
|
+
- Nested elements: inner radius = outer radius - padding
|
|
216
|
+
- Sharp corners feel technical/precise; round feels friendly/approachable
|
|
217
|
+
|
|
218
|
+
### Shadows & Elevation (from Material, Linear)
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
Level 0: none (flat, on surface)
|
|
222
|
+
Level 1: 0 1px 2px rgba(0,0,0,0.05) - Subtle lift (cards)
|
|
223
|
+
Level 2: 0 4px 6px rgba(0,0,0,0.07) - Raised (dropdowns)
|
|
224
|
+
Level 3: 0 10px 15px rgba(0,0,0,0.1) - Floating (modals)
|
|
225
|
+
Level 4: 0 20px 25px rgba(0,0,0,0.15) - High (popovers)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Principles:**
|
|
229
|
+
|
|
230
|
+
- Shadows should feel like natural light (top-down, slight offset)
|
|
231
|
+
- Dark mode: use lighter surface colors instead of shadows
|
|
232
|
+
- Combine with subtle border for definition
|
|
233
|
+
- Interactive elements can elevate on hover
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Part 3: Component Patterns
|
|
238
|
+
|
|
239
|
+
### Buttons (from Stripe, Linear)
|
|
240
|
+
|
|
241
|
+
**Hierarchy:**
|
|
242
|
+
|
|
243
|
+
1. **Primary** - One per view, main action, filled with brand color
|
|
244
|
+
2. **Secondary** - Supporting actions, outlined or ghost style
|
|
245
|
+
3. **Tertiary** - Low-emphasis actions, text-only with hover state
|
|
246
|
+
4. **Destructive** - Delete/remove actions, red with confirmation
|
|
247
|
+
|
|
248
|
+
**States:**
|
|
249
|
+
|
|
250
|
+
- Default → Hover (+shadow or darken) → Active (scale 0.97) → Disabled (50% opacity)
|
|
251
|
+
- Loading: replace text with spinner, maintain width
|
|
252
|
+
- Min width: 80px; min height: 36px (touch-friendly: 44px)
|
|
253
|
+
|
|
254
|
+
**Best practices:**
|
|
255
|
+
|
|
256
|
+
- **Specific labels:** "Save API Key" not "Continue" or "Submit"
|
|
257
|
+
- Verb + noun labels: "Create project" not "Create"
|
|
258
|
+
- Sentence case, not ALL CAPS
|
|
259
|
+
- Icon left of text (or icon-only with tooltip)
|
|
260
|
+
- Primary button right-aligned in forms/dialogs
|
|
261
|
+
- **Icon buttons require `aria-label`**
|
|
262
|
+
|
|
263
|
+
**Active state feedback:**
|
|
264
|
+
|
|
265
|
+
```css
|
|
266
|
+
button:active {
|
|
267
|
+
transform: scale(0.97);
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Forms (from Airbnb, Stripe, Vercel)
|
|
272
|
+
|
|
273
|
+
**Input anatomy:**
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
┌─────────────────────────────────┐
|
|
277
|
+
│ Label │ ← Required (above input, not inside)
|
|
278
|
+
│ ┌─────────────────────────────┐ │
|
|
279
|
+
│ │ Placeholder... │ │ ← Format hint only, ends with ...
|
|
280
|
+
│ └─────────────────────────────┘ │
|
|
281
|
+
│ Helper text or error message │ ← Specific and actionable
|
|
282
|
+
└─────────────────────────────────┘
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Autocomplete attributes (required):**
|
|
286
|
+
|
|
287
|
+
```html
|
|
288
|
+
<!-- Always use appropriate autocomplete for user data -->
|
|
289
|
+
<input type="email" autocomplete="email" />
|
|
290
|
+
<input type="text" autocomplete="name" />
|
|
291
|
+
<input type="text" autocomplete="given-name" />
|
|
292
|
+
<input type="text" autocomplete="family-name" />
|
|
293
|
+
<input type="text" autocomplete="organization" />
|
|
294
|
+
<input type="text" autocomplete="street-address" />
|
|
295
|
+
<input type="text" autocomplete="postal-code" />
|
|
296
|
+
<input type="tel" autocomplete="tel" />
|
|
297
|
+
<input type="password" autocomplete="current-password" />
|
|
298
|
+
<input type="password" autocomplete="new-password" />
|
|
299
|
+
<input type="text" autocomplete="one-time-code" />
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Input types and modes:**
|
|
303
|
+
|
|
304
|
+
```html
|
|
305
|
+
<!-- Use correct type for validation and keyboard -->
|
|
306
|
+
<input type="email" inputmode="email" />
|
|
307
|
+
<input type="tel" inputmode="tel" />
|
|
308
|
+
<input type="url" inputmode="url" />
|
|
309
|
+
<input type="number" inputmode="numeric" />
|
|
310
|
+
|
|
311
|
+
<!-- Numeric input without spinners -->
|
|
312
|
+
<input type="text" inputmode="numeric" pattern="[0-9]*" />
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Disable spellcheck where inappropriate:**
|
|
316
|
+
|
|
317
|
+
```jsx
|
|
318
|
+
// Disable for codes, emails, usernames, URLs
|
|
319
|
+
<input type="text" spellCheck={false} autoComplete="username" />
|
|
320
|
+
<input type="email" spellCheck={false} />
|
|
321
|
+
<input type="text" spellCheck={false} placeholder="Enter code..." />
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**Anti-patterns to avoid:**
|
|
325
|
+
|
|
326
|
+
```jsx
|
|
327
|
+
// NEVER block paste - this is hostile UX
|
|
328
|
+
<input onPaste={(e) => e.preventDefault()} /> // ❌ NEVER DO THIS
|
|
329
|
+
|
|
330
|
+
// NEVER use placeholder as label
|
|
331
|
+
<input placeholder="Email" /> // ❌ Placeholder disappears on focus
|
|
332
|
+
|
|
333
|
+
// NEVER validate on every keystroke
|
|
334
|
+
onChange={(e) => validateEmail(e.target.value)} // ❌ Too aggressive
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**Best practices:**
|
|
338
|
+
|
|
339
|
+
- Labels above inputs (not inside—accessibility)
|
|
340
|
+
- Placeholder ≠ label; use for format hints only, end with `...`
|
|
341
|
+
- Inline validation on blur, not on every keystroke
|
|
342
|
+
- Error messages: specific and actionable ("Email must include @")
|
|
343
|
+
- **Focus first error field** after form submission fails
|
|
344
|
+
- Success state: checkmark icon, green border (brief)
|
|
345
|
+
- Required fields: mark optional ones instead of required
|
|
346
|
+
- Single column forms outperform multi-column
|
|
347
|
+
|
|
348
|
+
**Unsaved changes warning:**
|
|
349
|
+
|
|
350
|
+
```jsx
|
|
351
|
+
// Warn users before leaving with unsaved changes
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const handleBeforeUnload = (e) => {
|
|
354
|
+
if (hasUnsavedChanges) {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
e.returnValue = '';
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
360
|
+
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
361
|
+
}, [hasUnsavedChanges]);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Cards (from Material, Apple)
|
|
365
|
+
|
|
366
|
+
**Anatomy:**
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
┌────────────────────────────────┐
|
|
370
|
+
│ [Media/Image] │ ← Optional
|
|
371
|
+
├────────────────────────────────┤
|
|
372
|
+
│ Eyebrow · Metadata │ ← Optional
|
|
373
|
+
│ Title │ ← Required
|
|
374
|
+
│ Description text that can │ ← Optional
|
|
375
|
+
│ wrap to multiple lines... │
|
|
376
|
+
├────────────────────────────────┤
|
|
377
|
+
│ [Actions] [More] │ ← Optional
|
|
378
|
+
└────────────────────────────────┘
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**Best practices:**
|
|
382
|
+
|
|
383
|
+
- Entire card clickable for primary action
|
|
384
|
+
- Consistent padding (16-24px)
|
|
385
|
+
- Image aspect ratios: 16:9, 4:3, 1:1 (be consistent)
|
|
386
|
+
- Limit to 2 actions max; overflow to menu
|
|
387
|
+
- Hover: subtle lift (translateY -2px + shadow increase)
|
|
388
|
+
|
|
389
|
+
### Tables (from Linear, Notion)
|
|
390
|
+
|
|
391
|
+
**Best practices:**
|
|
392
|
+
|
|
393
|
+
- Left-align text, right-align numbers
|
|
394
|
+
- **Use `tabular-nums` for numeric columns** (consistent width digits)
|
|
395
|
+
- Zebra striping OR row hover, not both
|
|
396
|
+
- Sticky header on scroll
|
|
397
|
+
- Sortable columns: show current sort indicator
|
|
398
|
+
- Actions: row hover reveals action buttons (or kebab menu)
|
|
399
|
+
- Empty state: helpful message + action
|
|
400
|
+
- Pagination vs infinite scroll: pagination for data accuracy, infinite for browsing
|
|
401
|
+
- Min row height: 48px for touch; 40px for dense
|
|
402
|
+
- **Virtualize tables with >50 rows**
|
|
403
|
+
|
|
404
|
+
```css
|
|
405
|
+
.numeric-column {
|
|
406
|
+
font-variant-numeric: tabular-nums;
|
|
407
|
+
text-align: right;
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Navigation (from Apple HIG, Material)
|
|
412
|
+
|
|
413
|
+
**Patterns by scale:**
|
|
414
|
+
|
|
415
|
+
- **2-5 items**: Tab bar / horizontal tabs
|
|
416
|
+
- **5-10 items**: Side navigation (collapsible)
|
|
417
|
+
- **10+ items**: Side nav with sections/groups
|
|
418
|
+
|
|
419
|
+
**Best practices:**
|
|
420
|
+
|
|
421
|
+
- Current location always visible
|
|
422
|
+
- Breadcrumbs for deep hierarchy (not for flat structures)
|
|
423
|
+
- Mobile: bottom nav for primary actions (thumb-friendly)
|
|
424
|
+
- Icons + labels together; icon-only needs tooltip
|
|
425
|
+
- Consistent order across pages
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## Part 4: Interaction Design
|
|
430
|
+
|
|
431
|
+
### Feedback Patterns (from Dan Saffer's Microinteractions)
|
|
432
|
+
|
|
433
|
+
**Every action needs feedback:**
|
|
434
|
+
|
|
435
|
+
1. **Immediate** - Button press visual (scale, color change)
|
|
436
|
+
2. **Progress** - Loading states for anything >1s
|
|
437
|
+
3. **Completion** - Success confirmation (toast, checkmark, animation)
|
|
438
|
+
4. **Failure** - Clear error with recovery path
|
|
439
|
+
|
|
440
|
+
**Loading states:**
|
|
441
|
+
|
|
442
|
+
- 0-100ms: No indicator needed
|
|
443
|
+
- 100-300ms: Subtle change (opacity, skeleton)
|
|
444
|
+
- 300ms-1s: Spinner or progress bar
|
|
445
|
+
- 1s+: Skeleton screens + progress indication
|
|
446
|
+
- 10s+: Background processing with notification
|
|
447
|
+
|
|
448
|
+
### State Handling
|
|
449
|
+
|
|
450
|
+
**Every component needs these states:**
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
Default → Base appearance
|
|
454
|
+
Hover → Interactive hint (cursor change, highlight)
|
|
455
|
+
Focus → Keyboard navigation (visible ring)
|
|
456
|
+
Active → Being pressed/activated
|
|
457
|
+
Loading → Async operation in progress
|
|
458
|
+
Disabled → Not available (reduce opacity, remove pointer)
|
|
459
|
+
Error → Invalid input or failed operation
|
|
460
|
+
Success → Completed successfully (brief)
|
|
461
|
+
Empty → No data to display (helpful message + action)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Touch & Pointer Interactions
|
|
465
|
+
|
|
466
|
+
**Faster tap response:**
|
|
467
|
+
|
|
468
|
+
```css
|
|
469
|
+
/* Remove 300ms tap delay on touch devices */
|
|
470
|
+
button, a, [role="button"] {
|
|
471
|
+
touch-action: manipulation;
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**Contain scroll in modals:**
|
|
476
|
+
|
|
477
|
+
```css
|
|
478
|
+
/* Prevent scroll chaining to body when modal/drawer reaches edge */
|
|
479
|
+
.modal, .drawer, .dropdown {
|
|
480
|
+
overscroll-behavior: contain;
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**Touch targets:**
|
|
485
|
+
|
|
486
|
+
- Minimum 44x44px for all interactive elements (Apple HIG)
|
|
487
|
+
- Provide adequate spacing between targets (8px minimum)
|
|
488
|
+
|
|
489
|
+
**Hover states for pointer devices only:**
|
|
490
|
+
|
|
491
|
+
```css
|
|
492
|
+
/* Only apply hover effects on devices with fine pointers */
|
|
493
|
+
@media (hover: hover) and (pointer: fine) {
|
|
494
|
+
.card:hover {
|
|
495
|
+
transform: translateY(-2px);
|
|
496
|
+
box-shadow: var(--shadow-lg);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**Tap highlight:**
|
|
502
|
+
|
|
503
|
+
```css
|
|
504
|
+
/* Customize or remove tap highlight on mobile */
|
|
505
|
+
button {
|
|
506
|
+
-webkit-tap-highlight-color: transparent; /* Remove default */
|
|
507
|
+
/* Or use a custom color */
|
|
508
|
+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Optimistic Updates (from Linear, Notion)
|
|
513
|
+
|
|
514
|
+
- Update UI immediately, sync in background
|
|
515
|
+
- Show subtle "Saving..." indicator
|
|
516
|
+
- On failure: revert UI + show error toast with retry
|
|
517
|
+
- Best for: toggles, reordering, text edits
|
|
518
|
+
- Avoid for: destructive actions, payments
|
|
519
|
+
|
|
520
|
+
### Progressive Disclosure
|
|
521
|
+
|
|
522
|
+
**Reveal complexity gradually:**
|
|
523
|
+
|
|
524
|
+
- Show essential options first
|
|
525
|
+
- "Advanced" or "More options" for power features
|
|
526
|
+
- Inline expansion over page navigation
|
|
527
|
+
- Tooltips for supplementary information
|
|
528
|
+
- Context menus for secondary actions
|
|
529
|
+
|
|
530
|
+
### Inferring Intent
|
|
531
|
+
|
|
532
|
+
**Anticipate user actions before they happen:**
|
|
533
|
+
|
|
534
|
+
```jsx
|
|
535
|
+
// Preload on mousedown (fires before click)
|
|
536
|
+
<button
|
|
537
|
+
onMouseDown={() => prefetchData()}
|
|
538
|
+
onClick={() => showData()}
|
|
539
|
+
>
|
|
540
|
+
View Details
|
|
541
|
+
</button>
|
|
542
|
+
|
|
543
|
+
// Preload on hover for links
|
|
544
|
+
<Link
|
|
545
|
+
href="/dashboard"
|
|
546
|
+
onMouseEnter={() => router.prefetch('/dashboard')}
|
|
547
|
+
>
|
|
548
|
+
Dashboard
|
|
549
|
+
</Link>
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
**Proximity-based preloading:**
|
|
553
|
+
|
|
554
|
+
```jsx
|
|
555
|
+
// Start loading when cursor approaches
|
|
556
|
+
function useProximityPreload(ref, onApproach) {
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
const element = ref.current;
|
|
559
|
+
const handleMouseMove = (e) => {
|
|
560
|
+
const rect = element.getBoundingClientRect();
|
|
561
|
+
const distance = Math.hypot(
|
|
562
|
+
e.clientX - (rect.left + rect.width / 2),
|
|
563
|
+
e.clientY - (rect.top + rect.height / 2)
|
|
564
|
+
);
|
|
565
|
+
if (distance < 100) onApproach();
|
|
566
|
+
};
|
|
567
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
568
|
+
return () => document.removeEventListener('mousemove', handleMouseMove);
|
|
569
|
+
}, [ref, onApproach]);
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Smart defaults:**
|
|
574
|
+
|
|
575
|
+
- Pre-fill forms with likely values
|
|
576
|
+
- Remember user's last selection
|
|
577
|
+
- Use geolocation for location fields
|
|
578
|
+
- Default date pickers to sensible dates (today, tomorrow)
|
|
579
|
+
|
|
580
|
+
### Interaction Metaphors
|
|
581
|
+
|
|
582
|
+
**Physical analogies users already understand:**
|
|
583
|
+
|
|
584
|
+
| Gesture | Real-world Metaphor | UI Behavior |
|
|
585
|
+
|---------|---------------------|-------------|
|
|
586
|
+
| Drag | Moving physical objects | Reorder, move items |
|
|
587
|
+
| Swipe | Flipping pages, pushing aside | Navigate, dismiss |
|
|
588
|
+
| Pinch | Zooming a camera lens | Scale content |
|
|
589
|
+
| Pull down | Stretching a spring | Refresh content |
|
|
590
|
+
| Long press | Pressing firmly to reveal | Context menu |
|
|
591
|
+
|
|
592
|
+
**Consistency requirement:**
|
|
593
|
+
|
|
594
|
+
Once you establish a gesture metaphor, use it consistently:
|
|
595
|
+
|
|
596
|
+
```
|
|
597
|
+
❌ Swipe right to delete in one view, swipe right to archive in another
|
|
598
|
+
✅ Swipe right always archives, swipe left always deletes
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**Honor platform conventions:**
|
|
602
|
+
|
|
603
|
+
- iOS: Swipe from left edge = back navigation
|
|
604
|
+
- Android: Back button/gesture = return to previous screen
|
|
605
|
+
- Desktop: Right-click = context menu
|
|
606
|
+
|
|
607
|
+
### Ergonomic Interactions
|
|
608
|
+
|
|
609
|
+
**Expand hit areas with pseudo-elements:**
|
|
610
|
+
|
|
611
|
+
```css
|
|
612
|
+
/* Thin visual element with large tap target */
|
|
613
|
+
.icon-button {
|
|
614
|
+
position: relative;
|
|
615
|
+
width: 24px;
|
|
616
|
+
height: 24px;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.icon-button::after {
|
|
620
|
+
content: '';
|
|
621
|
+
position: absolute;
|
|
622
|
+
inset: -12px; /* Expands hit area to 48x48px */
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**Bidirectional scroll support:**
|
|
627
|
+
|
|
628
|
+
```css
|
|
629
|
+
/* Support both LTR and RTL scrolling */
|
|
630
|
+
.horizontal-scroll {
|
|
631
|
+
overflow-x: auto;
|
|
632
|
+
scroll-behavior: smooth;
|
|
633
|
+
/* Use logical properties */
|
|
634
|
+
scroll-padding-inline: 16px;
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Thumb-friendly mobile zones:**
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
┌─────────────────────────────────┐
|
|
642
|
+
│ Hard to reach (top) │ ← Avoid primary actions here
|
|
643
|
+
├─────────────────────────────────┤
|
|
644
|
+
│ │
|
|
645
|
+
│ Comfortable middle │ ← Secondary actions OK
|
|
646
|
+
│ │
|
|
647
|
+
├─────────────────────────────────┤
|
|
648
|
+
│ Easy reach (bottom) │ ← Primary actions here
|
|
649
|
+
└─────────────────────────────────┘
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### Contained Gestures
|
|
653
|
+
|
|
654
|
+
**Prevent gesture conflicts with parent elements:**
|
|
655
|
+
|
|
656
|
+
```css
|
|
657
|
+
/* Contain drag/swipe gestures within element */
|
|
658
|
+
.draggable-area {
|
|
659
|
+
touch-action: none; /* Disable browser handling */
|
|
660
|
+
user-select: none; /* Prevent text selection during drag */
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/* Allow vertical scroll but capture horizontal */
|
|
664
|
+
.horizontal-swipe {
|
|
665
|
+
touch-action: pan-y; /* Allow vertical, capture horizontal */
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
**Pointer capture for drag operations:**
|
|
670
|
+
|
|
671
|
+
```jsx
|
|
672
|
+
function useDrag(onDrag, onDragEnd) {
|
|
673
|
+
const handlePointerDown = (e) => {
|
|
674
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const handlePointerMove = (e) => {
|
|
678
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
679
|
+
onDrag({ x: e.clientX, y: e.clientY });
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const handlePointerUp = (e) => {
|
|
684
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
685
|
+
onDragEnd();
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
onPointerDown: handlePointerDown,
|
|
690
|
+
onPointerMove: handlePointerMove,
|
|
691
|
+
onPointerUp: handlePointerUp,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**Drag threshold detection:**
|
|
697
|
+
|
|
698
|
+
```jsx
|
|
699
|
+
// Distinguish click from drag with movement threshold
|
|
700
|
+
const DRAG_THRESHOLD = 5; // pixels
|
|
701
|
+
|
|
702
|
+
function useDragThreshold() {
|
|
703
|
+
const startPos = useRef(null);
|
|
704
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
705
|
+
|
|
706
|
+
const handlePointerDown = (e) => {
|
|
707
|
+
startPos.current = { x: e.clientX, y: e.clientY };
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const handlePointerMove = (e) => {
|
|
711
|
+
if (!startPos.current) return;
|
|
712
|
+
|
|
713
|
+
const distance = Math.hypot(
|
|
714
|
+
e.clientX - startPos.current.x,
|
|
715
|
+
e.clientY - startPos.current.y
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
if (distance > DRAG_THRESHOLD) {
|
|
719
|
+
setIsDragging(true);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const handlePointerUp = (e) => {
|
|
724
|
+
const wasDragging = isDragging;
|
|
725
|
+
setIsDragging(false);
|
|
726
|
+
startPos.current = null;
|
|
727
|
+
return wasDragging; // Return true if was drag, false if was click
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
return { isDragging, handlePointerDown, handlePointerMove, handlePointerUp };
|
|
731
|
+
}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
**Gesture state machine:**
|
|
735
|
+
|
|
736
|
+
```
|
|
737
|
+
IDLE → PRESS (pointer down)
|
|
738
|
+
PRESS → DRAG (movement > threshold)
|
|
739
|
+
PRESS → CLICK (pointer up, no movement)
|
|
740
|
+
DRAG → DRAG_END (pointer up)
|
|
741
|
+
DRAG_END → IDLE (animation complete)
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## Part 5: Motion & Animation
|
|
747
|
+
|
|
748
|
+
### The Frequency Principle
|
|
749
|
+
|
|
750
|
+
Animation frequency should match usage frequency:
|
|
751
|
+
|
|
752
|
+
| Usage Pattern | Animation Approach |
|
|
753
|
+
|---|---|
|
|
754
|
+
| 100+ times/day | No animation—instant response |
|
|
755
|
+
| Occasional use | Standard animation (150-300ms) |
|
|
756
|
+
| Rare/first-time | Can add delight, longer duration |
|
|
757
|
+
|
|
758
|
+
Example: A "send message" button used constantly → instant. An "export report" button used weekly → can animate.
|
|
759
|
+
|
|
760
|
+
### Easing Blueprint
|
|
761
|
+
|
|
762
|
+
**Ease-out family (most common):**
|
|
763
|
+
|
|
764
|
+
Use for entrances, user-initiated actions, and most UI transitions.
|
|
765
|
+
|
|
766
|
+
```css
|
|
767
|
+
:root {
|
|
768
|
+
/* Increasing intensity: quad → cubic → quart → quint */
|
|
769
|
+
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
770
|
+
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
|
|
771
|
+
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
|
772
|
+
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**Ease-in-out family:**
|
|
777
|
+
|
|
778
|
+
Use for on-screen movement (element moving from point A to point B).
|
|
779
|
+
|
|
780
|
+
```css
|
|
781
|
+
:root {
|
|
782
|
+
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
|
|
783
|
+
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
**Easing decision flowchart:**
|
|
788
|
+
|
|
789
|
+
```
|
|
790
|
+
Is the element entering or exiting the screen?
|
|
791
|
+
→ Yes: Use ease-out (for both enter AND exit)
|
|
792
|
+
|
|
793
|
+
Is the element moving on screen (A to B)?
|
|
794
|
+
→ Yes: Use ease-in-out
|
|
795
|
+
|
|
796
|
+
Is it a hover state or color change?
|
|
797
|
+
→ Yes: Use ease (CSS default) or ease-out-quad
|
|
798
|
+
|
|
799
|
+
Is it constant/looping motion (spinner, progress)?
|
|
800
|
+
→ Yes: Use linear
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
### Timing Guidelines
|
|
804
|
+
|
|
805
|
+
| Element Type | Duration | Notes |
|
|
806
|
+
|---|---|---|
|
|
807
|
+
| Micro-interactions | 100-150ms | Buttons, toggles, hover states |
|
|
808
|
+
| Tooltips, dropdowns | 150-250ms | Small UI appearing |
|
|
809
|
+
| Modals, drawers | 200-300ms | Larger surfaces |
|
|
810
|
+
| Page transitions | 300-400ms | Full view changes |
|
|
811
|
+
| Staggered items | 30-50ms delay | Between each item |
|
|
812
|
+
|
|
813
|
+
**Important:** Exit animations should be 20-30% faster than entrances.
|
|
814
|
+
|
|
815
|
+
### Animation Patterns
|
|
816
|
+
|
|
817
|
+
**Entrances:**
|
|
818
|
+
|
|
819
|
+
- Fade in + slide up (8-16px)
|
|
820
|
+
- Scale from 0.95 to 1 + fade (never from 0)
|
|
821
|
+
- Stagger children by 30-50ms
|
|
822
|
+
|
|
823
|
+
**Exits:**
|
|
824
|
+
|
|
825
|
+
- Fade out (faster than entrance)
|
|
826
|
+
- Scale to 0.95 + fade
|
|
827
|
+
- Slide in direction of dismissal
|
|
828
|
+
|
|
829
|
+
**Transform origin:**
|
|
830
|
+
|
|
831
|
+
Always set `transform-origin` toward the trigger element:
|
|
832
|
+
|
|
833
|
+
```css
|
|
834
|
+
/* Dropdown opening from button */
|
|
835
|
+
.dropdown {
|
|
836
|
+
transform-origin: top left; /* Opens from button location */
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/* Modal opening from center */
|
|
840
|
+
.modal {
|
|
841
|
+
transform-origin: center center;
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
**Hover flicker prevention:**
|
|
846
|
+
|
|
847
|
+
```css
|
|
848
|
+
/* ❌ Don't animate the parent on hover */
|
|
849
|
+
.card:hover {
|
|
850
|
+
transform: scale(1.02); /* Causes flicker */
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* ✅ Animate a child element instead */
|
|
854
|
+
.card:hover .card-content {
|
|
855
|
+
transform: scale(1.02);
|
|
856
|
+
}
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
**Sequential tooltips:**
|
|
860
|
+
|
|
861
|
+
After the first tooltip in a series, skip animation for subsequent ones:
|
|
862
|
+
|
|
863
|
+
```jsx
|
|
864
|
+
// Skip animation if another tooltip was shown recently
|
|
865
|
+
const skipAnimation = Date.now() - lastTooltipTime < 300;
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
### Spring Physics
|
|
869
|
+
|
|
870
|
+
**When to use springs:**
|
|
871
|
+
|
|
872
|
+
- Drag and drop interactions
|
|
873
|
+
- Gesture-based animations
|
|
874
|
+
- Interruptible motion (user can grab mid-animation)
|
|
875
|
+
- Physics-based feel (natural, organic)
|
|
876
|
+
|
|
877
|
+
**Spring parameters:**
|
|
878
|
+
|
|
879
|
+
```jsx
|
|
880
|
+
// Physical spring configuration
|
|
881
|
+
const spring = {
|
|
882
|
+
stiffness: 300, // Higher = faster, snappier
|
|
883
|
+
damping: 30, // Higher = less oscillation
|
|
884
|
+
mass: 1 // Higher = more inertia, slower
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// Typical ranges:
|
|
888
|
+
// stiffness: 100-1000 (most UI: 200-400)
|
|
889
|
+
// damping: 10-100 (most UI: 20-40)
|
|
890
|
+
// mass: 0.5-2 (most UI: 1)
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
**Critical principle: Never reuse spring values**
|
|
894
|
+
|
|
895
|
+
Each interaction should have its own tuned spring. A dropdown menu spring differs from a drag-to-dismiss spring.
|
|
896
|
+
|
|
897
|
+
```jsx
|
|
898
|
+
// ❌ Bad - same spring for everything
|
|
899
|
+
const SPRING = { stiffness: 300, damping: 30 };
|
|
900
|
+
|
|
901
|
+
// ✅ Good - tuned per interaction
|
|
902
|
+
const DROPDOWN_SPRING = { stiffness: 400, damping: 35, mass: 0.8 };
|
|
903
|
+
const DRAG_SPRING = { stiffness: 250, damping: 25, mass: 1 };
|
|
904
|
+
const BOUNCE_SPRING = { stiffness: 180, damping: 12, mass: 1 };
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
**Damping for rubber band effects:**
|
|
908
|
+
|
|
909
|
+
```jsx
|
|
910
|
+
// Rubber band effect for over-scroll
|
|
911
|
+
function rubberBand(offset, limit, elasticity = 0.55) {
|
|
912
|
+
const clampedOffset = Math.max(0, offset);
|
|
913
|
+
const delta = clampedOffset - limit;
|
|
914
|
+
if (delta <= 0) return offset;
|
|
915
|
+
|
|
916
|
+
// Logarithmic decay for natural feel
|
|
917
|
+
return limit + (1 - Math.exp(-delta / (limit * elasticity))) * limit * elasticity;
|
|
918
|
+
}
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
**iOS-style projection (momentum scrolling):**
|
|
922
|
+
|
|
923
|
+
```jsx
|
|
924
|
+
// Project final position based on velocity
|
|
925
|
+
function project(velocity, position, deceleration = 0.998) {
|
|
926
|
+
// v(t) = v0 * deceleration^t
|
|
927
|
+
// When v(t) ≈ 0, t = log(0.001) / log(deceleration)
|
|
928
|
+
const duration = Math.log(0.001) / Math.log(deceleration);
|
|
929
|
+
const distance = velocity * (1 - Math.pow(deceleration, duration)) / (1 - deceleration);
|
|
930
|
+
return position + distance;
|
|
931
|
+
}
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
**Framer Motion spring shorthand:**
|
|
935
|
+
|
|
936
|
+
```jsx
|
|
937
|
+
// Simple configuration
|
|
938
|
+
const springConfig = {
|
|
939
|
+
type: "spring",
|
|
940
|
+
duration: 0.5, // Overall duration
|
|
941
|
+
bounce: 0.2 // 0 = no bounce, 1 = very bouncy
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// Subtle bounce (most UI): 0.1 - 0.3
|
|
945
|
+
// Playful bounce: 0.3 - 0.5
|
|
946
|
+
// Avoid > 0.5 in most production UI
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
### Motion Choreography
|
|
950
|
+
|
|
951
|
+
**Blur overlapping layers:**
|
|
952
|
+
|
|
953
|
+
When animated elements cross paths, they create visual noise. Add subtle blur:
|
|
954
|
+
|
|
955
|
+
```css
|
|
956
|
+
/* Add 1-2px blur during transitions */
|
|
957
|
+
.transitioning-element {
|
|
958
|
+
filter: blur(1px);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/* Or use will-change to hint GPU compositing */
|
|
962
|
+
.animated-layer {
|
|
963
|
+
will-change: transform;
|
|
964
|
+
transform: translateZ(0); /* Force separate layer */
|
|
965
|
+
}
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
**Stagger animation delays:**
|
|
969
|
+
|
|
970
|
+
```jsx
|
|
971
|
+
// Stagger children by 30-50ms each
|
|
972
|
+
const container = {
|
|
973
|
+
hidden: { opacity: 0 },
|
|
974
|
+
show: {
|
|
975
|
+
opacity: 1,
|
|
976
|
+
transition: {
|
|
977
|
+
staggerChildren: 0.04, // 40ms between each
|
|
978
|
+
delayChildren: 0.1, // Wait 100ms before starting
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const item = {
|
|
984
|
+
hidden: { opacity: 0, y: 8 },
|
|
985
|
+
show: { opacity: 1, y: 0 }
|
|
986
|
+
};
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
**Double exit stiffness:**
|
|
990
|
+
|
|
991
|
+
Exit animations should feel quicker. Double the spring stiffness:
|
|
992
|
+
|
|
993
|
+
```jsx
|
|
994
|
+
function AnimatedPanel({ isOpen }) {
|
|
995
|
+
return (
|
|
996
|
+
<motion.div
|
|
997
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
998
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
999
|
+
exit={{ opacity: 0, scale: 0.95 }}
|
|
1000
|
+
transition={{
|
|
1001
|
+
enter: { type: "spring", stiffness: 300, damping: 30 },
|
|
1002
|
+
exit: { type: "spring", stiffness: 600, damping: 30 } // 2x stiffness
|
|
1003
|
+
}}
|
|
1004
|
+
/>
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
**Crossfade icons (not swap):**
|
|
1010
|
+
|
|
1011
|
+
When changing icons, don't just swap. Scale down + blur out the old, scale up + blur in the new:
|
|
1012
|
+
|
|
1013
|
+
```jsx
|
|
1014
|
+
// Icon crossfade
|
|
1015
|
+
<AnimatePresence mode="wait">
|
|
1016
|
+
<motion.div
|
|
1017
|
+
key={iconKey}
|
|
1018
|
+
initial={{ opacity: 0, scale: 0.5, filter: 'blur(7px)' }}
|
|
1019
|
+
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
|
1020
|
+
exit={{ opacity: 0, scale: 0.5, filter: 'blur(7px)' }}
|
|
1021
|
+
transition={{ duration: 0.15 }}
|
|
1022
|
+
>
|
|
1023
|
+
<Icon />
|
|
1024
|
+
</motion.div>
|
|
1025
|
+
</AnimatePresence>
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
**Morph surfaces with overflow: hidden:**
|
|
1029
|
+
|
|
1030
|
+
When morphing between shapes, prevent content from spilling:
|
|
1031
|
+
|
|
1032
|
+
```jsx
|
|
1033
|
+
// Container with overflow: hidden + crossfade content
|
|
1034
|
+
<motion.div
|
|
1035
|
+
layout
|
|
1036
|
+
style={{ overflow: 'hidden', borderRadius: 12 }}
|
|
1037
|
+
transition={{ layout: { duration: 0.3 } }}
|
|
1038
|
+
>
|
|
1039
|
+
<AnimatePresence mode="wait">
|
|
1040
|
+
<motion.div
|
|
1041
|
+
key={contentKey}
|
|
1042
|
+
initial={{ opacity: 0 }}
|
|
1043
|
+
animate={{ opacity: 1 }}
|
|
1044
|
+
exit={{ opacity: 0 }}
|
|
1045
|
+
>
|
|
1046
|
+
{content}
|
|
1047
|
+
</motion.div>
|
|
1048
|
+
</AnimatePresence>
|
|
1049
|
+
</motion.div>
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### High-Frequency Actions
|
|
1053
|
+
|
|
1054
|
+
**No fade-in for menus:**
|
|
1055
|
+
|
|
1056
|
+
Menus triggered frequently should appear instantly, but can fade out:
|
|
1057
|
+
|
|
1058
|
+
```jsx
|
|
1059
|
+
// Instant appear, animated dismiss
|
|
1060
|
+
<motion.div
|
|
1061
|
+
initial={false} // Skip enter animation
|
|
1062
|
+
animate={{ opacity: 1 }}
|
|
1063
|
+
exit={{ opacity: 0, transition: { duration: 0.1 } }}
|
|
1064
|
+
>
|
|
1065
|
+
<Menu />
|
|
1066
|
+
</motion.div>
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
**Skip animation between rapid data updates:**
|
|
1070
|
+
|
|
1071
|
+
```jsx
|
|
1072
|
+
// Skip animation if update is too fast
|
|
1073
|
+
const lastUpdate = useRef(Date.now());
|
|
1074
|
+
const ANIMATION_THRESHOLD = 100; // ms
|
|
1075
|
+
|
|
1076
|
+
function updateValue(newValue) {
|
|
1077
|
+
const now = Date.now();
|
|
1078
|
+
const shouldAnimate = now - lastUpdate.current > ANIMATION_THRESHOLD;
|
|
1079
|
+
lastUpdate.current = now;
|
|
1080
|
+
|
|
1081
|
+
if (shouldAnimate) {
|
|
1082
|
+
animateToValue(newValue);
|
|
1083
|
+
} else {
|
|
1084
|
+
setValueInstantly(newValue);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
**Keyboard interactions often need no animation:**
|
|
1090
|
+
|
|
1091
|
+
```jsx
|
|
1092
|
+
// Tab navigation - instant focus, no animation
|
|
1093
|
+
// Arrow key navigation - instant highlight
|
|
1094
|
+
// Enter to select - instant (or very fast 50ms)
|
|
1095
|
+
const keyboardTransition = { duration: 0.05 };
|
|
1096
|
+
const pointerTransition = { duration: 0.15 };
|
|
1097
|
+
|
|
1098
|
+
const transition = isKeyboardNav ? keyboardTransition : pointerTransition;
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
### Gesture Lifecycle
|
|
1102
|
+
|
|
1103
|
+
**Three phases of gesture handling:**
|
|
1104
|
+
|
|
1105
|
+
```
|
|
1106
|
+
START (pointer down) → Set constraints, capture pointer
|
|
1107
|
+
MOVE (pointer move) → Update position continuously
|
|
1108
|
+
END (pointer up) → Animate to final position
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
**Use jump() for continuous updates, set() for animated snap:**
|
|
1112
|
+
|
|
1113
|
+
```jsx
|
|
1114
|
+
import { useMotionValue, useSpring } from 'framer-motion';
|
|
1115
|
+
|
|
1116
|
+
function DraggableElement() {
|
|
1117
|
+
const x = useMotionValue(0);
|
|
1118
|
+
const springX = useSpring(x, { stiffness: 300, damping: 30 });
|
|
1119
|
+
|
|
1120
|
+
const handleDrag = (e) => {
|
|
1121
|
+
// During drag: jump() for instant tracking (no spring delay)
|
|
1122
|
+
x.jump(e.clientX - startX);
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const handleDragEnd = () => {
|
|
1126
|
+
// On release: set() for animated snap to final position
|
|
1127
|
+
springX.set(snapToNearest(x.get()));
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
return <motion.div style={{ x: springX }} />;
|
|
1131
|
+
}
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
**useTransform for derived values:**
|
|
1135
|
+
|
|
1136
|
+
```jsx
|
|
1137
|
+
import { useMotionValue, useTransform } from 'framer-motion';
|
|
1138
|
+
|
|
1139
|
+
function SwipeCard() {
|
|
1140
|
+
const x = useMotionValue(0);
|
|
1141
|
+
|
|
1142
|
+
// Derive rotation from horizontal position
|
|
1143
|
+
const rotate = useTransform(x, [-200, 200], [-15, 15]);
|
|
1144
|
+
|
|
1145
|
+
// Derive opacity from position
|
|
1146
|
+
const opacity = useTransform(x, [-200, 0, 200], [0.5, 1, 0.5]);
|
|
1147
|
+
|
|
1148
|
+
return (
|
|
1149
|
+
<motion.div
|
|
1150
|
+
style={{ x, rotate, opacity }}
|
|
1151
|
+
drag="x"
|
|
1152
|
+
dragConstraints={{ left: 0, right: 0 }}
|
|
1153
|
+
/>
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
**Gesture state tracking:**
|
|
1159
|
+
|
|
1160
|
+
```jsx
|
|
1161
|
+
function useGestureState() {
|
|
1162
|
+
const [state, setState] = useState('idle');
|
|
1163
|
+
// idle → press → drag → drag-end → idle
|
|
1164
|
+
|
|
1165
|
+
const handlers = {
|
|
1166
|
+
onPointerDown: () => setState('press'),
|
|
1167
|
+
onDragStart: () => setState('drag'),
|
|
1168
|
+
onDragEnd: () => {
|
|
1169
|
+
setState('drag-end');
|
|
1170
|
+
// Return to idle after animation
|
|
1171
|
+
setTimeout(() => setState('idle'), 300);
|
|
1172
|
+
},
|
|
1173
|
+
onPointerUp: () => {
|
|
1174
|
+
if (state === 'press') setState('idle'); // Was click, not drag
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
return { state, handlers };
|
|
1179
|
+
}
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
### Animation Performance
|
|
1183
|
+
|
|
1184
|
+
**Only animate compositor properties:**
|
|
1185
|
+
|
|
1186
|
+
```css
|
|
1187
|
+
/* ✅ GPU-accelerated (cheap) */
|
|
1188
|
+
transform: translateX(100px);
|
|
1189
|
+
transform: scale(1.1);
|
|
1190
|
+
transform: rotate(45deg);
|
|
1191
|
+
opacity: 0.5;
|
|
1192
|
+
|
|
1193
|
+
/* ❌ Triggers layout/paint (expensive) */
|
|
1194
|
+
width: 200px;
|
|
1195
|
+
height: 200px;
|
|
1196
|
+
top: 100px;
|
|
1197
|
+
left: 100px;
|
|
1198
|
+
margin: 20px;
|
|
1199
|
+
padding: 20px;
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
**Never use `transition: all`:**
|
|
1203
|
+
|
|
1204
|
+
```css
|
|
1205
|
+
/* ❌ Bad - animates everything including layout properties */
|
|
1206
|
+
.element {
|
|
1207
|
+
transition: all 0.3s ease;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/* ✅ Good - explicit properties */
|
|
1211
|
+
.element {
|
|
1212
|
+
transition: transform 0.3s var(--ease-out-cubic),
|
|
1213
|
+
opacity 0.3s var(--ease-out-cubic);
|
|
1214
|
+
}
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
**Fix transform shakiness:**
|
|
1218
|
+
|
|
1219
|
+
```css
|
|
1220
|
+
/* Add will-change if animation looks shaky */
|
|
1221
|
+
.animated-element {
|
|
1222
|
+
will-change: transform;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/* Remove after animation completes to free memory */
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
**CSS vs JavaScript animations:**
|
|
1229
|
+
|
|
1230
|
+
| Use CSS | Use JavaScript |
|
|
1231
|
+
|---|---|
|
|
1232
|
+
| Simple state transitions | Complex sequences |
|
|
1233
|
+
| Hover/focus effects | Gesture-based |
|
|
1234
|
+
| No user interaction during | Interruptible animations |
|
|
1235
|
+
| Performance-critical | Dynamic values |
|
|
1236
|
+
|
|
1237
|
+
### Reduced Motion
|
|
1238
|
+
|
|
1239
|
+
**Every animation needs a reduced motion alternative:**
|
|
1240
|
+
|
|
1241
|
+
```css
|
|
1242
|
+
/* Base animation */
|
|
1243
|
+
.modal {
|
|
1244
|
+
animation: slideIn 0.3s var(--ease-out-cubic);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
@keyframes slideIn {
|
|
1248
|
+
from {
|
|
1249
|
+
opacity: 0;
|
|
1250
|
+
transform: translateY(16px);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/* Reduced motion: instant or fade only */
|
|
1255
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1256
|
+
.modal {
|
|
1257
|
+
animation: fadeIn 0.15s ease;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
@keyframes fadeIn {
|
|
1261
|
+
from { opacity: 0; }
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
**Framer Motion hook:**
|
|
1267
|
+
|
|
1268
|
+
```jsx
|
|
1269
|
+
import { useReducedMotion } from 'framer-motion';
|
|
1270
|
+
|
|
1271
|
+
function Modal({ children }) {
|
|
1272
|
+
const shouldReduceMotion = useReducedMotion();
|
|
1273
|
+
|
|
1274
|
+
return (
|
|
1275
|
+
<motion.div
|
|
1276
|
+
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 16 }}
|
|
1277
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1278
|
+
transition={{ duration: shouldReduceMotion ? 0.1 : 0.3 }}
|
|
1279
|
+
>
|
|
1280
|
+
{children}
|
|
1281
|
+
</motion.div>
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
**What reduced motion should do:**
|
|
1287
|
+
|
|
1288
|
+
- Remove parallax effects
|
|
1289
|
+
- Stop auto-playing videos/carousels
|
|
1290
|
+
- Replace slide/scale with fade or instant
|
|
1291
|
+
- Keep essential feedback (success checkmarks can still appear, just not animated)
|
|
1292
|
+
|
|
1293
|
+
---
|
|
1294
|
+
|
|
1295
|
+
## Part 6: Accessibility Essentials
|
|
1296
|
+
|
|
1297
|
+
### WCAG Quick Reference
|
|
1298
|
+
|
|
1299
|
+
**Perceivable:**
|
|
1300
|
+
|
|
1301
|
+
- Color contrast: 4.5:1 text, 3:1 UI components
|
|
1302
|
+
- Don't rely on color alone (add icons, patterns)
|
|
1303
|
+
- Text resizable to 200% without loss
|
|
1304
|
+
- Captions for video; transcripts for audio
|
|
1305
|
+
|
|
1306
|
+
**Operable:**
|
|
1307
|
+
|
|
1308
|
+
- All functionality via keyboard
|
|
1309
|
+
- No keyboard traps
|
|
1310
|
+
- Skip links for repeated content
|
|
1311
|
+
- Touch targets: 44x44px minimum
|
|
1312
|
+
|
|
1313
|
+
**Understandable:**
|
|
1314
|
+
|
|
1315
|
+
- Consistent navigation
|
|
1316
|
+
- Identify input errors clearly
|
|
1317
|
+
- Labels and instructions for forms
|
|
1318
|
+
|
|
1319
|
+
**Robust:**
|
|
1320
|
+
|
|
1321
|
+
- Semantic HTML elements
|
|
1322
|
+
- ARIA only when HTML isn't enough
|
|
1323
|
+
- Tested with screen readers
|
|
1324
|
+
|
|
1325
|
+
### Focus Management
|
|
1326
|
+
|
|
1327
|
+
**Use `:focus-visible` over `:focus`:**
|
|
1328
|
+
|
|
1329
|
+
```css
|
|
1330
|
+
/* ✅ Only show focus ring for keyboard navigation */
|
|
1331
|
+
:focus-visible {
|
|
1332
|
+
outline: 2px solid var(--color-primary);
|
|
1333
|
+
outline-offset: 2px;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/* ❌ Don't remove outline without replacement */
|
|
1337
|
+
:focus {
|
|
1338
|
+
outline: none; /* BAD - removes accessibility */
|
|
1339
|
+
}
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
**Compound controls:**
|
|
1343
|
+
|
|
1344
|
+
```css
|
|
1345
|
+
/* Highlight parent when any child is focused */
|
|
1346
|
+
.input-group:focus-within {
|
|
1347
|
+
box-shadow: 0 0 0 2px var(--color-primary);
|
|
1348
|
+
}
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
### Keyboard Navigation
|
|
1352
|
+
|
|
1353
|
+
**All interactive elements must be keyboard-operable:**
|
|
1354
|
+
|
|
1355
|
+
```jsx
|
|
1356
|
+
// ❌ Click-only interaction
|
|
1357
|
+
<div onClick={handleAction}>Click me</div>
|
|
1358
|
+
|
|
1359
|
+
// ✅ Keyboard accessible
|
|
1360
|
+
<button onClick={handleAction}>Click me</button>
|
|
1361
|
+
|
|
1362
|
+
// ✅ If must use div, add keyboard support
|
|
1363
|
+
<div
|
|
1364
|
+
role="button"
|
|
1365
|
+
tabIndex={0}
|
|
1366
|
+
onClick={handleAction}
|
|
1367
|
+
onKeyDown={(e) => {
|
|
1368
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1369
|
+
e.preventDefault();
|
|
1370
|
+
handleAction();
|
|
1371
|
+
}
|
|
1372
|
+
}}
|
|
1373
|
+
>
|
|
1374
|
+
Click me
|
|
1375
|
+
</div>
|
|
1376
|
+
```
|
|
1377
|
+
|
|
1378
|
+
**Keyboard patterns:**
|
|
1379
|
+
|
|
1380
|
+
- Tab order must match visual order
|
|
1381
|
+
- Enter/Space activate buttons and links
|
|
1382
|
+
- Escape closes dialogs and dropdowns
|
|
1383
|
+
- Arrow keys navigate within components (tabs, menus)
|
|
1384
|
+
|
|
1385
|
+
### ARIA Patterns
|
|
1386
|
+
|
|
1387
|
+
**Icon buttons require `aria-label`:**
|
|
1388
|
+
|
|
1389
|
+
```jsx
|
|
1390
|
+
// ❌ No accessible name
|
|
1391
|
+
<button><CloseIcon /></button>
|
|
1392
|
+
|
|
1393
|
+
// ✅ Accessible
|
|
1394
|
+
<button aria-label="Close dialog"><CloseIcon /></button>
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
**Form controls require labels:**
|
|
1398
|
+
|
|
1399
|
+
```jsx
|
|
1400
|
+
// ❌ No label
|
|
1401
|
+
<input type="email" placeholder="Email" />
|
|
1402
|
+
|
|
1403
|
+
// ✅ Visible label
|
|
1404
|
+
<label>
|
|
1405
|
+
Email
|
|
1406
|
+
<input type="email" />
|
|
1407
|
+
</label>
|
|
1408
|
+
|
|
1409
|
+
// ✅ Or visually hidden label
|
|
1410
|
+
<label htmlFor="email" className="sr-only">Email</label>
|
|
1411
|
+
<input id="email" type="email" placeholder="email@example.com" />
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
**Live regions for async updates:**
|
|
1415
|
+
|
|
1416
|
+
```jsx
|
|
1417
|
+
// Announce dynamic content to screen readers
|
|
1418
|
+
<div aria-live="polite" aria-atomic="true">
|
|
1419
|
+
{statusMessage}
|
|
1420
|
+
</div>
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
**Semantic HTML before ARIA:**
|
|
1424
|
+
|
|
1425
|
+
```jsx
|
|
1426
|
+
// ❌ ARIA role when native element exists
|
|
1427
|
+
<div role="button" tabIndex={0}>Submit</div>
|
|
1428
|
+
|
|
1429
|
+
// ✅ Use native element
|
|
1430
|
+
<button>Submit</button>
|
|
1431
|
+
|
|
1432
|
+
// ❌ ARIA for native functionality
|
|
1433
|
+
<div role="navigation">...</div>
|
|
1434
|
+
|
|
1435
|
+
// ✅ Use native element
|
|
1436
|
+
<nav>...</nav>
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
**Common ARIA patterns:**
|
|
1440
|
+
|
|
1441
|
+
```html
|
|
1442
|
+
<!-- Modal -->
|
|
1443
|
+
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
|
1444
|
+
<h2 id="modal-title">Dialog Title</h2>
|
|
1445
|
+
</div>
|
|
1446
|
+
|
|
1447
|
+
<!-- Tab panel -->
|
|
1448
|
+
<div role="tablist">
|
|
1449
|
+
<button role="tab" aria-selected="true" aria-controls="panel1">Tab 1</button>
|
|
1450
|
+
</div>
|
|
1451
|
+
<div role="tabpanel" id="panel1">Content</div>
|
|
1452
|
+
|
|
1453
|
+
<!-- Loading state -->
|
|
1454
|
+
<button aria-busy="true" aria-describedby="loading-text">
|
|
1455
|
+
<span id="loading-text" className="sr-only">Loading...</span>
|
|
1456
|
+
</button>
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
---
|
|
1460
|
+
|
|
1461
|
+
## Part 7: Performance Patterns
|
|
1462
|
+
|
|
1463
|
+
### Virtualization
|
|
1464
|
+
|
|
1465
|
+
**Large lists require virtualization:**
|
|
1466
|
+
|
|
1467
|
+
```jsx
|
|
1468
|
+
// Use virtualization for lists > 50 items
|
|
1469
|
+
import { VList } from 'virtua';
|
|
1470
|
+
|
|
1471
|
+
function LargeList({ items }) {
|
|
1472
|
+
return (
|
|
1473
|
+
<VList style={{ height: 400 }}>
|
|
1474
|
+
{items.map(item => <ListItem key={item.id} item={item} />)}
|
|
1475
|
+
</VList>
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
**CSS-based virtualization:**
|
|
1481
|
+
|
|
1482
|
+
```css
|
|
1483
|
+
/* For simpler cases, use content-visibility */
|
|
1484
|
+
.list-item {
|
|
1485
|
+
content-visibility: auto;
|
|
1486
|
+
contain-intrinsic-size: 0 60px; /* Estimated height */
|
|
1487
|
+
}
|
|
1488
|
+
```
|
|
1489
|
+
|
|
1490
|
+
### Layout Thrashing
|
|
1491
|
+
|
|
1492
|
+
**Avoid layout reads in render:**
|
|
1493
|
+
|
|
1494
|
+
```jsx
|
|
1495
|
+
// ❌ Bad - forces layout recalculation
|
|
1496
|
+
function Component() {
|
|
1497
|
+
const width = element.getBoundingClientRect().width; // Layout read
|
|
1498
|
+
element.style.width = width + 10 + 'px'; // Layout write
|
|
1499
|
+
const height = element.offsetHeight; // Another layout read!
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// ✅ Good - batch reads, then writes
|
|
1503
|
+
function Component() {
|
|
1504
|
+
// Batch reads
|
|
1505
|
+
const width = element.getBoundingClientRect().width;
|
|
1506
|
+
const height = element.offsetHeight;
|
|
1507
|
+
|
|
1508
|
+
// Then batch writes
|
|
1509
|
+
requestAnimationFrame(() => {
|
|
1510
|
+
element.style.width = width + 10 + 'px';
|
|
1511
|
+
element.style.height = height + 10 + 'px';
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
**Properties that trigger layout:**
|
|
1517
|
+
|
|
1518
|
+
- `offsetHeight`, `offsetWidth`, `offsetTop`, `offsetLeft`
|
|
1519
|
+
- `getBoundingClientRect()`
|
|
1520
|
+
- `scrollHeight`, `scrollWidth`, `scrollTop`, `scrollLeft`
|
|
1521
|
+
- `getComputedStyle()`
|
|
1522
|
+
|
|
1523
|
+
### Resource Loading
|
|
1524
|
+
|
|
1525
|
+
**Preconnect to CDN domains:**
|
|
1526
|
+
|
|
1527
|
+
```html
|
|
1528
|
+
<!-- Add in <head> for domains you'll fetch from -->
|
|
1529
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
1530
|
+
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
**Preload critical fonts:**
|
|
1534
|
+
|
|
1535
|
+
```html
|
|
1536
|
+
<link
|
|
1537
|
+
rel="preload"
|
|
1538
|
+
href="/fonts/inter-var.woff2"
|
|
1539
|
+
as="font"
|
|
1540
|
+
type="font/woff2"
|
|
1541
|
+
crossorigin
|
|
1542
|
+
/>
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
**Image loading strategy:**
|
|
1546
|
+
|
|
1547
|
+
```jsx
|
|
1548
|
+
// Above the fold: load immediately
|
|
1549
|
+
<img src="/hero.jpg" fetchpriority="high" />
|
|
1550
|
+
|
|
1551
|
+
// Below the fold: lazy load
|
|
1552
|
+
<img src="/card.jpg" loading="lazy" />
|
|
1553
|
+
|
|
1554
|
+
// Critical background images: preload
|
|
1555
|
+
<link rel="preload" as="image" href="/hero-bg.jpg" />
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
---
|
|
1559
|
+
|
|
1560
|
+
## Part 8: Content & Copy
|
|
1561
|
+
|
|
1562
|
+
### Writing Style
|
|
1563
|
+
|
|
1564
|
+
**Active voice over passive:**
|
|
1565
|
+
|
|
1566
|
+
```
|
|
1567
|
+
✅ "Install the CLI"
|
|
1568
|
+
❌ "The CLI will be installed"
|
|
1569
|
+
|
|
1570
|
+
✅ "Your changes were saved"
|
|
1571
|
+
❌ "Changes have been saved by the system"
|
|
1572
|
+
```
|
|
1573
|
+
|
|
1574
|
+
**Title Case for headings and buttons:**
|
|
1575
|
+
|
|
1576
|
+
```
|
|
1577
|
+
✅ "Save API Key"
|
|
1578
|
+
❌ "Save api key"
|
|
1579
|
+
|
|
1580
|
+
✅ "Getting Started"
|
|
1581
|
+
❌ "Getting started"
|
|
1582
|
+
```
|
|
1583
|
+
|
|
1584
|
+
**Use numerals:**
|
|
1585
|
+
|
|
1586
|
+
```
|
|
1587
|
+
✅ "8 deployments"
|
|
1588
|
+
❌ "eight deployments"
|
|
1589
|
+
|
|
1590
|
+
✅ "3 items selected"
|
|
1591
|
+
❌ "three items selected"
|
|
1592
|
+
```
|
|
1593
|
+
|
|
1594
|
+
### Labels & Messages
|
|
1595
|
+
|
|
1596
|
+
**Specific labels over generic:**
|
|
1597
|
+
|
|
1598
|
+
```
|
|
1599
|
+
✅ "Save API Key"
|
|
1600
|
+
❌ "Continue"
|
|
1601
|
+
|
|
1602
|
+
✅ "Create Project"
|
|
1603
|
+
❌ "Submit"
|
|
1604
|
+
|
|
1605
|
+
✅ "Delete Repository"
|
|
1606
|
+
❌ "Confirm"
|
|
1607
|
+
```
|
|
1608
|
+
|
|
1609
|
+
**Error messages include fix/next step:**
|
|
1610
|
+
|
|
1611
|
+
```
|
|
1612
|
+
✅ "Email must include @ symbol"
|
|
1613
|
+
❌ "Invalid email"
|
|
1614
|
+
|
|
1615
|
+
✅ "Password must be at least 8 characters"
|
|
1616
|
+
❌ "Password too short"
|
|
1617
|
+
|
|
1618
|
+
✅ "Could not connect. Check your internet connection and try again."
|
|
1619
|
+
❌ "Network error"
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
### Internationalization
|
|
1623
|
+
|
|
1624
|
+
**Use Intl APIs for formatting:**
|
|
1625
|
+
|
|
1626
|
+
```jsx
|
|
1627
|
+
// ❌ Hardcoded format
|
|
1628
|
+
const date = `${month}/${day}/${year}`;
|
|
1629
|
+
const price = `$${amount.toFixed(2)}`;
|
|
1630
|
+
|
|
1631
|
+
// ✅ Locale-aware formatting
|
|
1632
|
+
const date = new Intl.DateTimeFormat('en-US', {
|
|
1633
|
+
dateStyle: 'medium'
|
|
1634
|
+
}).format(new Date());
|
|
1635
|
+
|
|
1636
|
+
const price = new Intl.NumberFormat('en-US', {
|
|
1637
|
+
style: 'currency',
|
|
1638
|
+
currency: 'USD'
|
|
1639
|
+
}).format(amount);
|
|
1640
|
+
|
|
1641
|
+
// Relative time
|
|
1642
|
+
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
|
1643
|
+
rtf.format(-1, 'day'); // "yesterday"
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
---
|
|
1647
|
+
|
|
1648
|
+
## Part 9: Anti-Patterns Checklist
|
|
1649
|
+
|
|
1650
|
+
Flag these patterns during design review:
|
|
1651
|
+
|
|
1652
|
+
### Accessibility Violations
|
|
1653
|
+
|
|
1654
|
+
- [ ] `user-scalable=no` or `maximum-scale=1` in viewport meta
|
|
1655
|
+
- [ ] `<div onClick>` instead of `<button>` for interactive elements
|
|
1656
|
+
- [ ] Form inputs without associated labels
|
|
1657
|
+
- [ ] Icon buttons without `aria-label`
|
|
1658
|
+
- [ ] `outline: none` without focus replacement
|
|
1659
|
+
|
|
1660
|
+
### Performance Issues
|
|
1661
|
+
|
|
1662
|
+
- [ ] `transition: all` (animates layout properties)
|
|
1663
|
+
- [ ] Images without explicit `width` and `height`
|
|
1664
|
+
- [ ] Large arrays (>50 items) rendered without virtualization
|
|
1665
|
+
- [ ] Layout reads (`getBoundingClientRect`) in render cycle
|
|
1666
|
+
|
|
1667
|
+
### UX Problems
|
|
1668
|
+
|
|
1669
|
+
- [ ] `onPaste` with `preventDefault()` (blocks paste)
|
|
1670
|
+
- [ ] Hardcoded date/number formats (not using Intl)
|
|
1671
|
+
- [ ] Placeholder used as label
|
|
1672
|
+
- [ ] Validation on every keystroke
|
|
1673
|
+
- [ ] No empty state designed
|
|
1674
|
+
|
|
1675
|
+
### Mobile Issues
|
|
1676
|
+
|
|
1677
|
+
- [ ] Touch targets smaller than 44x44px
|
|
1678
|
+
- [ ] No `touch-action: manipulation` on buttons
|
|
1679
|
+
- [ ] Hover effects without `@media (hover: hover)` query
|
|
1680
|
+
|
|
1681
|
+
### Interface Robustness Checklist
|
|
1682
|
+
|
|
1683
|
+
Test every interactive component against these stress conditions:
|
|
1684
|
+
|
|
1685
|
+
**Rapid User Input:**
|
|
1686
|
+
- [ ] Scroll fast — does it break or show visual glitches?
|
|
1687
|
+
- [ ] Spam click — does it trigger multiple actions or crash?
|
|
1688
|
+
- [ ] Resize window rapidly — does layout break or cause errors?
|
|
1689
|
+
- [ ] Type very fast in inputs — does it lag or lose characters?
|
|
1690
|
+
|
|
1691
|
+
**Animation Interruption:**
|
|
1692
|
+
- [ ] Interrupt animations mid-way — does it recover gracefully?
|
|
1693
|
+
- [ ] Close modal while opening — does it handle state correctly?
|
|
1694
|
+
- [ ] Navigate away during transition — does it clean up properly?
|
|
1695
|
+
- [ ] Trigger multiple animations simultaneously — do they conflict?
|
|
1696
|
+
|
|
1697
|
+
**Network Conditions:**
|
|
1698
|
+
- [ ] Test with slow network (3G throttling) — does it show loading states?
|
|
1699
|
+
- [ ] Test with offline mode — does it fail gracefully?
|
|
1700
|
+
- [ ] Test with request timeout — does it retry or show error?
|
|
1701
|
+
|
|
1702
|
+
**Input Methods:**
|
|
1703
|
+
- [ ] Test with keyboard only — is everything accessible?
|
|
1704
|
+
- [ ] Test with screen reader — are states announced?
|
|
1705
|
+
- [ ] Test with touch device — are hit areas adequate?
|
|
1706
|
+
- [ ] Test with trackpad gestures — do scroll/swipe work correctly?
|
|
1707
|
+
|
|
1708
|
+
**State Combinations:**
|
|
1709
|
+
- [ ] Test all combinations of loading + error + empty states
|
|
1710
|
+
- [ ] Test disabled state during async operations
|
|
1711
|
+
- [ ] Test hover + focus + active simultaneously
|
|
1712
|
+
- [ ] Test with maximum and minimum content
|
|
1713
|
+
|
|
1714
|
+
**Memory & Performance:**
|
|
1715
|
+
- [ ] Run for extended period — does memory grow unbounded?
|
|
1716
|
+
- [ ] Test with large datasets — does it virtualize or paginate?
|
|
1717
|
+
- [ ] Monitor for memory leaks in animations and observers
|
|
1718
|
+
- [ ] Check for event listener cleanup on unmount
|
|
1719
|
+
|
|
1720
|
+
---
|
|
1721
|
+
|
|
1722
|
+
## Part 10: Design System References
|
|
1723
|
+
|
|
1724
|
+
### Study These Systems
|
|
1725
|
+
|
|
1726
|
+
**For Clarity & Precision:**
|
|
1727
|
+
|
|
1728
|
+
- [Linear](https://linear.app) - Information density done right
|
|
1729
|
+
- [Stripe](https://stripe.com) - Trust through craft
|
|
1730
|
+
- [Vercel](https://vercel.com) - Developer-focused simplicity
|
|
1731
|
+
|
|
1732
|
+
**For Warmth & Approachability:**
|
|
1733
|
+
|
|
1734
|
+
- [Airbnb](https://airbnb.com) - Friendly, image-forward
|
|
1735
|
+
- [Notion](https://notion.so) - Flexible, playful
|
|
1736
|
+
- [Slack](https://slack.com) - Conversational, colorful
|
|
1737
|
+
|
|
1738
|
+
**For Data & Density:**
|
|
1739
|
+
|
|
1740
|
+
- [Bloomberg Terminal](https://bloomberg.com) - Maximum information
|
|
1741
|
+
- [Figma](https://figma.com) - Tool-like precision
|
|
1742
|
+
- [GitHub](https://github.com) - Code-centric clarity
|
|
1743
|
+
|
|
1744
|
+
**For Motion & Delight:**
|
|
1745
|
+
|
|
1746
|
+
- [Apple](https://apple.com) - Cinematic quality
|
|
1747
|
+
- [Framer](https://framer.com) - Motion-first
|
|
1748
|
+
|
|
1749
|
+
### When Generating Variants
|
|
1750
|
+
|
|
1751
|
+
Reference specific aspects:
|
|
1752
|
+
|
|
1753
|
+
- "Use Linear's density approach"
|
|
1754
|
+
- "Stripe's button hierarchy"
|
|
1755
|
+
- "Airbnb's card layout"
|
|
1756
|
+
- "Notion's toggle interaction"
|
|
1757
|
+
- "Vercel's dark mode palette"
|
|
1758
|
+
|
|
1759
|
+
---
|
|
1760
|
+
|
|
1761
|
+
## Part 11: Code Patterns Library
|
|
1762
|
+
|
|
1763
|
+
Practical patterns for implementing advanced interactions.
|
|
1764
|
+
|
|
1765
|
+
### useMotionValue vs useState
|
|
1766
|
+
|
|
1767
|
+
**Performance difference:**
|
|
1768
|
+
|
|
1769
|
+
```jsx
|
|
1770
|
+
// ❌ Bad - triggers re-render on every frame
|
|
1771
|
+
const [x, setX] = useState(0);
|
|
1772
|
+
<div style={{ transform: `translateX(${x}px)` }} />
|
|
1773
|
+
|
|
1774
|
+
// ✅ Good - bypasses React, updates directly
|
|
1775
|
+
const x = useMotionValue(0);
|
|
1776
|
+
<motion.div style={{ x }} />
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
Use `useMotionValue` for:
|
|
1780
|
+
- Continuous animation values (position, scale, opacity)
|
|
1781
|
+
- Gesture-driven values (drag position)
|
|
1782
|
+
- Any value that changes at 60fps
|
|
1783
|
+
|
|
1784
|
+
Use `useState` for:
|
|
1785
|
+
- Discrete states (open/closed, active tab)
|
|
1786
|
+
- Values that trigger layout changes
|
|
1787
|
+
|
|
1788
|
+
### useSpring: jump() vs set()
|
|
1789
|
+
|
|
1790
|
+
```jsx
|
|
1791
|
+
const springValue = useSpring(0, { stiffness: 300, damping: 30 });
|
|
1792
|
+
|
|
1793
|
+
// set() - Animate to target value with spring physics
|
|
1794
|
+
springValue.set(100); // Smoothly animates from current → 100
|
|
1795
|
+
|
|
1796
|
+
// jump() - Instantly set value, no animation
|
|
1797
|
+
springValue.jump(100); // Immediately becomes 100
|
|
1798
|
+
|
|
1799
|
+
// Use jump() during drag, set() on release
|
|
1800
|
+
const handleDrag = (e) => springValue.jump(e.clientX);
|
|
1801
|
+
const handleRelease = () => springValue.set(snapPoint);
|
|
1802
|
+
```
|
|
1803
|
+
|
|
1804
|
+
### Grid Stacking for Overlapping Elements
|
|
1805
|
+
|
|
1806
|
+
**Stack elements using CSS Grid (no absolute positioning):**
|
|
1807
|
+
|
|
1808
|
+
```jsx
|
|
1809
|
+
// All children occupy the same grid cell
|
|
1810
|
+
function StackedElements({ children }) {
|
|
1811
|
+
return (
|
|
1812
|
+
<div style={{
|
|
1813
|
+
display: 'grid',
|
|
1814
|
+
gridTemplateColumns: '1fr',
|
|
1815
|
+
gridTemplateRows: '1fr',
|
|
1816
|
+
}}>
|
|
1817
|
+
{children.map((child, i) => (
|
|
1818
|
+
<div key={i} style={{ gridArea: '1 / 1' }}>
|
|
1819
|
+
{child}
|
|
1820
|
+
</div>
|
|
1821
|
+
))}
|
|
1822
|
+
</div>
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
```
|
|
1826
|
+
|
|
1827
|
+
**Benefits over `position: absolute`:**
|
|
1828
|
+
- Children contribute to parent size
|
|
1829
|
+
- Natural document flow
|
|
1830
|
+
- Better for responsive layouts
|
|
1831
|
+
|
|
1832
|
+
### Native Scroll vs Wheel Events
|
|
1833
|
+
|
|
1834
|
+
**Prefer native scroll over wheel event listeners:**
|
|
1835
|
+
|
|
1836
|
+
```jsx
|
|
1837
|
+
// ❌ Bad - janky, blocks main thread
|
|
1838
|
+
element.addEventListener('wheel', (e) => {
|
|
1839
|
+
scrollPosition += e.deltaY;
|
|
1840
|
+
element.style.transform = `translateY(${-scrollPosition}px)`;
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
// ✅ Good - smooth, GPU-accelerated
|
|
1844
|
+
<div style={{ overflow: 'auto', scrollBehavior: 'smooth' }}>
|
|
1845
|
+
{content}
|
|
1846
|
+
</div>
|
|
1847
|
+
```
|
|
1848
|
+
|
|
1849
|
+
**When you need scroll position reactively:**
|
|
1850
|
+
|
|
1851
|
+
```jsx
|
|
1852
|
+
// Use Intersection Observer for scroll-triggered effects
|
|
1853
|
+
const observer = new IntersectionObserver(
|
|
1854
|
+
(entries) => {
|
|
1855
|
+
entries.forEach(entry => {
|
|
1856
|
+
if (entry.isIntersecting) {
|
|
1857
|
+
entry.target.classList.add('visible');
|
|
1858
|
+
}
|
|
1859
|
+
});
|
|
1860
|
+
},
|
|
1861
|
+
{ threshold: 0.1 }
|
|
1862
|
+
);
|
|
1863
|
+
```
|
|
1864
|
+
|
|
1865
|
+
### clip-path for Animated Resizing
|
|
1866
|
+
|
|
1867
|
+
**Animate size without layout shifts:**
|
|
1868
|
+
|
|
1869
|
+
```jsx
|
|
1870
|
+
// ❌ Bad - animating width triggers layout
|
|
1871
|
+
<motion.div animate={{ width: isOpen ? 300 : 0 }} />
|
|
1872
|
+
|
|
1873
|
+
// ✅ Good - clip-path is GPU-accelerated
|
|
1874
|
+
<motion.div
|
|
1875
|
+
style={{ width: 300 }} // Fixed width
|
|
1876
|
+
animate={{
|
|
1877
|
+
clipPath: isOpen
|
|
1878
|
+
? 'inset(0 0 0 0)'
|
|
1879
|
+
: 'inset(0 100% 0 0)' // Clip from right
|
|
1880
|
+
}}
|
|
1881
|
+
/>
|
|
1882
|
+
```
|
|
1883
|
+
|
|
1884
|
+
**Reveal patterns:**
|
|
1885
|
+
|
|
1886
|
+
```css
|
|
1887
|
+
/* Reveal from left */
|
|
1888
|
+
clip-path: inset(0 100% 0 0) → inset(0 0 0 0)
|
|
1889
|
+
|
|
1890
|
+
/* Reveal from center */
|
|
1891
|
+
clip-path: inset(0 50% 0 50%) → inset(0 0 0 0)
|
|
1892
|
+
|
|
1893
|
+
/* Reveal from top */
|
|
1894
|
+
clip-path: inset(0 0 100% 0) → inset(0 0 0 0)
|
|
1895
|
+
|
|
1896
|
+
/* Circle reveal from center */
|
|
1897
|
+
clip-path: circle(0% at 50% 50%) → circle(100% at 50% 50%)
|
|
1898
|
+
```
|
|
1899
|
+
|
|
1900
|
+
### Scroll Fading (Blur Fade Effect)
|
|
1901
|
+
|
|
1902
|
+
**Fade content at scroll edges:**
|
|
1903
|
+
|
|
1904
|
+
```css
|
|
1905
|
+
.scroll-container {
|
|
1906
|
+
overflow-y: auto;
|
|
1907
|
+
mask-image: linear-gradient(
|
|
1908
|
+
to bottom,
|
|
1909
|
+
transparent 0%,
|
|
1910
|
+
black 10%,
|
|
1911
|
+
black 90%,
|
|
1912
|
+
transparent 100%
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/* Or use scroll-driven animations (modern browsers) */
|
|
1917
|
+
@supports (animation-timeline: scroll()) {
|
|
1918
|
+
.scroll-item {
|
|
1919
|
+
animation: fadeIn linear both;
|
|
1920
|
+
animation-timeline: view();
|
|
1921
|
+
animation-range: entry 0% entry 20%;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
@keyframes fadeIn {
|
|
1925
|
+
from { opacity: 0; transform: translateY(20px); }
|
|
1926
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
```
|
|
1930
|
+
|
|
1931
|
+
### overflow: clip vs hidden
|
|
1932
|
+
|
|
1933
|
+
**Use `clip` when you don't need scroll:**
|
|
1934
|
+
|
|
1935
|
+
```css
|
|
1936
|
+
/* hidden: creates scroll container, may affect stacking */
|
|
1937
|
+
.container { overflow: hidden; }
|
|
1938
|
+
|
|
1939
|
+
/* clip: just clips, no scroll container created */
|
|
1940
|
+
.container { overflow: clip; }
|
|
1941
|
+
```
|
|
1942
|
+
|
|
1943
|
+
**When to use each:**
|
|
1944
|
+
- `hidden` - Need scrollable overflow (JS can scroll it)
|
|
1945
|
+
- `clip` - Just want to hide overflow, no scroll needed (better perf)
|
|
1946
|
+
|
|
1947
|
+
### Re-mount with React Key for CSS Keyframes
|
|
1948
|
+
|
|
1949
|
+
**Replay CSS animations by changing key:**
|
|
1950
|
+
|
|
1951
|
+
```jsx
|
|
1952
|
+
// CSS animation plays once on mount
|
|
1953
|
+
function Notification({ message }) {
|
|
1954
|
+
const [key, setKey] = useState(0);
|
|
1955
|
+
|
|
1956
|
+
const triggerAnimation = () => setKey(k => k + 1);
|
|
1957
|
+
|
|
1958
|
+
return (
|
|
1959
|
+
<div key={key} className="animate-shake">
|
|
1960
|
+
{message}
|
|
1961
|
+
</div>
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
```
|
|
1965
|
+
|
|
1966
|
+
```css
|
|
1967
|
+
.animate-shake {
|
|
1968
|
+
animation: shake 0.5s ease-out;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
@keyframes shake {
|
|
1972
|
+
0%, 100% { transform: translateX(0); }
|
|
1973
|
+
25% { transform: translateX(-5px); }
|
|
1974
|
+
75% { transform: translateX(5px); }
|
|
1975
|
+
}
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
### Smart Image Preloading
|
|
1979
|
+
|
|
1980
|
+
**Preload images before they're needed:**
|
|
1981
|
+
|
|
1982
|
+
```jsx
|
|
1983
|
+
// Preload on hover (link navigation)
|
|
1984
|
+
function NavLink({ href, children }) {
|
|
1985
|
+
const preloadImages = () => {
|
|
1986
|
+
const images = ['/hero.jpg', '/feature.png'];
|
|
1987
|
+
images.forEach(src => {
|
|
1988
|
+
const img = new Image();
|
|
1989
|
+
img.src = src;
|
|
1990
|
+
});
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
return (
|
|
1994
|
+
<Link
|
|
1995
|
+
href={href}
|
|
1996
|
+
onMouseEnter={preloadImages}
|
|
1997
|
+
onFocus={preloadImages}
|
|
1998
|
+
>
|
|
1999
|
+
{children}
|
|
2000
|
+
</Link>
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// Preload based on viewport proximity
|
|
2005
|
+
function useImagePreload(src, rootMargin = '200px') {
|
|
2006
|
+
const ref = useRef(null);
|
|
2007
|
+
|
|
2008
|
+
useEffect(() => {
|
|
2009
|
+
const observer = new IntersectionObserver(
|
|
2010
|
+
([entry]) => {
|
|
2011
|
+
if (entry.isIntersecting) {
|
|
2012
|
+
const img = new Image();
|
|
2013
|
+
img.src = src;
|
|
2014
|
+
observer.disconnect();
|
|
2015
|
+
}
|
|
2016
|
+
},
|
|
2017
|
+
{ rootMargin }
|
|
2018
|
+
);
|
|
2019
|
+
|
|
2020
|
+
if (ref.current) observer.observe(ref.current);
|
|
2021
|
+
return () => observer.disconnect();
|
|
2022
|
+
}, [src, rootMargin]);
|
|
2023
|
+
|
|
2024
|
+
return ref;
|
|
2025
|
+
}
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
### layoutId for Shared Element Transitions
|
|
2029
|
+
|
|
2030
|
+
**Morph elements between views:**
|
|
2031
|
+
|
|
2032
|
+
```jsx
|
|
2033
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
2034
|
+
|
|
2035
|
+
function App() {
|
|
2036
|
+
const [selected, setSelected] = useState(null);
|
|
2037
|
+
|
|
2038
|
+
return (
|
|
2039
|
+
<>
|
|
2040
|
+
{/* Grid of cards */}
|
|
2041
|
+
{items.map(item => (
|
|
2042
|
+
<motion.div
|
|
2043
|
+
key={item.id}
|
|
2044
|
+
layoutId={`card-${item.id}`}
|
|
2045
|
+
onClick={() => setSelected(item)}
|
|
2046
|
+
>
|
|
2047
|
+
<Card item={item} />
|
|
2048
|
+
</motion.div>
|
|
2049
|
+
))}
|
|
2050
|
+
|
|
2051
|
+
{/* Expanded view */}
|
|
2052
|
+
<AnimatePresence>
|
|
2053
|
+
{selected && (
|
|
2054
|
+
<motion.div
|
|
2055
|
+
layoutId={`card-${selected.id}`}
|
|
2056
|
+
className="expanded-card"
|
|
2057
|
+
>
|
|
2058
|
+
<ExpandedCard item={selected} />
|
|
2059
|
+
</motion.div>
|
|
2060
|
+
)}
|
|
2061
|
+
</AnimatePresence>
|
|
2062
|
+
</>
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
```
|
|
2066
|
+
|
|
2067
|
+
### Debounced Resize Observer
|
|
2068
|
+
|
|
2069
|
+
**Prevent excessive callbacks during resize:**
|
|
2070
|
+
|
|
2071
|
+
```jsx
|
|
2072
|
+
function useResizeObserver(ref, callback, debounceMs = 100) {
|
|
2073
|
+
useEffect(() => {
|
|
2074
|
+
const element = ref.current;
|
|
2075
|
+
if (!element) return;
|
|
2076
|
+
|
|
2077
|
+
let timeoutId;
|
|
2078
|
+
const observer = new ResizeObserver((entries) => {
|
|
2079
|
+
clearTimeout(timeoutId);
|
|
2080
|
+
timeoutId = setTimeout(() => {
|
|
2081
|
+
callback(entries[0].contentRect);
|
|
2082
|
+
}, debounceMs);
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
observer.observe(element);
|
|
2086
|
+
return () => {
|
|
2087
|
+
clearTimeout(timeoutId);
|
|
2088
|
+
observer.disconnect();
|
|
2089
|
+
};
|
|
2090
|
+
}, [ref, callback, debounceMs]);
|
|
2091
|
+
}
|
|
2092
|
+
```
|
|
2093
|
+
|
|
2094
|
+
---
|
|
2095
|
+
|
|
2096
|
+
## Quick Decision Framework
|
|
2097
|
+
|
|
2098
|
+
When unsure, ask:
|
|
2099
|
+
|
|
2100
|
+
1. **Is it clear?** → User knows what to do and what happened
|
|
2101
|
+
2. **Is it fast?** → Minimum steps, appropriate feedback
|
|
2102
|
+
3. **Is it consistent?** → Matches patterns elsewhere in the app
|
|
2103
|
+
4. **Is it accessible?** → Keyboard, screen reader, color contrast
|
|
2104
|
+
5. **Is it calm?** → No unnecessary motion, color, or elements
|
|
2105
|
+
6. **Is it specific?** → Labels describe exactly what will happen
|
|
2106
|
+
7. **Is it recoverable?** → User can undo or go back
|
|
2107
|
+
|
|
2108
|
+
### Animation Decision Quick Check
|
|
2109
|
+
|
|
2110
|
+
```
|
|
2111
|
+
Should this animate?
|
|
2112
|
+
├── Used 100+ times/day? → No animation
|
|
2113
|
+
├── Entering/exiting screen? → ease-out, 150-250ms
|
|
2114
|
+
├── Moving on screen? → ease-in-out, 200-300ms
|
|
2115
|
+
├── Hover/color change? → ease, 100-150ms
|
|
2116
|
+
└── Unsure? → Start without animation, add if needed
|
|
2117
|
+
```
|
|
2118
|
+
|
|
2119
|
+
### Interaction Pattern Quick Reference
|
|
2120
|
+
|
|
2121
|
+
| Situation | Solution |
|
|
2122
|
+
|-----------|----------|
|
|
2123
|
+
| Overlapping motion | Add 1-2px blur during transition |
|
|
2124
|
+
| Menu animation | Fade-out only, no fade-in (instant appear) |
|
|
2125
|
+
| Keyboard interactions | Often no animation needed (50ms max) |
|
|
2126
|
+
| High-frequency updates | Skip animation if update < 100ms apart |
|
|
2127
|
+
| Drag gesture | Use `jump()` during, `set()` at end |
|
|
2128
|
+
| Touch gestures | Use `touch-action: none` to capture |
|
|
2129
|
+
| Thin hit areas | Expand with `::after` pseudo-element |
|
|
2130
|
+
| Scroll-based animation | Use native scroll, not wheel event |
|
|
2131
|
+
| Width animation | Use `clip-path` instead of width |
|
|
2132
|
+
| Replay CSS animation | Change React `key` prop |
|
|
2133
|
+
| Morph between elements | Use Framer Motion `layoutId` |
|
|
2134
|
+
| State during gesture | Track: idle → press → drag → drag-end |
|
|
2135
|
+
| Overflow without scroll | Use `overflow: clip` |
|
|
2136
|
+
| Exit animations | Double the spring stiffness |
|
|
2137
|
+
| Icon swap | Crossfade with scale 0.5 + blur 7px |
|
|
2138
|
+
| Preload data | Start on `mousedown` (before click) |
|
|
2139
|
+
| Spring animation values | Never reuse — tune per interaction |
|
|
2140
|
+
| React animation perf | Use `useMotionValue`, not `useState` |
|