- Design system
- Loading & motion
07 — Loading & motion
Loading & motion
What a page shows while it is waiting, and what it is allowed to animate. Both are places where a system either holds together or quietly falls apart, because both get invented per-page otherwise.
Choosing a loading state
Three tools, and picking between them is the whole decision. Get it wrong and the page feels broken even though nothing is.
| Wait | Show | Why |
|---|---|---|
| Under ~300 ms | Nothing | A spinner that appears and vanishes reads as a glitch. Let it land. |
| You know the shape | .skel | The placeholder holds the exact space, so nothing jumps when content arrives. |
| You don't know the shape | .spinner | A submit, a search, a save. There is no layout to reserve. |
| Whole route changing | .progress--top | A 2 px bar under the header. Cheap, and it does not block the page. |
| You have a real percentage | .meter | A number always beats a moving stripe. Use the indeterminate bar only when you genuinely cannot count. |
| Refreshing in place | .async[aria-busy] | Dim what is there rather than replacing it — swapping in a skeleton loses the reader's place. |
Skeletons
A skeleton exists to stop the layout jumping. That is its entire job — it is not decoration, and it is not a loading indicator. If your skeleton is a different height from the thing it replaces, it is causing the shift it was added to prevent.
<div class="card" aria-busy="true" aria-label="Loading project"> <span class="skel skel--title"></span> <span class="skel skel--text"></span> <span class="skel skel--text"></span> </div>
The skeleton is built from the same .card — the padding, gap and radius come from the component, not from the skeleton. That is what makes the two boxes the same height without anyone measuring.
Text
The last line is 72% wide. Real paragraphs do not end flush, and a block of equal-length bars reads as a table.
Objects
Sizes are in em, not px, so a skeleton inside smaller type shrinks with it.
prefers-reduced-motion it holds a static tint — the affordance stays, the movement goes.
Spinners and progress
For a wait whose shape you cannot predict. Never for the first paint of a layout you already know — that is a skeleton.
<button class="btn btn--filled" aria-busy="true"> <span class="btn__label">Send enquiry</span> <span class="spinner spinner--sm" aria-hidden="true"></span> </button>
The spinner inherits currentColor, so it works on any button intent with no extra rule. aria-busy both styles the button and tells a screen reader what is happening — there is no .is-loading class to fall out of sync.
Indeterminate — route change, unknown duration
Determinate — use this whenever you can count
A real number tells the reader whether to wait or leave. A moving stripe tells them nothing, so reach for it only when you genuinely cannot count.
The four states
Every region that fetches has four, and a page that only designs the happy one will ship the other three by accident.
Loading
Loaded
- Live
Singapore
Empty
No regions yet
Add one to start routing traffic.
Error
Empty is not an error — it usually means the reader has something to do, so give them the action. An error is not empty: say what failed and offer a retry, never just a blank panel.
Announcing the wait
A spinner is invisible to a screen reader. Two attributes cover almost every case, and neither needs JavaScript beyond toggling them.
| Attribute | On | Does |
|---|---|---|
aria-busy="true" | The region or control that is waiting | Tells assistive tech the content is mid-update, so it does not announce a half-built tree |
role="status" | A short text message beside the spinner | Announces politely, without interrupting |
role="alert" | The error that replaces it | Interrupts, because a failure needs to be heard now |
aria-label | A skeleton container | "Loading project" — the skeleton itself has no readable text |
aria-busy set
It is the most common bug in this area: the request finishes, the spinner is removed, and the attribute stays. The region is then permanently "updating" to a screen reader while looking perfectly normal to everyone else. Clear it in the same place you remove the spinner.
What we animate
The boundary matters more than the list. Motion here is feedback and continuity — it confirms something happened, or it carries the eye from one state to the next. It is never ornament, and nothing on these sites moves on its own.
| Motion | Token | Where |
|---|---|---|
| Colour / background change | --dur-fast | Hover and focus on any control |
| Scale to 0.97 | --dur-fast | :active on buttons — the press confirmation |
| Lift 2 px + shadow | --dur | Hover on a card that is a link |
| Arrow nudge 3 px | --dur | .link-more and .card__arrow |
| Fade + 4 px rise | --dur-fast | Menus and popovers opening |
| Fade + scale 0.97 | --dur | Modal entrance |
| Slide from the edge | --dur-slow | Drawers and the nav sheet |
| Chevron rotate 180° | --dur | Accordion open |
| Skeleton sweep | 1.4s loop | Placeholder content only |
| Spinner rotate | 0.7s loop | Indeterminate waits only |
cubic-bezier(.22,1,.36,1) — and it does not overshoot.
height, width, top or margin. Only opacity and transform stay on the compositor; the rest force layout every frame.
base.css reduces every transition and animation to 0.01 ms under
prefers-reduced-motion: reduce. Two things it cannot reach: an animation
inside a standalone .svg file, which carries its own styles (see
SVG & data), and anything driven from JavaScript.
For the second, read the preference and branch:
matchMedia('(prefers-reduced-motion: reduce)').matches.
Page titles
The <title> is the most-read text on any site — it is the browser tab, the bookmark, the search result and the shared link. One format, everywhere.
Page name — Site name Foundations — tanghoong design system Realtime bid pipeline — Charlie Tang Hoong Not found — tanghoong design system Home page of a sub-domain drops the repetition: tanghoong design system ← not "Home — tanghoong design system" Charlie Tang Hoong — Forward Deployed AI & Automation Engineer
An em dash with spaces, not a pipe or a hyphen. The page name comes first because a browser tab truncates from the right, and at eight visible characters the page name is the only part that distinguishes one tab from another.
| Field | Length | Rule |
|---|---|---|
<title> | ≤ 60 chars | Page name first, em dash, site name. Front-load the distinguishing word. |
meta description | ≤ 160 chars | One sentence, written for a person. Not a keyword list. |
og:title | ≤ 60 chars | Same as <title>. No reason to differ. |
og:image | 1200×630 | Required, or the shared link previews blank. |
h1 | — | One per page. It may be shorter than the title; the title carries the site name, the h1 does not. |
tanghoong design system on every page here — not "Design System" on one and "tanghoong design" on the next.