oh-my-customcode 0.57.0 → 0.58.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/README.md +7 -7
- package/dist/cli/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/.claude/agents/fe-design-expert.md +121 -0
- package/templates/.claude/agents/fe-svelte-agent.md +2 -0
- package/templates/.claude/agents/fe-vercel-agent.md +1 -0
- package/templates/.claude/agents/fe-vuejs-agent.md +2 -0
- package/templates/.claude/skills/impeccable-design/SKILL.md +173 -0
- package/templates/CLAUDE.md +3 -3
- package/templates/guides/impeccable-design/color-and-contrast.md +278 -0
- package/templates/guides/impeccable-design/index.yaml +12 -0
- package/templates/guides/impeccable-design/motion-design.md +390 -0
- package/templates/guides/impeccable-design/typography.md +386 -0
- package/templates/guides/impeccable-design/ux-writing.md +400 -0
- package/templates/guides/index.yaml +9 -0
- package/templates/manifest.json +5 -5
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Color and Contrast
|
|
2
|
+
|
|
3
|
+
> Reference: Impeccable Design Language — https://github.com/pbakaus/impeccable (Apache 2.0)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## OKLCH: The Preferred Color Model
|
|
8
|
+
|
|
9
|
+
OKLCH (Oklab Lightness, Chroma, Hue) is a perceptually uniform color space. Unlike HSL, equal numeric changes in OKLCH produce equal perceived differences — making it the right tool for generating accessible, harmonious palettes programmatically.
|
|
10
|
+
|
|
11
|
+
### Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
oklch(lightness% chroma hue)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Channel | Range | Description |
|
|
18
|
+
|---------|-------|-------------|
|
|
19
|
+
| `lightness` | 0%–100% | Perceived brightness |
|
|
20
|
+
| `chroma` | 0–0.4 (approx) | Color saturation/vividness |
|
|
21
|
+
| `hue` | 0–360 | Color angle (red=25, yellow=90, green=145, cyan=200, blue=250, purple=310) |
|
|
22
|
+
|
|
23
|
+
### Why not HSL?
|
|
24
|
+
|
|
25
|
+
HSL's lightness channel is not perceptually uniform. A blue at `hsl(250, 70%, 50%)` and a yellow at `hsl(60, 70%, 50%)` have the same numeric lightness but very different perceived brightness. This makes HSL palettes that "look right" on the screen require constant manual tuning.
|
|
26
|
+
|
|
27
|
+
OKLCH fixes this: a blue at `oklch(50% 0.15 250)` and a yellow at `oklch(50% 0.15 90)` appear equally bright.
|
|
28
|
+
|
|
29
|
+
### Browser support
|
|
30
|
+
|
|
31
|
+
OKLCH is supported in all modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+). For legacy support, provide an HSL fallback:
|
|
32
|
+
|
|
33
|
+
```css
|
|
34
|
+
color: hsl(250, 60%, 40%); /* fallback */
|
|
35
|
+
color: oklch(40% 0.15 250); /* modern */
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Chroma Constraints
|
|
41
|
+
|
|
42
|
+
### Reduce chroma near white and black extremes
|
|
43
|
+
|
|
44
|
+
At very high or very low lightness values, maximum chroma cannot be rendered — the color clips to the display gamut. Reduce chroma as lightness approaches 0% or 100%:
|
|
45
|
+
|
|
46
|
+
| Lightness range | Max chroma (approx) |
|
|
47
|
+
|-----------------|---------------------|
|
|
48
|
+
| 10%–20% | 0.04–0.08 |
|
|
49
|
+
| 20%–40% | 0.08–0.20 |
|
|
50
|
+
| 40%–60% | 0.15–0.35 |
|
|
51
|
+
| 60%–80% | 0.10–0.25 |
|
|
52
|
+
| 80%–95% | 0.03–0.10 |
|
|
53
|
+
|
|
54
|
+
Check rendered output with [OKLCH.com](https://oklch.com/) — the gamut boundary is shown visually.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Neutrals: Tinted, Not Pure Gray
|
|
59
|
+
|
|
60
|
+
Pure grays (chroma 0) appear cold and disconnected from the UI's color palette. Tinting neutrals with a low-chroma version of the brand hue creates cohesion.
|
|
61
|
+
|
|
62
|
+
### Formula
|
|
63
|
+
|
|
64
|
+
Use the brand hue with chroma 0.01–0.02 for neutrals:
|
|
65
|
+
|
|
66
|
+
```css
|
|
67
|
+
:root {
|
|
68
|
+
/* Brand hue: 250 (blue) */
|
|
69
|
+
--neutral-900: oklch(12% 0.01 250);
|
|
70
|
+
--neutral-800: oklch(20% 0.01 250);
|
|
71
|
+
--neutral-700: oklch(30% 0.01 250);
|
|
72
|
+
--neutral-600: oklch(40% 0.01 250);
|
|
73
|
+
--neutral-500: oklch(50% 0.01 250);
|
|
74
|
+
--neutral-400: oklch(60% 0.01 250);
|
|
75
|
+
--neutral-300: oklch(72% 0.01 250);
|
|
76
|
+
--neutral-200: oklch(84% 0.01 250);
|
|
77
|
+
--neutral-100: oklch(93% 0.01 250);
|
|
78
|
+
--neutral-50: oklch(97% 0.01 250);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Temperature
|
|
83
|
+
|
|
84
|
+
| Hue angle | Temperature | Effect |
|
|
85
|
+
|-----------|-------------|--------|
|
|
86
|
+
| ~60° | Warm | Approachable, editorial |
|
|
87
|
+
| ~250° | Cool | Technical, precise, professional |
|
|
88
|
+
|
|
89
|
+
Warm neutrals (cream, off-white) suit consumer and lifestyle products. Cool neutrals suit developer tools, dashboards, and data products.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Palette Architecture
|
|
94
|
+
|
|
95
|
+
### Components
|
|
96
|
+
|
|
97
|
+
| Component | Count | Purpose |
|
|
98
|
+
|-----------|-------|---------|
|
|
99
|
+
| Primary | 1 color, 3–5 shades | Brand identity, CTAs, interactive elements |
|
|
100
|
+
| Neutral | 9–11 shades | Backgrounds, borders, text |
|
|
101
|
+
| Semantic | 4 colors (success, warning, danger, info), 2–3 shades each | State communication |
|
|
102
|
+
| Surface | 2–3 variants | Background layering (base, raised, overlay) |
|
|
103
|
+
|
|
104
|
+
### Primary palette (5 shades)
|
|
105
|
+
|
|
106
|
+
```css
|
|
107
|
+
:root {
|
|
108
|
+
--primary-50: oklch(95% 0.05 250); /* tint, hover backgrounds */
|
|
109
|
+
--primary-200: oklch(80% 0.10 250); /* light states */
|
|
110
|
+
--primary-500: oklch(55% 0.20 250); /* primary action */
|
|
111
|
+
--primary-700: oklch(38% 0.18 250); /* pressed, dark variant */
|
|
112
|
+
--primary-900: oklch(22% 0.12 250); /* text on light bg */
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Semantic palette
|
|
117
|
+
|
|
118
|
+
```css
|
|
119
|
+
:root {
|
|
120
|
+
/* Success */
|
|
121
|
+
--success-light: oklch(92% 0.06 145);
|
|
122
|
+
--success: oklch(50% 0.18 145);
|
|
123
|
+
--success-dark: oklch(35% 0.15 145);
|
|
124
|
+
|
|
125
|
+
/* Warning */
|
|
126
|
+
--warning-light: oklch(93% 0.08 85);
|
|
127
|
+
--warning: oklch(65% 0.20 85);
|
|
128
|
+
--warning-dark: oklch(45% 0.18 85);
|
|
129
|
+
|
|
130
|
+
/* Danger */
|
|
131
|
+
--danger-light: oklch(93% 0.06 25);
|
|
132
|
+
--danger: oklch(50% 0.20 25);
|
|
133
|
+
--danger-dark: oklch(35% 0.18 25);
|
|
134
|
+
|
|
135
|
+
/* Info */
|
|
136
|
+
--info-light: oklch(93% 0.05 230);
|
|
137
|
+
--info: oklch(52% 0.18 230);
|
|
138
|
+
--info-dark: oklch(38% 0.16 230);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## The 60-30-10 Rule
|
|
145
|
+
|
|
146
|
+
Visual weight should be distributed to create hierarchy without chaos:
|
|
147
|
+
|
|
148
|
+
| Proportion | Role | Example |
|
|
149
|
+
|------------|------|---------|
|
|
150
|
+
| 60% | Dominant (neutral backgrounds) | Page background, card surfaces |
|
|
151
|
+
| 30% | Secondary (supporting) | Sidebar, navigation, secondary panels |
|
|
152
|
+
| 10% | Accent (brand, CTAs) | Buttons, links, highlights, icons |
|
|
153
|
+
|
|
154
|
+
Violating this ratio — for example, a 40% brand-color background — overwhelms the interface and makes it harder to identify interactive elements.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## WCAG Contrast Requirements
|
|
159
|
+
|
|
160
|
+
Contrast ratio is calculated between foreground and background luminance. Use a contrast checker to verify all text/background combinations.
|
|
161
|
+
|
|
162
|
+
### Minimum ratios
|
|
163
|
+
|
|
164
|
+
| Element | WCAG AA | WCAG AAA |
|
|
165
|
+
|---------|---------|----------|
|
|
166
|
+
| Body text (< 18px / < 14px bold) | 4.5:1 | 7:1 |
|
|
167
|
+
| Large text (≥ 18px / ≥ 14px bold) | 3:1 | 4.5:1 |
|
|
168
|
+
| UI components (borders, icons, input outlines) | 3:1 | 4.5:1 |
|
|
169
|
+
|
|
170
|
+
### Practical targets
|
|
171
|
+
|
|
172
|
+
- Body text: aim for 7:1 (AAA) — the difference in effort is minimal and accessibility is significantly better
|
|
173
|
+
- Large headings: 4.5:1 minimum
|
|
174
|
+
- Placeholder text: WCAG requires 4.5:1; placeholders are not exempt despite their decorative role
|
|
175
|
+
- Disabled elements: WCAG exempts disabled controls from contrast requirements, but aim for 3:1 as a courtesy
|
|
176
|
+
|
|
177
|
+
### Anti-patterns
|
|
178
|
+
|
|
179
|
+
| Anti-pattern | Problem | Fix |
|
|
180
|
+
|--------------|---------|-----|
|
|
181
|
+
| Light gray text on white | Classic failure — looks designed, fails AA | Use `oklch(45% 0.01 250)` on white for safe gray |
|
|
182
|
+
| Gray text on colored background | Double unpredictability — two variables affecting contrast | Test with a contrast checker, not by eye |
|
|
183
|
+
| Red on green | Colorblind failure (deuteranopia/protanopia) | Add pattern or icon; do not rely on color alone |
|
|
184
|
+
| Blue on red | Chromatic aberration causes vibration | Add lightness contrast; avoid hue-only contrast |
|
|
185
|
+
| Yellow on white | Low contrast despite perceived brightness | Yellow on white fails AA unless very dark yellow |
|
|
186
|
+
| Thin text over images | Impossible to guarantee; background varies | Add text shadow, overlay panel, or blur backdrop |
|
|
187
|
+
|
|
188
|
+
### Testing tools
|
|
189
|
+
|
|
190
|
+
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
|
191
|
+
- Chrome DevTools: Elements panel → Accessibility → Contrast
|
|
192
|
+
- Firefox DevTools: Accessibility panel
|
|
193
|
+
- Polypane: Built-in contrast checking across all breakpoints
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## No Pure Gray or Black
|
|
198
|
+
|
|
199
|
+
Pure grays (`oklch(N% 0 0)` or `#808080`) read as cold and disconnected. Tinted grays integrate into the palette.
|
|
200
|
+
|
|
201
|
+
### Rule: chroma 0.005–0.01 minimum
|
|
202
|
+
|
|
203
|
+
```css
|
|
204
|
+
/* WRONG: pure gray */
|
|
205
|
+
color: oklch(50% 0 0);
|
|
206
|
+
|
|
207
|
+
/* CORRECT: tinted neutral */
|
|
208
|
+
color: oklch(50% 0.01 250);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### No pure black
|
|
212
|
+
|
|
213
|
+
`#000000` or `oklch(0% 0 0)` is rarely appropriate. High contrast without tint creates harshness. Instead:
|
|
214
|
+
|
|
215
|
+
```css
|
|
216
|
+
/* WRONG */
|
|
217
|
+
--text-primary: #000000;
|
|
218
|
+
|
|
219
|
+
/* CORRECT: near-black with subtle tint */
|
|
220
|
+
--text-primary: oklch(12% 0.01 250);
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Dark Mode
|
|
226
|
+
|
|
227
|
+
Dark mode is NOT a color inversion. Inversion breaks contrast relationships and produces garish results. Instead, dark mode requires thoughtfully redesigned color relationships.
|
|
228
|
+
|
|
229
|
+
### Principles
|
|
230
|
+
|
|
231
|
+
**Lighter surfaces indicate depth.** In light mode, shadows indicate elevation. In dark mode, lighter backgrounds indicate elevation:
|
|
232
|
+
|
|
233
|
+
| Elevation | Light mode | Dark mode |
|
|
234
|
+
|-----------|-----------|-----------|
|
|
235
|
+
| Page background | Lightest | Darkest |
|
|
236
|
+
| Card | +shadow | Slightly lighter |
|
|
237
|
+
| Modal/overlay | +deeper shadow | Lighter still |
|
|
238
|
+
| Tooltip | Darkest shadow | Lightest surface |
|
|
239
|
+
|
|
240
|
+
**Slightly desaturated accents.** Saturated colors are harder to look at on dark backgrounds for extended periods. Reduce chroma by 0.02–0.04:
|
|
241
|
+
|
|
242
|
+
```css
|
|
243
|
+
@media (prefers-color-scheme: dark) {
|
|
244
|
+
--primary-500: oklch(62% 0.16 250); /* was 0.20 in light mode */
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Dark gray, never pure black.** Use 12–18% lightness for the base background:
|
|
249
|
+
|
|
250
|
+
```css
|
|
251
|
+
@media (prefers-color-scheme: dark) {
|
|
252
|
+
--surface-base: oklch(14% 0.01 250); /* main background */
|
|
253
|
+
--surface-raised: oklch(18% 0.01 250); /* cards */
|
|
254
|
+
--surface-overlay: oklch(22% 0.01 250); /* modals */
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Redefine semantic tokens, do not invert them.** Semantic tokens exist precisely for this purpose:
|
|
259
|
+
|
|
260
|
+
```css
|
|
261
|
+
:root {
|
|
262
|
+
--text-body: oklch(25% 0.01 250);
|
|
263
|
+
--text-muted: oklch(50% 0.01 250);
|
|
264
|
+
--bg-base: oklch(98% 0.01 250);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@media (prefers-color-scheme: dark) {
|
|
268
|
+
--text-body: oklch(90% 0.01 250);
|
|
269
|
+
--text-muted: oklch(65% 0.01 250);
|
|
270
|
+
--bg-base: oklch(14% 0.01 250);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Dark mode testing
|
|
275
|
+
|
|
276
|
+
- DevTools: Rendering panel → Emulate CSS media feature prefers-color-scheme
|
|
277
|
+
- Test WCAG contrast ratios in dark mode independently — they differ from light mode
|
|
278
|
+
- Test with vision emulation filters (protanopia, deuteranopia, achromatopsia) in DevTools
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: impeccable-design
|
|
2
|
+
description: AI design language reference — typography, color, motion, and UX writing for production-grade UI
|
|
3
|
+
source:
|
|
4
|
+
type: external
|
|
5
|
+
origin: github
|
|
6
|
+
url: https://github.com/pbakaus/impeccable
|
|
7
|
+
license: Apache-2.0
|
|
8
|
+
documents:
|
|
9
|
+
- typography.md
|
|
10
|
+
- color-and-contrast.md
|
|
11
|
+
- motion-design.md
|
|
12
|
+
- ux-writing.md
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# Motion Design
|
|
2
|
+
|
|
3
|
+
> Reference: Impeccable Design Language — https://github.com/pbakaus/impeccable (Apache 2.0)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The 100/300/500 Rule
|
|
8
|
+
|
|
9
|
+
Animation duration should match the conceptual weight of the change. Too fast and users miss feedback; too slow and the interface feels sluggish.
|
|
10
|
+
|
|
11
|
+
### Duration tiers
|
|
12
|
+
|
|
13
|
+
| Tier | Duration | Use cases |
|
|
14
|
+
|------|----------|-----------|
|
|
15
|
+
| Feedback | 100–150ms | Button press, checkbox toggle, hover state, ripple |
|
|
16
|
+
| State change | 200–300ms | Dropdown open, tooltip appear, tab switch, accordion |
|
|
17
|
+
| Structural | 300–500ms | Page transition, modal open, sidebar expand |
|
|
18
|
+
| Entry / onboarding | 500–800ms | Hero animations, first-run sequences, loading complete |
|
|
19
|
+
|
|
20
|
+
### Exit animations are shorter
|
|
21
|
+
|
|
22
|
+
Elements leaving the screen should animate out at roughly 75% of their entrance duration. The user's attention has already moved on:
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
.modal-enter { animation-duration: 300ms; }
|
|
26
|
+
.modal-exit { animation-duration: 225ms; } /* 75% of 300ms */
|
|
27
|
+
|
|
28
|
+
.dropdown-enter { animation-duration: 200ms; }
|
|
29
|
+
.dropdown-exit { animation-duration: 150ms; }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Easing Functions
|
|
35
|
+
|
|
36
|
+
Generic browser easings (`ease`, `ease-in`, `ease-out`, `ease-in-out`) are functional but imprecise. Custom cubic-bezier curves produce more polished results.
|
|
37
|
+
|
|
38
|
+
### The three essential curves
|
|
39
|
+
|
|
40
|
+
**Ease-out** — for elements appearing on screen (entering, expanding):
|
|
41
|
+
```css
|
|
42
|
+
/* Fast start, gentle landing */
|
|
43
|
+
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Ease-in** — for elements leaving screen (exiting, collapsing):
|
|
47
|
+
```css
|
|
48
|
+
/* Gradual acceleration, fast exit */
|
|
49
|
+
animation-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Ease-in-out** — for bidirectional transitions (sliding between states):
|
|
53
|
+
```css
|
|
54
|
+
/* Symmetrical: slow start, fast middle, slow end */
|
|
55
|
+
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Exponential curves (more expressive)
|
|
59
|
+
|
|
60
|
+
| Curve | CSS | Character |
|
|
61
|
+
|-------|-----|-----------|
|
|
62
|
+
| Quart-out | `cubic-bezier(0.25, 1, 0.5, 1)` | Smooth default, good for most UI |
|
|
63
|
+
| Quint-out | `cubic-bezier(0.22, 1, 0.36, 1)` | Dramatic, large structural changes |
|
|
64
|
+
| Expo-out | `cubic-bezier(0.16, 1, 0.3, 1)` | Snappy, high-energy feedback |
|
|
65
|
+
|
|
66
|
+
### CSS custom properties for easing tokens
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
:root {
|
|
70
|
+
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
71
|
+
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
|
72
|
+
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
|
73
|
+
--ease-spring: cubic-bezier(0.25, 1, 0.5, 1);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Anti-pattern: bounce and elastic
|
|
78
|
+
|
|
79
|
+
Bounce and elastic easings were popular in the early 2010s. They now read as amateurish and dated. They also perform poorly for accessibility (vestibular disorders). Do not use them:
|
|
80
|
+
|
|
81
|
+
```css
|
|
82
|
+
/* WRONG: dated, amateurish */
|
|
83
|
+
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* overshoot */
|
|
84
|
+
|
|
85
|
+
/* CORRECT: expo-out is energetic without bouncing */
|
|
86
|
+
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Performance: Only Animate transform and opacity
|
|
92
|
+
|
|
93
|
+
Layout-triggering properties (`width`, `height`, `top`, `left`, `margin`, `padding`) force the browser to recalculate layout on every frame — expensive and jank-prone. Only `transform` and `opacity` skip layout and paint, running entirely on the GPU compositor.
|
|
94
|
+
|
|
95
|
+
### Safe properties
|
|
96
|
+
|
|
97
|
+
```css
|
|
98
|
+
/* CORRECT: compositor-only, 60fps */
|
|
99
|
+
.panel { transform: translateX(-100%); opacity: 0; }
|
|
100
|
+
.panel.open { transform: translateX(0); opacity: 1; }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Avoid layout-triggering animations
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
/* WRONG: triggers layout recalculation every frame */
|
|
107
|
+
.panel { left: -300px; }
|
|
108
|
+
.panel.open { left: 0; }
|
|
109
|
+
|
|
110
|
+
/* WRONG: forces paint */
|
|
111
|
+
.card { background-color: #fff; }
|
|
112
|
+
.card:hover { background-color: #f5f5f5; } /* fine for hover, bad inside keyframes */
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Animating height with CSS grid
|
|
116
|
+
|
|
117
|
+
Animating `height: 0` to `height: auto` is a common requirement (accordion, expand/collapse) that cannot use `transform` directly. The cleanest CSS-only solution uses `grid-template-rows`:
|
|
118
|
+
|
|
119
|
+
```css
|
|
120
|
+
.accordion-content {
|
|
121
|
+
display: grid;
|
|
122
|
+
grid-template-rows: 0fr;
|
|
123
|
+
transition: grid-template-rows 250ms var(--ease-out);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.accordion-content.open {
|
|
127
|
+
grid-template-rows: 1fr;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.accordion-content > div {
|
|
131
|
+
overflow: hidden; /* required for 0fr to clip content */
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Staggered Animations
|
|
138
|
+
|
|
139
|
+
Staggering applies progressively increasing delays to a list of elements, creating a wave effect. It communicates that items belong together while adding visual interest.
|
|
140
|
+
|
|
141
|
+
### CSS custom properties approach
|
|
142
|
+
|
|
143
|
+
```css
|
|
144
|
+
.list-item {
|
|
145
|
+
animation: fade-up 300ms var(--ease-out) both;
|
|
146
|
+
animation-delay: calc(var(--i) * 50ms);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Set `--i` on each element:
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<li class="list-item" style="--i: 0">First</li>
|
|
154
|
+
<li class="list-item" style="--i: 1">Second</li>
|
|
155
|
+
<li class="list-item" style="--i: 2">Third</li>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or set it in JavaScript:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
document.querySelectorAll('.list-item').forEach((el, i) => {
|
|
162
|
+
el.style.setProperty('--i', i);
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Cap total stagger duration at 500ms
|
|
167
|
+
|
|
168
|
+
A list of 20 items staggered at 50ms each takes 1000ms to complete — too long. Cap the maximum total delay:
|
|
169
|
+
|
|
170
|
+
```css
|
|
171
|
+
animation-delay: calc(min(var(--i), 8) * 50ms); /* max 400ms total */
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Or limit at the last item: stagger interval × item count should not exceed 500ms.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Accessibility: prefers-reduced-motion
|
|
179
|
+
|
|
180
|
+
Approximately 35% of adults over 40 have vestibular disorders that can be triggered by parallax, sliding transitions, and spinning elements. The `prefers-reduced-motion` media query is not optional.
|
|
181
|
+
|
|
182
|
+
### User statistics
|
|
183
|
+
|
|
184
|
+
This is not a fringe case. `prefers-reduced-motion: reduce` affects a significant portion of users — including those who enable it for performance reasons on low-power devices.
|
|
185
|
+
|
|
186
|
+
### Replace spatial motion with crossfades
|
|
187
|
+
|
|
188
|
+
The principle: preserve the informational purpose of the animation while removing the vestibular trigger.
|
|
189
|
+
|
|
190
|
+
```css
|
|
191
|
+
@keyframes slide-in {
|
|
192
|
+
from { transform: translateY(20px); opacity: 0; }
|
|
193
|
+
to { transform: translateY(0); opacity: 1; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@keyframes fade-in {
|
|
197
|
+
from { opacity: 0; }
|
|
198
|
+
to { opacity: 1; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.panel {
|
|
202
|
+
animation: slide-in 300ms var(--ease-out);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@media (prefers-reduced-motion: reduce) {
|
|
206
|
+
.panel {
|
|
207
|
+
animation: fade-in 150ms linear; /* shorter, opacity-only */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Preserve functional animations
|
|
213
|
+
|
|
214
|
+
Some animations communicate state changes that users need (progress bars, loading spinners, form validation). These should be preserved even for reduced-motion users — reduce their speed and intensity, do not eliminate them:
|
|
215
|
+
|
|
216
|
+
```css
|
|
217
|
+
@media (prefers-reduced-motion: reduce) {
|
|
218
|
+
.spinner {
|
|
219
|
+
/* Slow down, keep the spin so user knows loading is happening */
|
|
220
|
+
animation-duration: 2s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.progress-bar {
|
|
224
|
+
/* Shorten transition but keep it */
|
|
225
|
+
transition-duration: 50ms;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Global reset approach
|
|
231
|
+
|
|
232
|
+
A pragmatic approach for existing codebases:
|
|
233
|
+
|
|
234
|
+
```css
|
|
235
|
+
@media (prefers-reduced-motion: reduce) {
|
|
236
|
+
*,
|
|
237
|
+
*::before,
|
|
238
|
+
*::after {
|
|
239
|
+
animation-duration: 0.01ms !important;
|
|
240
|
+
animation-iteration-count: 1 !important;
|
|
241
|
+
transition-duration: 0.01ms !important;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This is a blunt instrument. Prefer targeted overrides where functional animations need preservation.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Perceived Performance
|
|
251
|
+
|
|
252
|
+
Animation can make interfaces feel faster even when task completion time is identical. These techniques exploit how the brain perceives wait time.
|
|
253
|
+
|
|
254
|
+
### The 80ms threshold
|
|
255
|
+
|
|
256
|
+
Users perceive delays under 80ms as instantaneous. Responses between 80–100ms feel slightly delayed but acceptable. Above 200ms, users consciously notice the wait.
|
|
257
|
+
|
|
258
|
+
Design principle: use the delay budget wisely. If a backend call takes 300ms, an optimistic UI update at 0ms prevents the user from ever perceiving the delay.
|
|
259
|
+
|
|
260
|
+
### Active vs passive waiting
|
|
261
|
+
|
|
262
|
+
A spinner saying "Loading..." is passive waiting — the user is frozen. A progress bar, animated skeleton screen, or optimistic UI update is active — the user feels progress is happening.
|
|
263
|
+
|
|
264
|
+
- Skeleton screens reduce perceived wait time by 10–30% compared to blank space
|
|
265
|
+
- Content that appears progressively (top to bottom) feels faster than all-at-once reveals
|
|
266
|
+
|
|
267
|
+
### Preemptive transitions
|
|
268
|
+
|
|
269
|
+
Start a transition before the user's action completes. Hover states that begin animating at hover start (not click) make actions feel faster:
|
|
270
|
+
|
|
271
|
+
```css
|
|
272
|
+
.button {
|
|
273
|
+
transition: background-color 150ms var(--ease-out),
|
|
274
|
+
transform 100ms var(--ease-out);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.button:hover {
|
|
278
|
+
/* Animate on hover, not just on click */
|
|
279
|
+
background-color: var(--primary-600);
|
|
280
|
+
transform: translateY(-1px);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.button:active {
|
|
284
|
+
transform: translateY(0);
|
|
285
|
+
transition-duration: 50ms;
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Optimistic UI
|
|
290
|
+
|
|
291
|
+
Update the UI immediately on user action, then sync with the server. Show the final state first and roll back only on error:
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
// Optimistic update pattern
|
|
295
|
+
async function toggleLike(postId) {
|
|
296
|
+
// 1. Update UI immediately
|
|
297
|
+
setLiked(true);
|
|
298
|
+
setCount(prev => prev + 1);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// 2. Sync with server
|
|
302
|
+
await api.like(postId);
|
|
303
|
+
} catch {
|
|
304
|
+
// 3. Roll back on failure
|
|
305
|
+
setLiked(false);
|
|
306
|
+
setCount(prev => prev - 1);
|
|
307
|
+
showError('Could not save — please try again');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Performance Implementation
|
|
315
|
+
|
|
316
|
+
### will-change: use on trigger, not permanently
|
|
317
|
+
|
|
318
|
+
`will-change` promotes an element to its own GPU layer. Overuse wastes GPU memory and can slow rendering:
|
|
319
|
+
|
|
320
|
+
```css
|
|
321
|
+
/* WRONG: always promoted, wastes GPU memory */
|
|
322
|
+
.card { will-change: transform; }
|
|
323
|
+
|
|
324
|
+
/* CORRECT: promote only when animation is about to happen */
|
|
325
|
+
.card:hover { will-change: transform; }
|
|
326
|
+
|
|
327
|
+
/* Or in JS: add/remove on animation start/end */
|
|
328
|
+
el.addEventListener('mouseenter', () => el.style.willChange = 'transform');
|
|
329
|
+
el.addEventListener('animationend', () => el.style.willChange = 'auto');
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Intersection Observer for scroll animations
|
|
333
|
+
|
|
334
|
+
Never use scroll event listeners for animation triggers — they fire on every scroll event and block the main thread. Use `IntersectionObserver`:
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
const observer = new IntersectionObserver((entries) => {
|
|
338
|
+
entries.forEach(entry => {
|
|
339
|
+
if (entry.isIntersecting) {
|
|
340
|
+
entry.target.classList.add('animate-in');
|
|
341
|
+
observer.unobserve(entry.target); // stop observing once triggered
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}, { threshold: 0.1 });
|
|
345
|
+
|
|
346
|
+
document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el));
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Motion tokens
|
|
350
|
+
|
|
351
|
+
Centralize all durations and easings as tokens to enable systematic changes and theming:
|
|
352
|
+
|
|
353
|
+
```css
|
|
354
|
+
:root {
|
|
355
|
+
/* Duration */
|
|
356
|
+
--duration-fast: 100ms;
|
|
357
|
+
--duration-normal: 200ms;
|
|
358
|
+
--duration-slow: 350ms;
|
|
359
|
+
--duration-deliberate: 500ms;
|
|
360
|
+
|
|
361
|
+
/* Easing */
|
|
362
|
+
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
363
|
+
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
|
364
|
+
--ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
|
|
365
|
+
|
|
366
|
+
/* Reduce motion override */
|
|
367
|
+
--duration-motion-safe: 200ms;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@media (prefers-reduced-motion: reduce) {
|
|
371
|
+
:root {
|
|
372
|
+
--duration-motion-safe: 0.01ms;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Anti-Patterns
|
|
380
|
+
|
|
381
|
+
| Anti-pattern | Problem | Fix |
|
|
382
|
+
|--------------|---------|-----|
|
|
383
|
+
| Animating everything | Dilutes meaning; users stop noticing what's important | Reserve motion for state changes that need emphasis |
|
|
384
|
+
| Feedback animation > 500ms | Feels broken; user re-clicks, creates double-trigger bugs | Keep feedback under 200ms |
|
|
385
|
+
| No reduced-motion support | Triggers vestibular disorders for ~35% of adults over 40 | Always implement `prefers-reduced-motion` |
|
|
386
|
+
| Bounce and elastic easing | Dated, amateurish, vestibular risk | Use expo-out for energy |
|
|
387
|
+
| `left`/`top`/`height` in keyframes | Triggers layout recalc every frame, causes jank | Use `transform` + `opacity` only |
|
|
388
|
+
| Stagger > 500ms total | Users wait for the list to finish before acting | Cap at 500ms total stagger |
|
|
389
|
+
| `will-change` on static elements | Wastes GPU memory, can slow render | Apply only during/before animation |
|
|
390
|
+
| Scroll listener for animations | Blocks main thread, causes jank | Use `IntersectionObserver` |
|