CSS Loading Spinner: A Clean, Copyable Pattern

A dependable CSS spinner needs two layers
A clean CSS loading spinner can be made from one empty element with a circular border, one contrasting border segment, and a transform: rotate() animation. Mark that visual element aria-hidden="true" and pair it with real status text. The animation communicates activity visually; the text communicates the state to people who cannot see or comfortably watch it.
Here is the complete pattern:
<div class="loading" role="status" aria-atomic="true">
<span class="spinner" aria-hidden="true"></span>
<span class="loading__text">Loading results…</span>
</div>
.loading {
--spinner-size: 2rem;
--spinner-width: 0.25rem;
--spinner-track: #cbd5e1;
--spinner-active: #1d4ed8;
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: #0f172a;
font: 600 1rem/1.4 system-ui, sans-serif;
}
.spinner {
box-sizing: border-box;
width: var(--spinner-size);
height: var(--spinner-size);
flex: 0 0 auto;
border: var(--spinner-width) solid var(--spinner-track);
border-top-color: var(--spinner-active);
border-radius: 50%;
animation: spinner-rotate 0.8s linear infinite;
}
@keyframes spinner-rotate {
to {
transform: rotate(1turn);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
}
Paste the HTML and CSS into a blank page and the ring will rotate beside “Loading results…”. Under a reduced-motion preference, it remains a static partial ring while the text continues carrying the meaning.
How the spinner works
The element has equal width and height, a full border, and border-radius: 50%, which creates a ring. Three sides use the quiet track color. border-top-color creates one contrasting segment. Rotation moves that segment around the ring.
The keyframe needs only an ending state. The browser interpolates from the element's initial transform to one complete turn. linear keeps angular speed constant; infinite repeats until the loading element is removed.
The box-sizing: border-box declaration keeps the declared width and height inclusive of the border. Without it, a project-wide box-sizing reset may still save you, but a standalone snippet should not rely on a kindly ancestor it has never met.
flex: 0 0 auto prevents the spinner from shrinking in a tight flex container. The inline flex wrapper aligns the ring and text and gives them a predictable gap.
Customize size, thickness, color, and speed
Change the custom properties on .loading:
.loading--large {
--spinner-size: 3rem;
--spinner-width: 0.375rem;
--spinner-track: #d1fae5;
--spinner-active: #047857;
}
<div class="loading loading--large" role="status" aria-atomic="true">
<span class="spinner" aria-hidden="true"></span>
<span class="loading__text">Preparing preview…</span>
</div>
Keep width and height equal. A thicker border creates a heavier ring but also leaves less open center. For a tiny spinner, reduce the border width along with overall size so it does not become a rotating poker chip.
To change speed, edit the animation duration:
.spinner {
animation-duration: 1.1s;
}
A longer duration rotates more slowly. Avoid rapid, attention-demanding motion, especially when several indicators could appear at once. Timing should acknowledge the wait, not challenge the user to a staring contest.
Check the active segment and track against every background on which the component appears. The active segment must remain distinguishable from the track, and the visible status text needs normal text contrast. A beautiful ring that disappears in dark mode is simply a circle with work-life balance.
Why transform is the right property to animate
This pattern animates transform, not top, left, width, margin, or border width. Properties that change geometry can trigger layout and paint work on every frame. Transforms and opacity can often be handled during compositing, though actual performance still depends on the page, browser, device, and number of animated elements.
MDN's animation performance guide lists transform and opacity as properties that can avoid layout and paint when handled in their own layer. That does not make unlimited animation free. Ten full-page overlays containing two hundred spinning nodes will find a way to become expensive.
Do not add will-change: transform by reflex. Browsers can optimize animations, and persistent layer hints consume resources. Measure a real performance problem before applying a hint, then remove it when the animation is inactive if your implementation truly needs one.
Make the status accessible
The ring is decorative because it provides no information beyond the accompanying text. aria-hidden="true" keeps it out of the accessibility tree. The parent uses role="status", which creates a polite live region, and aria-atomic="true" asks assistive technology to announce the complete updated message.
The W3C technique for role="status" recommends that the status container exist before the message update. In a dynamic application, render an empty status container with the page, then insert “Loading results…” when work begins. If you create both the live region and its already-filled message in one operation, announcements can be inconsistent across environments.
Do not put role="status" on the spinning element while hiding that same element with ARIA. An element removed from the accessibility tree cannot also serve as the accessible message. Keep semantics on the wrapper or text.
If sighted users do not need visible text, visually hide it rather than deleting it:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
<div class="loading" role="status" aria-atomic="true">
<span class="spinner" aria-hidden="true"></span>
<span class="loading__text visually-hidden">Loading results…</span>
</div>
Visible status text is often better because everyone benefits from knowing what is happening. Hide it only when nearby interface context already makes the state unmistakable visually.
Respect reduced-motion preferences
The prefers-reduced-motion: reduce media query detects a user preference for reducing non-essential motion. In this pattern, the spinner stops but remains visually distinct, and the status text remains present.
Do not hide the whole loading component in the media query. Reduced motion is not reduced information. Also avoid replacing rotation with a strong pulsing or scaling loop; that may be no more comfortable. The wider guide to accessible loading indicators covers alternatives and state announcements.
If your design needs a subtle active visual in reduced-motion mode, consider a static ring plus a text ellipsis that changes only when application state changes—not a perpetual animated ellipsis.
Use truthful application state
CSS can draw the spinner, but JavaScript or framework state must decide when it appears. A robust loading flow should:
- Insert or reveal the indicator when the task begins.
- Mark the affected content region
aria-busy="true"when appropriate. - Update status text if the task meaningfully changes.
- Remove or hide the indicator when the task succeeds or fails.
- Set
aria-busy="false"and move focus only when the user needs it. - Show an actionable error rather than spinning forever.
Avoid flashing a spinner for near-instant work. A brief delay before showing it can prevent distracting flicker, while a separate threshold can introduce more detailed progress or a cancel option for longer operations. Choose timings from observed application behavior rather than copying a universal millisecond recipe.
The spinner must not block the entire page unless the entire page truly cannot be used. For a loading card, place the indicator within that card and keep unrelated controls available.
When to use another loading pattern
Use this compact ring for an indeterminate wait where progress cannot be measured and the affected area is clear. Choose something else when:
- Several text or media blocks are taking shape: a CSS skeleton loader can reserve the final layout.
- A short inline action is pending: the three-dot loader can fit beside a label.
- Completion can be measured: use a semantic
<progress>element or an appropriately implemented progress bar with actual values. - Nothing is happening: show an empty state, not a loader.
- The request failed: show the error and a retry path, not immortal rotation.
Common spinner bugs
The circle looks oval
Check that width and height match and that flex or grid sizing is not stretching one axis. Keep flex: 0 0 auto on the ring.
The animation does not run
Confirm the keyframe name matches exactly, the stylesheet is loaded, and no later rule sets animation: none. A reduced-motion setting may be activating the intentional static state.
The spinner changes layout when it appears
Reserve its space or position it inside a container designed for loading state. If the text changes from a short button label to a wider message, size the control for both states.
Screen readers announce nothing
Ensure the status container exists before its text changes, is not aria-hidden, and contains a useful message. Test with the assistive technologies your project supports; semantic intent still deserves real-world verification.
It never disappears
That is not a CSS defect. Tie visibility to success and error handling, and include timeout or retry behavior appropriate to the task. The most accessible animation in the world cannot compensate for a promise that never resolves.
The finished component is deliberately modest: one visual span, one text span, one transform animation, and one reduced-motion rule. Good loading feedback should make waiting legible, then get off the stage.