tanghoong design
  1. Design system
  2. 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.

WaitShowWhy
Under ~300 msNothingA spinner that appears and vanishes reads as a glitch. Let it land.
You know the shape.skelThe placeholder holds the exact space, so nothing jumps when content arrives.
You don't know the shape.spinnerA submit, a search, a save. There is no layout to reserve.
Whole route changing.progress--topA 2 px bar under the header. Cheap, and it does not block the page.
You have a real percentage.meterA 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.

A card, and its skeleton — same box, same height
Realtime bid pipeline Cut p95 from 240 ms to 38 ms by moving scoring to the edge and pre-warming the model cache. Delivered · 2024 AdTechGo
<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.

The pieces

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.

Do Sweep, don't pulse. The gradient moving across reads as "working"; an opacity pulse reads as "broken". Under prefers-reduced-motion it holds a static tint — the affordance stays, the movement goes.
Don't Skeleton a list of unknown length. Show three or four rows, not twenty — you are promising a shape you may not deliver, and the collapse when four arrive is worse than no skeleton at all.

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.

Spinner sizes and in-context use
— same width, so the row does not reflow mid-request
Checking availability…
<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 bar, and the determinate one you should prefer

Indeterminate — route change, unknown duration

Determinate — use this whenever you can count

Uploading 3 of 837%

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 · empty · error

Loading

Loaded

  • Singapore

    Live

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.

AttributeOnDoes
aria-busy="true"The region or control that is waitingTells assistive tech the content is mid-update, so it does not announce a half-built tree
role="status"A short text message beside the spinnerAnnounces politely, without interrupting
role="alert"The error that replaces itInterrupts, because a failure needs to be heard now
aria-labelA skeleton container"Loading project" — the skeleton itself has no readable text
Never leave 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.

Sanctioned — this is the complete list
MotionTokenWhere
Colour / background change--dur-fastHover and focus on any control
Scale to 0.97--dur-fast:active on buttons — the press confirmation
Lift 2 px + shadow--durHover on a card that is a link
Arrow nudge 3 px--dur.link-more and .card__arrow
Fade + 4 px rise--dur-fastMenus and popovers opening
Fade + scale 0.97--durModal entrance
Slide from the edge--dur-slowDrawers and the nav sheet
Chevron rotate 180°--durAccordion open
Skeleton sweep1.4s loopPlaceholder content only
Spinner rotate0.7s loopIndeterminate waits only
Not in this system Scroll-triggered reveals. Content that fades in as you scroll is content that is missing until you scroll, and it breaks find-in-page, print and anyone reading fast.
Not in this system Parallax, tilt, magnetic cursors, spring physics, bounce or elastic easing. One curve — cubic-bezier(.22,1,.36,1) — and it does not overshoot.
Not in this system Autoplaying loops as decoration: pulsing dots, breathing gradients, animated background mesh. The only permitted infinite animations are the skeleton sweep and the spinner, and both mean "waiting".
Not in this system Page transitions that delay navigation. A click should paint the next page, not play an animation first.
Not in this system Animating height, width, top or margin. Only opacity and transform stay on the compositor; the rest force layout every frame.
The test If the animation were removed, would anything be harder to understand? If not, it is ornament — cut it.
Reduced motion is a hard requirement, not a nicety 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.

The format
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.

FieldLengthRule
<title>≤ 60 charsPage name first, em dash, site name. Front-load the distinguishing word.
meta description≤ 160 charsOne sentence, written for a person. Not a keyword list.
og:title≤ 60 charsSame as <title>. No reason to differ.
og:image1200×630Required, or the shared link previews blank.
h1One per page. It may be shorter than the title; the title carries the site name, the h1 does not.
Do Keep the site name identical across a property. tanghoong design system on every page here — not "Design System" on one and "tanghoong design" on the next.
Don't Put the site name first. "tanghoong design system — Foundations" truncates to "tanghoong desi…" in a tab, which is identical for all ten pages.