Loading / Waiting States — general playbook
A loading state's job is to reassure the user that the app is working, preserve
context, and reduce perceived wait. It is not a place to show off. Match the
host project — a fintech dashboard, a kids' game, and a docs site should each get
a loader that looks like it belongs to them.
Rule 0 — adapt to the project, don't impose a design
Always derive colours, typography, radius, spacing, and any brand mark from the
project's existing design system (CSS variables, Tailwind @theme tokens,
brand assets). Do NOT bring a fixed style (specific hues, mascots, radar/star
motifs, etc.). Read the project's tokens first; if it has a logo/brand mark, a
full-screen splash may use it — otherwise stay minimal and neutral.
Pick the pattern for the kind of wait
- Skeleton screens — content that will fill a known layout (lists, cards,
tables, profiles). Best perceived performance; mirror the real layout's shapes.
- Inline spinner — short, indeterminate waits scoped to a control/section
(button submitting, a panel refreshing). Keep it small and local.
- Progress bar / % — only when progress is actually measurable (uploads,
multi-step jobs). Never fake or loop a determinate bar.
- Branded full-screen splash — app boot / first paint / heavy route change.
Brief; use the brand mark; don't block longer than needed.
- Overlay on existing content (dim ± blur scrim) — page is already loaded but
an action is processing; keep the context visible behind the scrim so the user
keeps their place.
Prefer skeletons/optimistic UI over spinners where a layout is known — they feel
faster. One good indicator beats several stacked "waiting" cues.
Motion principles
- One primary motion; subtle, smooth, looping (~0.8–2.5s), ease-in-out.
- Don't pile on cues (spinner + bar + pulsing text + dots at once reads as
anxious). Remove redundancy.
- Animate transform/opacity (GPU-friendly), not layout properties.
- Always honour
prefers-reduced-motion: reduce — swap to a static or
slow opacity fade; never leave a spinning thing with no fallback.
Accessibility
- Wrap the loader in
role="status" with aria-live="polite", or set
aria-busy="true" on the region being loaded.
- Include a visually-hidden text label (e.g.
Loading… / 載入中) — an
animation alone is not announced.
- Don't trap keyboard focus behind a splash; ensure the indicator has enough
contrast against its background.
Timing — don't flash, don't overstay
- Remove the loader the instant content is ready.
- For very fast operations, delay showing the spinner ~150–200ms so it never
flashes for sub-perceptible loads.
- If you must avoid flicker on a splash, a ~300–500ms minimum is fine; avoid
longer artificial delays.
- Localize copy to the app's language; keep it short and honest.
Implementation — React
const [loading, setLoading] = useState(true)
useEffect(() => { /* resolve real work */ setLoading(false) }, [])
// ...
{loading && <Loading />} // or <Skeleton/> inline where content will land
- Put reusable keyframes in the stylesheet (not inline) so variants share them.
- Full-screen splash:
fixed inset-0 z-[100] grid place-items-center; overlay
variant adds a translucent scrim + optional backdrop-blur.
- Screenshot/verify in a headless preview: boot loaders are brief, so temporarily
lengthen the timer (e.g. to 20s), capture, then restore the real duration.
Implementation — standalone HTML
Self-contained single .html (full <!DOCTYPE>, inline <style>, no external
assets) is great for previewing/sharing a loader concept. Serve it via the dev
server (http://localhost:<port>/<name>.html) rather than relying on file://
double-click, which may not open if .html isn't associated with a browser
(dragging the file into a browser also works).
Neutral, themeable snippets (adopt project tokens — these default to currentColor / CSS vars)
/* Spinner */
@keyframes ld-spin { to { transform: rotate(360deg); } }
.ld-spinner { width: 24px; height: 24px; border-radius: 50%;
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
border-top-color: currentColor; animation: ld-spin .8s linear infinite; }
/* Skeleton shimmer — set --sk to a project surface colour */
@keyframes ld-shimmer { to { background-position: -200% 0; } }
.ld-skeleton { border-radius: 8px; background:
linear-gradient(90deg, var(--sk,#e5e7eb) 25%,
color-mix(in srgb, var(--sk,#e5e7eb) 55%, #fff) 37%, var(--sk,#e5e7eb) 63%);
background-size: 200% 100%; animation: ld-shimmer 1.4s ease infinite; }
/* Indeterminate bar */
@keyframes ld-bar { 0%{transform:translateX(-130%)} 100%{transform:translateX(360%)} }
.ld-bar { position:relative; height:4px; border-radius:999px;
background: color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }
.ld-bar>i { position:absolute; inset:0 auto 0 0; width:40%; border-radius:999px;
background: currentColor; animation: ld-bar 1.4s ease-in-out infinite; }
/* Dot pulse */
@keyframes ld-dot { 0%,60%,100%{opacity:.25} 30%{opacity:1} }
.ld-dots i { display:inline-block; width:5px; height:5px; border-radius:50%;
background: currentColor; margin:0 2px; animation: ld-dot 1.4s infinite; }
.ld-dots i:nth-child(2){animation-delay:.2s} .ld-dots i:nth-child(3){animation-delay:.4s}
@media (prefers-reduced-motion: reduce) {
.ld-spinner,.ld-skeleton,.ld-bar>i,.ld-dots i { animation: none; }
}
Colour these via the project's tokens (set color/--sk on a parent, or map to
--text-secondary / --surface-2 / the brand accent). Keep them minimal unless
the project's design language calls for something richer.
Process — mockup first, then implement (default)
Default to showing a preview BEFORE touching app code, so the user approves the
direction first:
- Read the project's design tokens / brand; note the app's aesthetic + language.
- Decide the wait type → pick the pattern above.
- Show an HTML 示意 first, for approval. Render a quick preview — an inline
widget mockup, or a self-contained standalone
.html — built with the
project's tokens. Do NOT wire it into the app yet. Wait for the user's OK;
they'll often request tweaks, so iterate on the mockup until they're happy.
- Once approved, build the real component using the project's tokens, one
primary motion, a reduced-motion fallback, and an aria label.
- Wire the loading state; ensure it clears when work is done (delay-show if the
op is fast).
- Verify visually; keep / offer the standalone HTML for sharing.
Skip the mockup step (go straight to step 4) only if the user explicitly says
"just build it" / "直接做" / gives a design that's already locked.
1---2name: loading-screen3description: General, reusable methodology for designing and building loading / 載入 / 進站 / splash / waiting states in ANY project (React, Vue, Svelte, plain HTML; any CSS framework). Use this WHENEVER the user mentions a loading screen, splash, spinner, skeleton, progress indicator, 載入中 / 等待中 / 進站畫面, or "make a loading" — even if they just say "loading". It picks the right loading pattern for the wait, adapts to the host project's own design tokens and brand (never imposing a fixed palette or motif), and covers motion, accessibility, timing, and clean React / standalone-HTML implementations.4---56# Loading / Waiting States — general playbook78A loading state's job is to reassure the user that the app is working, preserve9context, and reduce *perceived* wait. It is not a place to show off. Match the10host project — a fintech dashboard, a kids' game, and a docs site should each get11a loader that looks like it belongs to them.1213## Rule 0 — adapt to the project, don't impose a design1415Always derive colours, typography, radius, spacing, and any brand mark from the16**project's existing design system** (CSS variables, Tailwind `@theme` tokens,17brand assets). Do NOT bring a fixed style (specific hues, mascots, radar/star18motifs, etc.). Read the project's tokens first; if it has a logo/brand mark, a19full-screen splash may use it — otherwise stay minimal and neutral.2021## Pick the pattern for the *kind* of wait2223- **Skeleton screens** — content that will fill a known layout (lists, cards,24 tables, profiles). Best perceived performance; mirror the real layout's shapes.25- **Inline spinner** — short, indeterminate waits scoped to a control/section26 (button submitting, a panel refreshing). Keep it small and local.27- **Progress bar / %** — only when progress is actually measurable (uploads,28 multi-step jobs). Never fake or loop a determinate bar.29- **Branded full-screen splash** — app boot / first paint / heavy route change.30 Brief; use the brand mark; don't block longer than needed.31- **Overlay on existing content (dim ± blur scrim)** — page is already loaded but32 an action is processing; keep the context visible behind the scrim so the user33 keeps their place.3435Prefer skeletons/optimistic UI over spinners where a layout is known — they feel36faster. One good indicator beats several stacked "waiting" cues.3738## Motion principles3940- One primary motion; subtle, smooth, looping (~0.8–2.5s), ease-in-out.41- Don't pile on cues (spinner + bar + pulsing text + dots at once reads as42 anxious). Remove redundancy.43- Animate transform/opacity (GPU-friendly), not layout properties.44- **Always** honour `prefers-reduced-motion: reduce` — swap to a static or45 slow opacity fade; never leave a spinning thing with no fallback.4647## Accessibility4849- Wrap the loader in `role="status"` with `aria-live="polite"`, or set50 `aria-busy="true"` on the region being loaded.51- Include a visually-hidden text label (e.g. `Loading…` / `載入中`) — an52 animation alone is not announced.53- Don't trap keyboard focus behind a splash; ensure the indicator has enough54 contrast against its background.5556## Timing — don't flash, don't overstay5758- Remove the loader the instant content is ready.59- For very fast operations, **delay showing** the spinner ~150–200ms so it never60 flashes for sub-perceptible loads.61- If you must avoid flicker on a splash, a ~300–500ms minimum is fine; avoid62 longer artificial delays.63- Localize copy to the app's language; keep it short and honest.6465## Implementation — React6667```jsx68const [loading, setLoading] = useState(true)69useEffect(() => { /* resolve real work */ setLoading(false) }, [])70// ...71{loading && <Loading />} // or <Skeleton/> inline where content will land72```7374- Put reusable keyframes in the stylesheet (not inline) so variants share them.75- Full-screen splash: `fixed inset-0 z-[100] grid place-items-center`; overlay76 variant adds a translucent scrim + optional `backdrop-blur`.77- Screenshot/verify in a headless preview: boot loaders are brief, so temporarily78 lengthen the timer (e.g. to 20s), capture, then restore the real duration.7980## Implementation — standalone HTML8182Self-contained single `.html` (full `<!DOCTYPE>`, inline `<style>`, no external83assets) is great for previewing/sharing a loader concept. Serve it via the dev84server (`http://localhost:<port>/<name>.html`) rather than relying on `file://`85double-click, which may not open if `.html` isn't associated with a browser86(dragging the file into a browser also works).8788## Neutral, themeable snippets (adopt project tokens — these default to `currentColor` / CSS vars)8990```css91/* Spinner */92@keyframes ld-spin { to { transform: rotate(360deg); } }93.ld-spinner { width: 24px; height: 24px; border-radius: 50%;94 border: 2px solid color-mix(in srgb, currentColor 22%, transparent);95 border-top-color: currentColor; animation: ld-spin .8s linear infinite; }9697/* Skeleton shimmer — set --sk to a project surface colour */98@keyframes ld-shimmer { to { background-position: -200% 0; } }99.ld-skeleton { border-radius: 8px; background:100 linear-gradient(90deg, var(--sk,#e5e7eb) 25%,101 color-mix(in srgb, var(--sk,#e5e7eb) 55%, #fff) 37%, var(--sk,#e5e7eb) 63%);102 background-size: 200% 100%; animation: ld-shimmer 1.4s ease infinite; }103104/* Indeterminate bar */105@keyframes ld-bar { 0%{transform:translateX(-130%)} 100%{transform:translateX(360%)} }106.ld-bar { position:relative; height:4px; border-radius:999px;107 background: color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }108.ld-bar>i { position:absolute; inset:0 auto 0 0; width:40%; border-radius:999px;109 background: currentColor; animation: ld-bar 1.4s ease-in-out infinite; }110111/* Dot pulse */112@keyframes ld-dot { 0%,60%,100%{opacity:.25} 30%{opacity:1} }113.ld-dots i { display:inline-block; width:5px; height:5px; border-radius:50%;114 background: currentColor; margin:0 2px; animation: ld-dot 1.4s infinite; }115.ld-dots i:nth-child(2){animation-delay:.2s} .ld-dots i:nth-child(3){animation-delay:.4s}116117@media (prefers-reduced-motion: reduce) {118 .ld-spinner,.ld-skeleton,.ld-bar>i,.ld-dots i { animation: none; }119}120```121122Colour these via the project's tokens (set `color`/`--sk` on a parent, or map to123`--text-secondary` / `--surface-2` / the brand accent). Keep them minimal unless124the project's design language calls for something richer.125126## Process — mockup first, then implement (default)127128Default to showing a preview BEFORE touching app code, so the user approves the129direction first:1301311. Read the project's design tokens / brand; note the app's aesthetic + language.1322. Decide the wait type → pick the pattern above.1333. **Show an HTML 示意 first, for approval.** Render a quick preview — an inline134 widget mockup, or a self-contained standalone `.html` — built with the135 project's tokens. Do NOT wire it into the app yet. Wait for the user's OK;136 they'll often request tweaks, so iterate on the mockup until they're happy.1374. Once approved, build the real component using the project's tokens, one138 primary motion, a reduced-motion fallback, and an aria label.1395. Wire the loading state; ensure it clears when work is done (delay-show if the140 op is fast).1416. Verify visually; keep / offer the standalone HTML for sharing.142143Skip the mockup step (go straight to step 4) only if the user explicitly says144"just build it" / "直接做" / gives a design that's already locked.