@ericrisco/rsc 3.3.1 → 3.4.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 +2 -2
- package/manifest.json +43 -22
- package/package.json +1 -1
- package/scripts/lib/default-skill-floor.js +5 -1
- package/scripts/lib/domains.js +2 -2
- package/scripts/lib/retired-skills.js +4 -0
- package/skills/design/SKILL.md +4 -3
- package/skills/design/evals/cases.yaml +2 -2
- package/skills/react/SKILL.md +2 -1
- package/skills/ux/SKILL.md +149 -0
- package/skills/ux/THIRD_PARTY.md +32 -0
- package/skills/ux/evals/cases.yaml +40 -0
- package/skills/ux/references/flows.md +124 -0
- package/skills/ux/upstream/UPSTREAM.json +59 -0
- package/skills/ux/upstream/ux-designer/GUIDE.md +180 -0
- package/skills/ux/upstream/ux-designer/LICENSE +21 -0
- package/skills/ux/upstream/ux-designer/references/01-core-principles.md +251 -0
- package/skills/ux/upstream/ux-designer/references/02-laws-of-ux.md +330 -0
- package/skills/ux/upstream/ux-designer/references/03-accessibility.md +411 -0
- package/skills/ux/upstream/ux-designer/references/04-visual-design.md +433 -0
- package/skills/ux/upstream/ux-designer/references/05-information-architecture.md +432 -0
- package/skills/ux/upstream/ux-designer/references/06-interaction-design.md +495 -0
- package/skills/ux/upstream/ux-designer/references/07-forms-and-inputs.md +491 -0
- package/skills/ux/upstream/ux-designer/references/08-mobile-ux.md +539 -0
- package/skills/ux/upstream/ux-designer/references/09-ux-writing.md +493 -0
- package/skills/ux/upstream/ux-designer/references/10-user-research.md +533 -0
- package/skills/ux/upstream/ux-designer/references/11-design-systems.md +618 -0
- package/skills/ux/upstream/ux-designer/references/12a-presence-awareness.md +395 -0
- package/skills/ux/upstream/ux-designer/references/12b-conflict-resolution-sync.md +420 -0
- package/skills/ux/upstream/ux-designer/references/13a-canvas-navigation.md +388 -0
- package/skills/ux/upstream/ux-designer/references/13b-canvas-objects-performance.md +559 -0
- package/skills/ux/upstream/ux-designer/references/14-ai-ux-patterns.md +511 -0
- package/skills/ux/upstream/ux-designer/references/15-ethical-design.md +419 -0
- package/skills/ux/upstream/ux-designer/references/16-onboarding.md +430 -0
- package/skills/ux/upstream/ux-designer/references/17-notifications.md +461 -0
- package/skills/ux/upstream/ux-designer/references/18-data-visualization.md +453 -0
- package/skills/ux/upstream/ux-designer/references/19-search-ux.md +479 -0
- package/skills/ux/upstream/ux-designer/references/20-emotional-design.md +382 -0
- package/skills/ux/upstream/ux-designer/references/21-data-tables.md +629 -0
- package/skills/ux/upstream/ux-designer/references/22-performance-ux.md +626 -0
- package/skills/ux/upstream/ux-designer/references/23-internationalization.md +347 -0
- package/skills/ux/upstream/ux-designer/references/24-voice-and-multimodal.md +308 -0
- package/skills/ux/upstream/ux-designer/references/25a-barcode-camera-scanning.md +342 -0
- package/skills/ux/upstream/ux-designer/references/25b-barcode-scanner-workflows.md +412 -0
- package/skills/ux/upstream/ux-designer/references/25c-barcode-display.md +277 -0
- package/skills/ux/upstream/ux-usability-foundations/GUIDE.md +285 -0
- package/skills/ux/upstream/ux-usability-foundations/LICENSE +21 -0
- package/skills/ux/upstream/ux-usability-foundations/references/anti-patterns.md +267 -0
- package/skills/ux/upstream/ux-usability-foundations/references/checklists.md +130 -0
- package/skills/ux/upstream/ux-usability-foundations/references/decision-prompts.md +340 -0
- package/skills/ux/upstream/ux-usability-foundations/references/principle-cards.md +449 -0
- package/targets/clone-bootstrap.mjs +1 -1
- package/skills/ui-engineering/SKILL.md +0 -113
- package/skills/ui-engineering/evals/cases.yaml +0 -38
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
# Performance & Loading UX
|
|
2
|
+
|
|
3
|
+
Perceived performance is often more important than actual performance. Users judge an app's speed not by stopwatch measurements but by how fast it *feels*. This reference covers techniques for making interfaces feel instant, responsive, and reliable.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- Perceived Performance
|
|
10
|
+
- Loading Indicator Decision Tree
|
|
11
|
+
- Skeleton Screens
|
|
12
|
+
- Optimistic Updates
|
|
13
|
+
- Progress Indicators
|
|
14
|
+
- Image Loading Strategies
|
|
15
|
+
- Content Layout Shift Prevention
|
|
16
|
+
- Offline-First & Cached State UX
|
|
17
|
+
- Prefetching & Preloading
|
|
18
|
+
- Key Metrics
|
|
19
|
+
- Anti-Patterns
|
|
20
|
+
- Sources
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Perceived Performance
|
|
25
|
+
|
|
26
|
+
### Response Time Thresholds
|
|
27
|
+
|
|
28
|
+
| Duration | Perception | UX Treatment |
|
|
29
|
+
|----------|-----------|--------------|
|
|
30
|
+
| < 100ms | Instant | No indicator needed |
|
|
31
|
+
| 100-300ms | Slight delay | Subtle state change (button press) |
|
|
32
|
+
| 300ms-1s | Noticeable | Show spinner or progress hint |
|
|
33
|
+
| 1-5s | Waiting | Skeleton screen or progress bar |
|
|
34
|
+
| 5-10s | Frustrated | Progress bar with estimate + cancel |
|
|
35
|
+
| > 10s | Abandonment risk | Progress steps, background processing option |
|
|
36
|
+
|
|
37
|
+
### Techniques for Feeling Fast
|
|
38
|
+
|
|
39
|
+
1. **Optimistic updates** — Apply changes immediately, sync later
|
|
40
|
+
2. **Skeleton screens** — Show content shape before data arrives
|
|
41
|
+
3. **Progressive loading** — Show most important content first
|
|
42
|
+
4. **Prefetching** — Load likely-next content before user requests it
|
|
43
|
+
5. **Instant navigation** — Use client-side routing with prefetch
|
|
44
|
+
6. **Animation as distraction** — Meaningful transitions mask load time
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Loading Indicator Decision Tree
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
How long will the operation take?
|
|
52
|
+
├── < 100ms (instant)?
|
|
53
|
+
│ └── → No indicator (just update the UI)
|
|
54
|
+
├── 100ms - 1s (brief)?
|
|
55
|
+
│ ├── User triggered the action?
|
|
56
|
+
│ │ └── → Button loading state (spinner replaces label or inline spinner)
|
|
57
|
+
│ └── Background/automatic?
|
|
58
|
+
│ └── → No indicator (avoid flicker)
|
|
59
|
+
├── 1s - 5s (noticeable)?
|
|
60
|
+
│ ├── Loading content for a region/page?
|
|
61
|
+
│ │ └── → Skeleton screen (matches target layout)
|
|
62
|
+
│ ├── Processing user action?
|
|
63
|
+
│ │ └── → Inline spinner with status text ("Saving...")
|
|
64
|
+
│ └── Unknown duration?
|
|
65
|
+
│ └── → Indeterminate progress bar
|
|
66
|
+
├── 5s - 30s (long)?
|
|
67
|
+
│ ├── Duration is predictable?
|
|
68
|
+
│ │ └── → Determinate progress bar with percentage
|
|
69
|
+
│ └── Duration is unpredictable?
|
|
70
|
+
│ └── → Indeterminate progress with stage labels
|
|
71
|
+
└── > 30s (very long)?
|
|
72
|
+
└── → Background task with notification on completion
|
|
73
|
+
("We'll email you when your export is ready")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Skeleton Screens
|
|
79
|
+
|
|
80
|
+
Skeleton screens show a low-fidelity preview of the content layout before data loads. They reduce perceived wait time and prevent layout shifts.
|
|
81
|
+
|
|
82
|
+
### Basic Skeleton
|
|
83
|
+
|
|
84
|
+
```css
|
|
85
|
+
.skeleton {
|
|
86
|
+
background: #e5e7eb;
|
|
87
|
+
border-radius: 4px;
|
|
88
|
+
position: relative;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Shimmer animation */
|
|
93
|
+
.skeleton::after {
|
|
94
|
+
content: '';
|
|
95
|
+
position: absolute;
|
|
96
|
+
inset: 0;
|
|
97
|
+
background: linear-gradient(
|
|
98
|
+
90deg,
|
|
99
|
+
transparent 0%,
|
|
100
|
+
rgba(255, 255, 255, 0.4) 50%,
|
|
101
|
+
transparent 100%
|
|
102
|
+
);
|
|
103
|
+
animation: skeleton-shimmer 1.5s infinite;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@keyframes skeleton-shimmer {
|
|
107
|
+
0% { transform: translateX(-100%); }
|
|
108
|
+
100% { transform: translateX(100%); }
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Skeleton Variants
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<!-- Text skeleton -->
|
|
116
|
+
<div class="skeleton skeleton--text" style="width: 80%; height: 16px;"></div>
|
|
117
|
+
<div class="skeleton skeleton--text" style="width: 60%; height: 16px;"></div>
|
|
118
|
+
|
|
119
|
+
<!-- Avatar skeleton -->
|
|
120
|
+
<div class="skeleton skeleton--circle" style="width: 40px; height: 40px;"></div>
|
|
121
|
+
|
|
122
|
+
<!-- Image skeleton -->
|
|
123
|
+
<div class="skeleton skeleton--image" style="width: 100%; aspect-ratio: 16/9;"></div>
|
|
124
|
+
|
|
125
|
+
<!-- Card skeleton -->
|
|
126
|
+
<div class="skeleton-card">
|
|
127
|
+
<div class="skeleton skeleton--image" style="height: 200px;"></div>
|
|
128
|
+
<div style="padding: 16px;">
|
|
129
|
+
<div class="skeleton skeleton--text" style="width: 70%; height: 20px;"></div>
|
|
130
|
+
<div class="skeleton skeleton--text" style="width: 100%; height: 14px; margin-top: 8px;"></div>
|
|
131
|
+
<div class="skeleton skeleton--text" style="width: 40%; height: 14px; margin-top: 8px;"></div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Skeleton Best Practices
|
|
137
|
+
|
|
138
|
+
| Do | Don't |
|
|
139
|
+
|----|-------|
|
|
140
|
+
| Match the actual content layout | Use generic shapes unrelated to content |
|
|
141
|
+
| Use subtle animation (shimmer) | Use pulsing that draws too much attention |
|
|
142
|
+
| Show for > 300ms loads | Show for instant loads (causes flicker) |
|
|
143
|
+
| Transition smoothly to real content | Pop content in abruptly |
|
|
144
|
+
| Keep skeleton visible while data loads | Replace skeleton with spinner |
|
|
145
|
+
|
|
146
|
+
### Skeleton Transition
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
function showContent(container, data) {
|
|
150
|
+
const skeleton = container.querySelector('.skeleton-wrapper');
|
|
151
|
+
const content = container.querySelector('.content-wrapper');
|
|
152
|
+
|
|
153
|
+
// Populate content using safe DOM methods (e.g., textContent,
|
|
154
|
+
// createElement, or a framework's reactive rendering)
|
|
155
|
+
renderDataToDOM(content, data);
|
|
156
|
+
|
|
157
|
+
// Cross-fade transition
|
|
158
|
+
skeleton.style.opacity = '0';
|
|
159
|
+
content.style.opacity = '1';
|
|
160
|
+
|
|
161
|
+
setTimeout(() => skeleton.remove(), 300);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```css
|
|
166
|
+
.skeleton-wrapper,
|
|
167
|
+
.content-wrapper {
|
|
168
|
+
transition: opacity 300ms ease;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.content-wrapper {
|
|
172
|
+
opacity: 0;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Optimistic Updates
|
|
179
|
+
|
|
180
|
+
Apply changes to the UI immediately before server confirmation, then reconcile.
|
|
181
|
+
|
|
182
|
+
### Pattern
|
|
183
|
+
|
|
184
|
+
```javascript
|
|
185
|
+
async function toggleFavorite(itemId) {
|
|
186
|
+
// 1. Optimistic: update UI immediately
|
|
187
|
+
const previousState = getItem(itemId).isFavorite;
|
|
188
|
+
updateItemUI(itemId, { isFavorite: !previousState });
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// 2. Send to server
|
|
192
|
+
await api.toggleFavorite(itemId);
|
|
193
|
+
// Success: UI already correct, nothing to do
|
|
194
|
+
} catch (error) {
|
|
195
|
+
// 3. Rollback on failure
|
|
196
|
+
updateItemUI(itemId, { isFavorite: previousState });
|
|
197
|
+
showToast('Failed to update. Please try again.', { type: 'error' });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### When to Use Optimistic Updates
|
|
203
|
+
|
|
204
|
+
| Use Optimistic | Don't Use |
|
|
205
|
+
|---------------|-----------|
|
|
206
|
+
| Toggles (like, favorite, bookmark) | Financial transactions |
|
|
207
|
+
| Status changes (read/unread) | Destructive actions (delete) |
|
|
208
|
+
| Form field auto-save | Multi-step workflows |
|
|
209
|
+
| Reordering lists | Actions requiring server validation |
|
|
210
|
+
| Adding/removing tags | External system integrations |
|
|
211
|
+
|
|
212
|
+
### Optimistic Update UX Requirements
|
|
213
|
+
|
|
214
|
+
- Always have a rollback path
|
|
215
|
+
- Show subtle "saving" indicator (not blocking)
|
|
216
|
+
- On failure: revert UI + show clear error message
|
|
217
|
+
- On conflict: show resolution UI (merge dialog)
|
|
218
|
+
- Debounce rapid changes (typing, slider adjustments)
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Progress Indicators
|
|
223
|
+
|
|
224
|
+
### Indeterminate Progress
|
|
225
|
+
|
|
226
|
+
For operations where duration is unknown.
|
|
227
|
+
|
|
228
|
+
```css
|
|
229
|
+
/* Indeterminate progress bar */
|
|
230
|
+
.progress-bar--indeterminate {
|
|
231
|
+
height: 4px;
|
|
232
|
+
background: #e5e7eb;
|
|
233
|
+
border-radius: 2px;
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.progress-bar--indeterminate::after {
|
|
238
|
+
content: '';
|
|
239
|
+
display: block;
|
|
240
|
+
height: 100%;
|
|
241
|
+
width: 40%;
|
|
242
|
+
background: #3b82f6;
|
|
243
|
+
border-radius: 2px;
|
|
244
|
+
animation: indeterminate 1.5s infinite ease-in-out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@keyframes indeterminate {
|
|
248
|
+
0% { transform: translateX(-100%); }
|
|
249
|
+
100% { transform: translateX(350%); }
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Determinate Progress
|
|
254
|
+
|
|
255
|
+
For operations where progress can be measured.
|
|
256
|
+
|
|
257
|
+
```html
|
|
258
|
+
<div class="progress-container">
|
|
259
|
+
<div class="progress-bar" role="progressbar"
|
|
260
|
+
aria-valuenow="65" aria-valuemin="0" aria-valuemax="100">
|
|
261
|
+
<div class="progress-bar__fill" style="width: 65%"></div>
|
|
262
|
+
</div>
|
|
263
|
+
<span class="progress-label">65% — Uploading 3 of 5 files</span>
|
|
264
|
+
</div>
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Multi-Step Progress
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
271
|
+
│ Importing data... │
|
|
272
|
+
│ │
|
|
273
|
+
│ ✓ Validating file format │
|
|
274
|
+
│ ✓ Parsing 1,234 records │
|
|
275
|
+
│ ⟳ Importing to database (847 of 1,234) │
|
|
276
|
+
│ ○ Running post-import checks │
|
|
277
|
+
│ │
|
|
278
|
+
│ ████████████████████░░░░░░░░░░ 68% [Cancel import] │
|
|
279
|
+
└──────────────────────────────────────────────────────────────┘
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Button Loading States
|
|
283
|
+
|
|
284
|
+
```css
|
|
285
|
+
.btn--loading {
|
|
286
|
+
position: relative;
|
|
287
|
+
color: transparent; /* Hide text */
|
|
288
|
+
pointer-events: none;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.btn--loading::after {
|
|
292
|
+
content: '';
|
|
293
|
+
position: absolute;
|
|
294
|
+
width: 16px;
|
|
295
|
+
height: 16px;
|
|
296
|
+
top: 50%;
|
|
297
|
+
left: 50%;
|
|
298
|
+
margin: -8px 0 0 -8px;
|
|
299
|
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
300
|
+
border-top-color: white;
|
|
301
|
+
border-radius: 50%;
|
|
302
|
+
animation: button-spin 0.6s linear infinite;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@keyframes button-spin {
|
|
306
|
+
to { transform: rotate(360deg); }
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Button loading best practices:**
|
|
311
|
+
- Disable the button to prevent double-submit
|
|
312
|
+
- Keep button dimensions constant (no layout shift)
|
|
313
|
+
- Show spinner inside the button, not replacing it
|
|
314
|
+
- Optionally change label: "Save" → "Saving..."
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Image Loading Strategies
|
|
319
|
+
|
|
320
|
+
### Lazy Loading
|
|
321
|
+
|
|
322
|
+
Only load images when they enter (or approach) the viewport.
|
|
323
|
+
|
|
324
|
+
```html
|
|
325
|
+
<!-- Native lazy loading -->
|
|
326
|
+
<img src="photo.jpg" loading="lazy" alt="Description"
|
|
327
|
+
width="800" height="600">
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Blur-Up / LQIP (Low Quality Image Placeholder)
|
|
331
|
+
|
|
332
|
+
Show a tiny, blurred preview that transitions to the full image.
|
|
333
|
+
|
|
334
|
+
```html
|
|
335
|
+
<div class="image-wrapper">
|
|
336
|
+
<img class="image-placeholder" src="photo-20px.jpg"
|
|
337
|
+
style="filter: blur(20px); transform: scale(1.1);"
|
|
338
|
+
aria-hidden="true">
|
|
339
|
+
<img class="image-full" src="photo-800px.jpg" loading="lazy"
|
|
340
|
+
alt="Description"
|
|
341
|
+
onload="this.classList.add('loaded')">
|
|
342
|
+
</div>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```css
|
|
346
|
+
.image-wrapper {
|
|
347
|
+
position: relative;
|
|
348
|
+
overflow: hidden;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.image-placeholder {
|
|
352
|
+
position: absolute;
|
|
353
|
+
inset: 0;
|
|
354
|
+
width: 100%;
|
|
355
|
+
height: 100%;
|
|
356
|
+
object-fit: cover;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.image-full {
|
|
360
|
+
opacity: 0;
|
|
361
|
+
transition: opacity 300ms ease;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.image-full.loaded {
|
|
365
|
+
opacity: 1;
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Responsive Images
|
|
370
|
+
|
|
371
|
+
```html
|
|
372
|
+
<img
|
|
373
|
+
srcset="photo-400w.jpg 400w,
|
|
374
|
+
photo-800w.jpg 800w,
|
|
375
|
+
photo-1200w.jpg 1200w"
|
|
376
|
+
sizes="(max-width: 600px) 400px,
|
|
377
|
+
(max-width: 1000px) 800px,
|
|
378
|
+
1200px"
|
|
379
|
+
src="photo-800w.jpg"
|
|
380
|
+
alt="Description"
|
|
381
|
+
loading="lazy"
|
|
382
|
+
>
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Image Loading Decision Tree
|
|
386
|
+
|
|
387
|
+
```
|
|
388
|
+
What type of image is it?
|
|
389
|
+
├── Above the fold (hero, logo, critical UI)?
|
|
390
|
+
│ └── → Eager load, preload via <link>, use srcset
|
|
391
|
+
├── Below the fold (content images)?
|
|
392
|
+
│ └── → loading="lazy", use LQIP/blur-up for large images
|
|
393
|
+
├── Thumbnail in a list/grid?
|
|
394
|
+
│ └── → loading="lazy", fixed dimensions, skeleton placeholder
|
|
395
|
+
├── Background/decorative?
|
|
396
|
+
│ └── → CSS background with lazy class, low priority
|
|
397
|
+
└── User avatar?
|
|
398
|
+
└── → Eager load if visible, fixed dimensions, fallback initial
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Content Layout Shift Prevention
|
|
404
|
+
|
|
405
|
+
Layout shifts are disorienting and frustrating. They happen when content dimensions change after initial render.
|
|
406
|
+
|
|
407
|
+
### Core Web Vitals: CLS
|
|
408
|
+
|
|
409
|
+
- **Good:** CLS < 0.1
|
|
410
|
+
- **Needs improvement:** 0.1 - 0.25
|
|
411
|
+
- **Poor:** CLS > 0.25
|
|
412
|
+
|
|
413
|
+
### Prevention Techniques
|
|
414
|
+
|
|
415
|
+
#### Reserve Space for Images
|
|
416
|
+
|
|
417
|
+
```css
|
|
418
|
+
/* Always specify dimensions */
|
|
419
|
+
img {
|
|
420
|
+
width: 100%;
|
|
421
|
+
height: auto;
|
|
422
|
+
aspect-ratio: 16 / 9; /* Reserve space before load */
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* Container with aspect ratio */
|
|
426
|
+
.image-container {
|
|
427
|
+
position: relative;
|
|
428
|
+
width: 100%;
|
|
429
|
+
aspect-ratio: 4 / 3;
|
|
430
|
+
background: #f3f4f6;
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
#### Reserve Space for Fonts
|
|
435
|
+
|
|
436
|
+
```css
|
|
437
|
+
/* Use font-display to control FOIT/FOUT */
|
|
438
|
+
@font-face {
|
|
439
|
+
font-family: 'CustomFont';
|
|
440
|
+
src: url('font.woff2') format('woff2');
|
|
441
|
+
font-display: swap; /* Show fallback immediately, swap when loaded */
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* Match fallback font metrics to reduce shift */
|
|
445
|
+
body {
|
|
446
|
+
font-family: 'CustomFont', system-ui, sans-serif;
|
|
447
|
+
font-size-adjust: 0.5; /* Normalize x-height */
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
#### Reserve Space for Dynamic Content
|
|
452
|
+
|
|
453
|
+
```css
|
|
454
|
+
/* Fixed-height containers for dynamic content */
|
|
455
|
+
.ad-slot {
|
|
456
|
+
min-height: 250px;
|
|
457
|
+
background: #f3f4f6;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.notification-area {
|
|
461
|
+
min-height: 0;
|
|
462
|
+
transition: min-height 300ms ease; /* Smooth expansion */
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
#### Avoid CLS-Causing Patterns
|
|
467
|
+
|
|
468
|
+
| Pattern | Problem | Solution |
|
|
469
|
+
|---------|---------|----------|
|
|
470
|
+
| Images without dimensions | Jump when loaded | Always set width/height or aspect-ratio |
|
|
471
|
+
| Injected content above viewport | Pushes content down | Insert below or use overlay |
|
|
472
|
+
| Web fonts without fallback | Text reflows | Use font-display: swap + size-adjust |
|
|
473
|
+
| Dynamic ads/banners | Layout shift on load | Reserve fixed space |
|
|
474
|
+
| Late-loading components | Layout reflowing | Use skeleton placeholders |
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## Offline-First & Cached State UX
|
|
479
|
+
|
|
480
|
+
### Connection Status
|
|
481
|
+
|
|
482
|
+
```css
|
|
483
|
+
/* Offline indicator */
|
|
484
|
+
.offline-banner {
|
|
485
|
+
position: fixed;
|
|
486
|
+
bottom: 0;
|
|
487
|
+
left: 0;
|
|
488
|
+
right: 0;
|
|
489
|
+
padding: 8px 16px;
|
|
490
|
+
background: #fef3c7;
|
|
491
|
+
color: #92400e;
|
|
492
|
+
text-align: center;
|
|
493
|
+
font-size: 14px;
|
|
494
|
+
z-index: 9999;
|
|
495
|
+
transform: translateY(100%);
|
|
496
|
+
transition: transform 300ms ease;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.offline-banner--visible {
|
|
500
|
+
transform: translateY(0);
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Stale Content Indicators
|
|
505
|
+
|
|
506
|
+
```html
|
|
507
|
+
<div class="stale-notice">
|
|
508
|
+
<span>Last updated 5 minutes ago</span>
|
|
509
|
+
<button class="refresh-btn">Refresh</button>
|
|
510
|
+
</div>
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Stale data guidelines:**
|
|
514
|
+
- Show "last updated" timestamp for cached data
|
|
515
|
+
- Provide manual refresh option
|
|
516
|
+
- Auto-refresh when connection restores
|
|
517
|
+
- Differentiate between "cached" and "live" data visually
|
|
518
|
+
- Never silently show outdated data as if it were current
|
|
519
|
+
|
|
520
|
+
### Offline-Capable Actions
|
|
521
|
+
|
|
522
|
+
| Action Type | Offline Behavior |
|
|
523
|
+
|-------------|-----------------|
|
|
524
|
+
| Read cached data | Show with "offline" indicator |
|
|
525
|
+
| Create new items | Queue locally, sync when online |
|
|
526
|
+
| Edit existing items | Queue changes, show "pending sync" |
|
|
527
|
+
| Delete items | Mark as "pending delete", hide from view |
|
|
528
|
+
| Search | Search local cache only, note limitation |
|
|
529
|
+
|
|
530
|
+
### Sync Status Indicators
|
|
531
|
+
|
|
532
|
+
```
|
|
533
|
+
┌──────────────────────────────────────────┐
|
|
534
|
+
│ ✓ All changes saved │ — Everything synced
|
|
535
|
+
│ ⟳ Saving... │ — Sync in progress
|
|
536
|
+
│ ⚠ Offline — changes will sync later │ — Queued changes
|
|
537
|
+
│ ✕ Save failed — click to retry │ — Error state
|
|
538
|
+
└──────────────────────────────────────────┘
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## Prefetching & Preloading
|
|
544
|
+
|
|
545
|
+
### Link Prefetching
|
|
546
|
+
|
|
547
|
+
```html
|
|
548
|
+
<!-- Prefetch likely next pages -->
|
|
549
|
+
<link rel="prefetch" href="/dashboard">
|
|
550
|
+
<link rel="prefetch" href="/api/user-data.json">
|
|
551
|
+
|
|
552
|
+
<!-- Preload critical resources -->
|
|
553
|
+
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
|
|
554
|
+
<link rel="preload" href="/hero.jpg" as="image">
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Hover Prefetch
|
|
558
|
+
|
|
559
|
+
```javascript
|
|
560
|
+
// Prefetch on hover (before click)
|
|
561
|
+
document.querySelectorAll('a[data-prefetch]').forEach(link => {
|
|
562
|
+
let prefetched = false;
|
|
563
|
+
|
|
564
|
+
link.addEventListener('mouseenter', () => {
|
|
565
|
+
if (prefetched) return;
|
|
566
|
+
prefetched = true;
|
|
567
|
+
|
|
568
|
+
const prefetchLink = document.createElement('link');
|
|
569
|
+
prefetchLink.rel = 'prefetch';
|
|
570
|
+
prefetchLink.href = link.href;
|
|
571
|
+
document.head.appendChild(prefetchLink);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Prefetch Strategy Decision
|
|
577
|
+
|
|
578
|
+
| Signal | Action |
|
|
579
|
+
|--------|--------|
|
|
580
|
+
| User hovers over link | Prefetch page |
|
|
581
|
+
| User types in search | Prefetch top results |
|
|
582
|
+
| User reaches 80% of list | Prefetch next page |
|
|
583
|
+
| User is on step 2 of 4 | Prefetch step 3 resources |
|
|
584
|
+
| Viewport approaches lazy content | Prefetch images |
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Key Metrics
|
|
589
|
+
|
|
590
|
+
| Metric | Value | Context |
|
|
591
|
+
|--------|-------|---------|
|
|
592
|
+
| Instant response | < 100ms | No loading indicator needed |
|
|
593
|
+
| Skeleton threshold | > 300ms | Show skeleton after this delay |
|
|
594
|
+
| Toast error display | >= 5s | Longer than success toasts |
|
|
595
|
+
| CLS target | < 0.1 | Core Web Vitals "good" |
|
|
596
|
+
| LCP target | < 2.5s | Largest Contentful Paint |
|
|
597
|
+
| FID target | < 100ms | First Input Delay |
|
|
598
|
+
| INP target | < 200ms | Interaction to Next Paint |
|
|
599
|
+
| Optimistic rollback | < 5s | Show error if no confirmation |
|
|
600
|
+
| Prefetch hover delay | 65ms | Wait before prefetching |
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
## Anti-Patterns
|
|
605
|
+
|
|
606
|
+
1. **Spinner for everything** - Use skeletons for content, spinners for actions
|
|
607
|
+
2. **Full-page loader** - Block only the region that's loading, not the whole page
|
|
608
|
+
3. **No minimum display time** - Skeleton that flashes for 50ms is worse than nothing
|
|
609
|
+
4. **Layout shifts on load** - Always reserve space for async content
|
|
610
|
+
5. **Silent failures** - User doesn't know their action failed
|
|
611
|
+
6. **Optimistic without rollback** - Showing success then silently losing data
|
|
612
|
+
7. **Blocking UI during save** - Use background save with status indicator
|
|
613
|
+
8. **No offline indication** - Users assume they're connected
|
|
614
|
+
9. **Stale data shown as current** - Always indicate data freshness
|
|
615
|
+
10. **Retry without feedback** - Show what's happening during retry attempts
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Sources
|
|
620
|
+
|
|
621
|
+
- [web.dev - Core Web Vitals](https://web.dev/vitals/) - Google's performance metrics
|
|
622
|
+
- [web.dev - Optimize CLS](https://web.dev/optimize-cls/) - Layout shift prevention
|
|
623
|
+
- [Nielsen Norman Group - Response Times](https://www.nngroup.com/articles/response-times-3-important-limits/) - Perception thresholds
|
|
624
|
+
- [Smashing Magazine - Skeleton Screens](https://www.smashingmagazine.com/2020/04/skeleton-screens-react/) - Implementation patterns
|
|
625
|
+
- [Luke Wroblewski - Mobile Input](https://www.lukew.com/ff/entry.asp?1950) - Perceived performance research
|
|
626
|
+
- [Instant.page](https://instant.page/) - Prefetch on hover library
|