Spinner
Indicates that a process is loading or in progress. Use for small or inline loading states. Don't use for large content areas — use a skeleton loader pattern instead.
FigmaDescription
Displays an animated circular indicator that communicates to users that a system process is underway and they should wait for it to complete.
Inside buttons during form submission, inline next to labels when fetching data, within small containers like cards or panels when their content is loading, and in toast or alert regions when a background task is running.
Government users frequently submit forms, run searches, or trigger exports that take a moment to complete. Without a loading indicator, users may submit twice or assume the system has failed. A spinner provides immediate feedback that the action was received and is being processed.
Anatomy
| Part | Required? | Notes |
|---|---|---|
| Track | Required | The full circular path that the indicator arc travels along. Rendered as a faint ring using --op-color-border-default at reduced opacity. |
| Arc indicator | Required | The animated segment that rotates around the track. Inherits colour from the surrounding context or an explicit variant token. |
| Label (visually hidden) | Recommended | An accessible text label passed via aria-label or a visually hidden element. Announced by screen readers in place of the animation, which conveys no meaning to assistive technology. |
Variants
Default
General-purpose loading state; inherits the current text colour.
Use within neutral containers, panels, or alongside body text when no specific brand colour is required.
Primary
Uses --op-color-interactive-default (green-400) to signal an in-progress action tied to a primary interaction.
Use inside or adjacent to a primary button that has been activated, or when confirming a key user action such as saving a consultation record.
Inverse
White arc on a dark or coloured background.
Use inside filled primary buttons, dark headers, or coloured banners where the default colour would not meet contrast requirements.
Muted
Reduced-prominence indicator using --op-color-text-tertiary.
Use for background or low-priority operations that should not compete with primary content, such as auto-saving a draft.
States
| State | Behaviour |
|---|---|
| Spinning | The arc rotates continuously at a consistent speed. This is the only active state. The animation runs indefinitely until the process resolves or the component is removed from the DOM. |
| Reduced motion | When the user has enabled prefers-reduced-motion, the continuous rotation is replaced by a pulsing opacity animation (fade in/out) so that motion-sensitive users still receive feedback without vestibular discomfort. |
| Hidden (resolved) | Once the loading state ends, the spinner is removed or hidden and focus is managed to the resulting content or a relevant landmark, as appropriate. |
Usage guidelines
When to use
- A button action is in progress — for example, submitting a stakeholder engagement form or exporting a consultation report.
- A small data region is fetching updated content, such as refreshing a stakeholder count or loading the next page of consultation responses.
- An inline search is resolving results, displayed next to the search input.
- A background auto-save is running on a community survey draft.
When not to use
- When loading a full page or large content area — use a skeleton loader pattern instead, which better sets expectations about the content structure.
- When progress can be quantified — use a progress bar (Progress Bar) so users know how much remains.
- As a permanent or persistent element — spinners must always resolve. If a process has no deterministic end, show an error state with a retry action.
- When the delay is under 300ms — avoid flashing a spinner for operations that complete almost instantly, as this introduces unnecessary visual noise.
Do / Don't
Do
Pair the spinner with a concise visible or screen-reader-only label that describes what is loading (e.g. 'Saving stakeholder record…').
Don't
Render the spinner alone with no accessible label. Screen readers will announce nothing meaningful about an unlabelled animation.
Do
Disable interactive controls (buttons, inputs) while the spinner is active to prevent duplicate submissions.
Don't
Leave the triggering button enabled while the spinner runs — users may click again, causing duplicate requests.
Do
Size the spinner to fit its container. Use the small size inside buttons and the default size in panels.
Don't
Place a large spinner inside a small button or tight inline context — it will break layout and look unpolished.
Do
Move focus to relevant content or show a success/error message once the spinner resolves.
Don't
Silently remove the spinner without any feedback. Users need confirmation that the action succeeded or failed.
Layout & Spacing
Spinner sizes map to the following dimensions:
- Small (button use): 16x16px — use --op-space-16 as the outer bounding box
- Default: 24x24px — use --op-space-24
- Large (panel use): 32x32px — use --op-space-32
When displayed inline next to text, apply --op-space-8 (8px) of horizontal gap between the spinner and its label using a flex container with gap: var(--op-space-8).
When centred inside a loading container, use flexbox with align-items: center and justify-content: center. Do not rely on absolute positioning.
Tokens
| Part | Token | Value |
|---|---|---|
| Arc indicator (default) | --op-color-text-primary | Inherits text colour so the spinner fits naturally in any text context. |
| Arc indicator (primary variant) | --op-color-interactive-default | Green-400; matches the primary button fill to reinforce that an action is in progress. |
| Arc indicator (inverse variant) | --op-color-text-on-interactive | White; used on dark or filled backgrounds. |
| Arc indicator (muted variant) | --op-color-text-tertiary | Low-prominence; for background or auto-save operations. |
| Track | --op-color-border-default | Applied at 30% opacity to create a subtle guide ring without adding visual weight. |
| Animation duration | 700ms | One full rotation. Not a design token — hardcoded in the component. Reduced-motion variant uses a 1200ms opacity pulse instead. |
Engineering notes
- The component is implemented as a `wa-spinner` Web Awesome web component. Import via the shared Web Awesome bundle — no additional registration required.
- Always supply an `aria-label` attribute on `wa-spinner` with a human-readable description of what is loading. Never rely on surrounding context alone — screen readers focus on the element, not its neighbours.
- Set `aria-live="polite"` on the container region that will be updated once loading completes, not on the spinner itself. This ensures the resolved content is announced without interrupting the user mid-sentence.
- When embedding inside a `wa-button`, add `loading` attribute to the button element rather than manually inserting a spinner — `wa-button` manages the spinner, disabled state, and ARIA internally.
- To honour prefers-reduced-motion, the `wa-spinner` component applies a CSS media query internally. No additional implementation is needed unless building a custom spinner.
- Remove the spinner from the DOM (or set `hidden`) once the process completes. Do not use visibility: hidden — this leaves the element in the accessibility tree.
Keyboard interaction
| Key | Action |
|---|---|
| Tab | The spinner itself is not focusable and does not appear in the tab order. Focus remains on the triggering control (e.g. the button) which should be disabled while loading. |
| Enter | Not applicable — the spinner is a status indicator, not an interactive element. |
Why it matters
Government platforms are required to meet WCAG 2.1 AA. Many users of council and agency portals rely on screen readers or have vestibular disorders that make continuous motion uncomfortable. A spinner without a text label is invisible to screen reader users, and an unthrottled animation can cause nausea for users with vestibular conditions. Both issues are avoidable with two lines of implementation.
Focus
The spinner does not receive focus. When a spinner appears as a result of a user action (e.g. clicking Submit), focus stays on the button. The button should be disabled and its label or aria-label updated to reflect the loading state (e.g. "Saving…"). When the action resolves, re-enable the button or move focus to the success message, updated region, or next logical element.
ARIA
| Role or attribute | When to use | Example |
|---|---|---|
aria-label | Describes what is loading. Applied directly to the wa-spinner element. | <wa-spinner aria-label="Saving stakeholder record"></wa-spinner> |
aria-busy | Applied to the container region that is loading. Set to true while the spinner is active, false when resolved. | <div aria-busy="true" aria-live="polite">...</div> |
aria-live | Applied to the region that will receive the loaded content. Use polite for most cases; use assertive only for errors that require immediate attention. | <section aria-live="polite" aria-atomic="true">...</section> |
role="status" | Can be applied to a visually hidden element that announces a dynamic status message (e.g. 'Loading complete') once the spinner resolves, without moving focus. | <span role="status" class="sr-only">Consultation data loaded.</span> |
Contrast
The arc indicator must meet a minimum 3:1 contrast ratio against its background (WCAG 1.4.11 non-text contrast).
- Default (--op-color-text-primary on --op-color-bg-primary): passes at approximately 14:1 in light mode.
- Primary (--op-color-interactive-default / green-400 on white): verify in the token reference — if the ratio falls below 3:1, switch to the primary-dark token variant for the arc.
- Inverse (--op-color-text-on-interactive on --op-color-interactive-default): white on green-400 — confirm ratio meets 3:1.
- Muted (--op-color-text-tertiary on --op-color-bg-primary): check that the tertiary text token still meets 3:1 for non-text elements. If not, use --op-color-text-secondary instead.
Touch targets
The spinner is not interactive and has no touch target requirement. If a spinner is placed inside a button or a tappable region, the 44x44px minimum touch target applies to the button, not the spinner itself.
Things to avoid
- Avoid using `aria-hidden="true"` on a spinner that is the only loading feedback available — this makes the loading state completely invisible to screen reader users.
- Avoid placing a spinner inside a `role="alert"` region — this will announce the loading state immediately and repeatedly as the animation re-renders, which is disruptive.
- Avoid auto-focusing the spinner — it is not interactive and focus on a non-interactive element confuses keyboard users.
- Avoid using colour alone to distinguish the spinner variant — ensure the label or surrounding context communicates the meaning.