Loading Status
Indicates that content or a process is in a loading or pending state. Use spinners for small inline states and skeleton variants for larger content areas. Don't use a spinner for a process with a known completion percentage.
FigmaDescription
Communicates to users that the system is working — either loading content, processing a submission, or waiting for a response — so they know to wait rather than repeat an action.
Inside buttons during form submissions, inline next to data that is fetching, overlaid on panels when content is loading, and as skeleton placeholders replacing lists or cards before data arrives.
Government users often work on slower networks or with large datasets. Without clear loading feedback, users re-submit forms or assume a process has failed — causing duplicate records or lost trust in the system.
Anatomy
| Part | Required? | Notes |
|---|---|---|
| Spinner track | Required | The full circular path the spinner animates along. Uses --op-color-border-default as the base track colour. |
| Spinner arc | Required | The animated portion that rotates around the track. Uses --op-color-interactive-default (green-400) for the active arc, or inherits the button's text colour when inside a button. |
| Label (spinner) | Optional | Short visible text beside the spinner, e.g. 'Loading...' or 'Saving...'. Omit when space is constrained; always provide an accessible label via aria-label regardless. |
| Skeleton block | Required | A grey rounded rectangle that stands in for a loading content region. Width and height should approximate the real content it replaces. |
| Skeleton shimmer | Recommended | A left-to-right animated gradient sweep across the skeleton block. Provides motion cue that the state is transient, not an error. Must be suppressed under prefers-reduced-motion. |
| Skeleton text lines | Optional | Multiple stacked skeleton blocks at text-line height, used to approximate a paragraph or list while it loads. Last line should be shorter (approx 60%) to mimic natural text wrap. |
Variants
Spinner — default
Inline indeterminate loading state
Small components: inside buttons, next to a field being validated, or beside a single data value being fetched. Use when the loading area is smaller than approximately 120px.
Spinner — overlay
Full-panel or full-section loading state
When an entire card, table, or modal content area is loading and the underlying content is not yet available. Pair with aria-busy on the container.
Skeleton — card
Placeholder for a card-shaped content block
Replacing stakeholder profile cards, consultation summary cards, or engagement activity cards while data loads. Matches the card's approximate dimensions.
Skeleton — list
Placeholder for a list of rows
Replacing tables or lists of stakeholders, submissions, or notifications while the dataset is fetching.
Skeleton — text block
Placeholder for a paragraph or multi-line description
Replacing consultation descriptions, stakeholder notes, or rich text fields while content is loading.
States
| State | Behaviour |
|---|---|
| Loading | Spinner rotates continuously or skeleton shimmer animates. The surrounding container has aria-busy='true'. Interactive elements within the loading region are disabled. |
| Complete | Loading indicator is removed and real content appears. aria-busy is removed or set to 'false'. No additional announcement is needed unless the content change is not visually apparent. |
| Error (post-load) | Loading indicator is replaced by an error state. Use the system's inline error or empty state pattern. Do not leave a spinner running if loading has failed. |
| Reduced motion | Spinner rotation and skeleton shimmer animation are suppressed via prefers-reduced-motion: reduce. The spinner track and arc remain visible as a static indicator; skeleton blocks remain visible without the sweep. |
Usage guidelines
When to use
- A user triggers an action (form submission, search, navigation) that requires a network request or server-side processing before content can be displayed.
- Content inside a panel, table, or card is being fetched asynchronously on page load.
- A button action is in flight — e.g. submitting a stakeholder record or publishing a consultation.
- Multiple content areas are loading simultaneously — use skeleton variants to preserve layout and reduce perceived load time.
When not to use
- When completion percentage is known — use Progress Bar instead.
- For instantaneous actions (under ~300ms) — avoid flashing a spinner for state changes the user will not perceive.
- As a permanent empty state — if content genuinely does not exist, use an empty state component, not a skeleton.
- To indicate success or failure after a process completes — use a Toast, Alert, or inline validation message instead.
Do / Don't
Do
Replace skeleton placeholders with real content as soon as the data is available, even if other regions are still loading.
Don't
Hold all content until every request resolves — this maximises perceived wait time unnecessarily.
Do
Add a visible 'Loading stakeholders...' label or aria-label to spinners so screen reader users receive an equivalent experience.
Don't
Use a spinner with no accessible label — screen readers will announce nothing meaningful.
Do
Disable submit buttons and set aria-disabled while a form submission is in flight to prevent duplicate submissions.
Don't
Leave form controls active during loading — government users may submit duplicate consultation responses or stakeholder records.
Do
Size skeleton blocks to closely approximate the content they replace to minimise layout shift when content arrives.
Don't
Use a single full-width skeleton bar for every content type — mismatched skeletons increase perceived jank.
Layout & Spacing
Spinner sizes:
- sm: 16px diameter — use inside buttons or inline with body text (--op-text-sm)
- md: 24px diameter — default standalone spinner
- lg: 40px diameter — overlay spinner centred in a panel
Spinner label gap: --op-space-8 (8px) between arc and label text
Skeleton blocks:
- Border radius: --op-radius-sm (4px) for text lines, --op-radius-md (8px) for card blocks
- Gap between stacked skeleton text lines: --op-space-8 (8px)
- Card skeleton padding matches the card component's internal padding: --op-space-16 (16px)
Overlay spinner:
- Container background: --op-color-bg-primary at 80% opacity
- Spinner centred horizontally and vertically within the loading region
- Min overlay height: 120px to avoid a spinner appearing clipped
Tokens
| Part | Token | Value |
|---|---|---|
| Spinner arc | --op-color-interactive-default | Green-400. Use --op-color-text-on-interactive when spinner appears inside a filled interactive button. |
| Spinner track | --op-color-border-default | Provides the grey base ring the arc animates over. |
| Skeleton block fill | --op-color-bg-subtle | Light neutral background for skeleton rectangles. |
| Skeleton shimmer highlight | --op-color-bg-primary | Used as the bright band in the shimmer gradient sweep. |
| Overlay background | --op-color-bg-primary | Applied at 80% opacity to the loading overlay container. |
| Spinner label text | --op-color-text-secondary | Subdued label keeps visual emphasis on the spinner itself. |
Engineering notes
- wa-spinner is the Web Awesome primitive for spinner variants. Set size via the size attribute (small, medium, large) or override with CSS custom properties.
- Skeleton variants are not yet provided as wa-* primitives in this release. Implement as div elements with the op-skeleton utility class and the shimmer animation defined in the design token stylesheet.
- Always set aria-label on wa-spinner when no visible label is present, e.g. aria-label='Loading stakeholders'.
- When a spinner is placed inside a wa-button, set the button's loading attribute — this handles disabling the button, swapping to spinner display, and applying aria-busy automatically.
- For overlay loading states, apply aria-busy='true' to the container element (not just the spinner), so assistive technologies announce the busy region correctly.
- Suppress animations in the skeleton shimmer with: @media (prefers-reduced-motion: reduce) { .op-skeleton { animation: none; } }
- When loading completes, move focus to the newly loaded content if the user initiated the load action (e.g. submitted a search). For passive background loads, do not move focus.
Keyboard interaction
| Key | Action |
|---|---|
| Tab | Focus skips over spinner and skeleton elements — they are not interactive. Focus management is handled by the triggering control (e.g. button). |
| Enter | Not applicable to loading indicators directly. The triggering button should be disabled (aria-disabled='true') while loading is in flight. |
Why it matters
Government users include staff and members of the public who rely on screen readers or keyboard navigation. A spinner with no label leaves these users with no indication that the system is working, which in a consultation or engagement context can lead to repeat submissions and corrupted data.
Focus
Do not place focus on a spinner or skeleton element. When a load is triggered by a user action:
- Disable the triggering control and set aria-disabled='true' while loading.
- On load completion, return focus to the triggering control or move it to the top of the newly loaded content region, depending on whether the loaded content replaces the current view.
- For background/passive loads (e.g. a table refreshing on an interval), do not move focus.
ARIA
| Role or attribute | When to use | Example |
|---|---|---|
aria-busy | Apply to the container region that is loading, not to the spinner itself. | <div aria-busy="true" aria-label="Loading stakeholder list">...</div> |
aria-label | Apply to wa-spinner when no visible text label accompanies it. | <wa-spinner aria-label="Saving consultation"></wa-spinner> |
aria-live | Use aria-live='polite' on a visually hidden status region to announce completion or error after loading, if the content change is not self-evident from focus movement. | <p aria-live="polite" class="sr-only">Stakeholders loaded.</p> |
role="status" | Can be applied to a wrapper element to implicitly create an aria-live='polite' region for load completion announcements. | <div role="status" class="sr-only">Consultation saved successfully.</div> |
Contrast
Spinner arc (--op-color-interactive-default, green-400) against the spinner track (--op-color-border-default on --op-color-bg-primary background) meets WCAG 2.1 AA non-text contrast of 3:1 minimum. Spinner label text uses --op-color-text-secondary, which meets 4.5:1 contrast against --op-color-bg-primary. Skeleton blocks use --op-color-bg-subtle, which is a decorative placeholder — contrast requirements for non-text UI components apply (3:1), and the shimmer gradient must not be the sole indicator of loading state.
Touch targets
Spinners are non-interactive and do not require a 44x44px touch target. However, any button containing a spinner must retain its full touch target size (minimum 44x44px) and must not shrink when switching to a loading state.
Things to avoid
- Do not use colour alone to distinguish a loading state from a static state — the spinner animation (or visible skeleton structure) must be the primary indicator.
- Do not leave a spinner running indefinitely — implement a timeout and surface an error state if loading exceeds a reasonable threshold (recommended: 30 seconds for government data queries).
- Do not animate the skeleton shimmer for users who have prefers-reduced-motion enabled.
- Do not use a spinner inside a disabled button without also setting aria-disabled='true' — sighted users see the spinner, but assistive technologies need the attribute.