Type something to search...
How to Create a Loading Spinner with Pure CSS

How to Create a Loading Spinner with Pure CSS

A loading spinner is one of the smallest pieces of UI on a page, but it carries real weight. It tells the user that their click registered, that the request is in flight, and that they should wait instead of clicking again. For years, spinners were animated GIFs or bundled icon fonts. Today you can build every common style with a single element, a few lines of CSS, and no external assets at all.

This guide walks through the building blocks (@keyframes, transform, and border tricks), then builds five different spinners you can drop into a project. Along the way we'll cover accessibility, reduced motion, sizing with custom properties, and the performance details that keep a spinner smooth even when the main thread is busy.

Why Build Spinners in CSS?

There are a few practical reasons to reach for CSS instead of an image or a component library:

  • No extra requests : The spinner is part of your stylesheet, so it's available the instant the page renders. A GIF has to download first, which defeats the purpose of a loading indicator.
  • Crisp at any size : CSS shapes are vector-like. They stay sharp on high-density screens and scale with a single variable.
  • Themeable : Colors can come from currentColor or custom properties, so the spinner matches light mode, dark mode, and brand colors automatically.
  • Smooth animation : Animations on transform and opacity can run on the compositor thread in most browsers, so they keep spinning even while JavaScript is parsing a large response.

The Core Building Blocks

Every spinner in this article uses the same three ingredients.

1. A @keyframes rule

Keyframes describe how a property changes over one cycle of the animation. For a rotating spinner, you only need the end state:

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

When you omit the from keyframe, the browser uses the element's current value (in this case, no rotation) as the starting point.

2. The animation shorthand

The shorthand attaches the keyframes to an element and controls timing:

.spinner {
  animation: spin 0.8s linear infinite;
}

That reads as: run spin, take 0.8 seconds per cycle, use a constant speed, and repeat forever. linear matters here. With the default ease timing, a rotating spinner visibly speeds up and slows down each turn, which looks like it's stuttering.

3. A shape to animate

Most spinners are a square element turned into a circle with border-radius: 50%, with one part of the circle visually different so the rotation is noticeable. A perfectly uniform circle rotating looks exactly like a circle standing still.

Spinner 1: The Classic Border Ring

This is the spinner you've seen a thousand times: a faint ring with one colored segment chasing itself around.

<div class="spinner" role="status">
  <span class="visually-hidden">Loading...</span>
</div>
.spinner {
  --size: 48px;
  --thickness: 5px;
  --track: rgb(148 163 184 / 0.3);
  --indicator: #38bdf8;

  width: var(--size);
  aspect-ratio: 1;
  border: var(--thickness) solid var(--track);
  border-top-color: var(--indicator);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

The trick is that each side of a border is drawn separately. Setting all four sides to a faint track color and then overriding border-top-color produces a quarter-circle arc. Because border-radius: 50% rounds the whole box, the arc curves smoothly into the track.

Want a two-segment look? Color the opposite side too:

.spinner--dual {
  border-top-color: var(--indicator);
  border-bottom-color: var(--indicator);
}

A useful detail: aspect-ratio: 1 means you only set the width. The height follows, and you can't accidentally end up with an oval.

Spinner 2: A Smooth Gradient Arc with conic-gradient

Border segments have hard ends. If you want an arc that fades out like a comet tail, use a conic-gradient and cut out the middle with a mask.

<div class="spinner-arc" role="status" aria-label="Loading"></div>
.spinner-arc {
  --size: 56px;
  --thickness: 6px;

  width: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(
    from 0deg,
    transparent 0deg,
    #818cf8 330deg,
    transparent 360deg
  );
  mask: radial-gradient(
    farthest-side,
    transparent calc(100% - var(--thickness)),
    #000 calc(100% - var(--thickness) + 1px)
  );
  animation: spin 1s linear infinite;
}

Here's what each piece does:

  • conic-gradient paints color around the center point, starting transparent and building up to solid indigo. That creates the tail.
  • The mask uses a radial-gradient that is transparent in the middle and opaque only in the outer ring. Anything transparent in the mask becomes invisible, so the solid disc becomes a ring.
  • The + 1px on the second stop gives a tiny antialiased edge, which avoids a jagged inner circle.

Both conic-gradient and the unprefixed mask property are supported in all current major browsers. If you need to support older Safari versions, add -webkit-mask with the same value above the unprefixed line.

Spinner 3: Three Bouncing Dots

Not every loader needs to rotate. Dot loaders are common in chat interfaces ("the other person is typing") and inline buttons.

<div class="dots" role="status" aria-label="Loading">
  <span></span>
  <span></span>
  <span></span>
</div>
.dots {
  display: inline-flex;
  gap: 6px;
  align-items: center;
}

.dots span {
  width: 10px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: currentColor;
  animation: bounce 0.9s ease-in-out infinite;
}

.dots span:nth-child(2) {
  animation-delay: 0.15s;
}

.dots span:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes bounce {
  0%,
  80%,
  100% {
    transform: translateY(0);
    opacity: 0.4;
  }
  40% {
    transform: translateY(-8px);
    opacity: 1;
  }
}

The staggered animation-delay is what makes the dots feel like a wave. Each dot runs the same animation, just offset in time.

There's one subtle issue with positive delays: on the very first cycle, the second and third dots sit still for a moment before they start moving. If you want the wave to be in motion from the first frame, use negative delays instead. A negative delay starts the animation partway through its cycle:

.dots span:nth-child(1) {
  animation-delay: -0.3s;
}

.dots span:nth-child(2) {
  animation-delay: -0.15s;
}

Because the dots use currentColor, they take on the text color of whatever contains them. Put them inside a white-on-blue button and they're white; put them in a gray paragraph and they're gray.

Spinner 4: Pulsing Ring

A pulse is a calmer alternative to rotation, and it works well for "live" or "connecting" states.

<div class="pulse" role="status" aria-label="Connecting"></div>
.pulse {
  position: relative;
  width: 16px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #34d399;
}

.pulse::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: inherit;
  animation: ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}

@keyframes ping {
  to {
    transform: scale(3);
    opacity: 0;
  }
}

The solid dot stays put while its ::after copy grows and fades. Because only transform and opacity change, this is cheap to render. Using inherit for the background and radius means you only have to change the color in one place.

Spinner 5: A Segmented "iOS-Style" Spinner

The segmented spinner, with a ring of bars that fade in sequence, looks complicated, but it's just one repeated element rotated at even intervals. You can build it with a custom property per bar.

<div class="segments" role="status" aria-label="Loading">
  <i style="--i: 0"></i>
  <i style="--i: 1"></i>
  <i style="--i: 2"></i>
  <i style="--i: 3"></i>
  <i style="--i: 4"></i>
  <i style="--i: 5"></i>
  <i style="--i: 6"></i>
  <i style="--i: 7"></i>
</div>
.segments {
  --count: 8;
  --duration: 0.8s;

  position: relative;
  width: 40px;
  aspect-ratio: 1;
  color: #94a3b8;
}

.segments i {
  position: absolute;
  left: calc(50% - 2px);
  top: 0;
  width: 4px;
  height: 28%;
  border-radius: 2px;
  background: currentColor;
  transform-origin: 50% calc(40px / 2);
  transform: rotate(calc(360deg / var(--count) * var(--i)));
  animation: fade var(--duration) linear infinite;
  animation-delay: calc(
    var(--duration) / var(--count) * var(--i) - var(--duration)
  );
}

@keyframes fade {
  from {
    opacity: 1;
  }
  to {
    opacity: 0.15;
  }
}

Let's unpack the math:

  • Each bar is positioned at the top center of the box.
  • transform-origin is set to the center of the whole spinner (20px down from the top of each bar), so rotating a bar swings it around the spinner's middle.
  • 360deg / 8 * i spaces the bars 45 degrees apart.
  • The delay formula gives each bar a slightly later start, and subtracting the full duration makes every delay negative, so all bars are mid-animation on the first frame.

If you change the spinner's width, update the transform-origin value to half of it. You could also store the size in a custom property and use calc(var(--size) / 2) for both.

Making Spinners Reusable with Custom Properties

Once you have a spinner you like, expose its knobs as custom properties so the same class works everywhere:

.spinner {
  --size: 2.5em;
  --thickness: 0.25em;
  --indicator: currentColor;
  --track: color-mix(in srgb, currentColor 20%, transparent);
  --speed: 0.8s;

  display: inline-block;
  width: var(--size);
  aspect-ratio: 1;
  border: var(--thickness) solid var(--track);
  border-top-color: var(--indicator);
  border-radius: 50%;
  animation: spin var(--speed) linear infinite;
  vertical-align: middle;
}

.spinner--sm {
  --size: 1em;
  --thickness: 2px;
}

.spinner--lg {
  --size: 4rem;
  --thickness: 6px;
}

Using em for size ties the spinner to the surrounding font size, which is exactly what you want inside a button. color-mix() creates a faint version of the current text color for the track, so the spinner adapts to its context without any extra color definitions. color-mix() is supported in all current major browsers; if you need older ones, set a plain rgb() fallback above it.

A spinner inside a button

A common pattern is to swap a button's label for a spinner while a form submits:

<button class="btn" aria-busy="true" disabled>
  <span class="spinner spinner--sm" aria-hidden="true"></span>
  Saving...
</button>
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.6em 1.2em;
  font: inherit;
  color: #fff;
  background: #2563eb;
  border: 0;
  border-radius: 8px;
}

.btn[aria-busy="true"] {
  cursor: progress;
  opacity: 0.85;
}

Keep the button's text visible ("Saving...") rather than replacing it entirely. It tells sighted users what's happening and keeps the button's width from jumping.

Accessibility: Spinners Need Words

A spinning circle means nothing to a screen reader. There are a few ways to handle this, depending on context.

Standalone loaders should use role="status", which creates a polite live region, plus a text label. Either put visually hidden text inside it, as in the first example, or use aria-label. When the loader is inserted into the page, assistive technology announces it without interrupting the user.

Loaders inside another control, like the button above, should be hidden with aria-hidden="true" because the surrounding text already explains the state. Announcing both "Saving" and "Loading" is redundant.

Regions that are updating can carry aria-busy="true" while content loads, and you remove it when the content arrives:

<section aria-busy="true" aria-live="polite">
  <div class="spinner" role="status">
    <span class="visually-hidden">Loading results...</span>
  </div>
</section>

Keep in mind that live regions are more reliable when the container already exists in the DOM before you change its content. If you inject a brand-new element with role="status" and text at the same moment, some screen reader and browser combinations won't announce it.

Respecting Reduced Motion

Some people experience dizziness or nausea from continuous motion. The prefers-reduced-motion media query lets you tone things down. Don't remove the loader entirely, since the user still needs to know something is happening. Slow it down or replace rotation with a gentle fade:

@media (prefers-reduced-motion: reduce) {
  .spinner,
  .spinner-arc {
    animation-duration: 2.5s;
  }

  .dots span,
  .segments i {
    animation: none;
    opacity: 0.6;
  }

  .pulse::after {
    animation: none;
  }
}

A slow rotation is generally considered acceptable because it's small and doesn't move across the screen, but this is a judgment call. If your audience skews toward people who have turned the setting on, a static "Loading..." label with a subtle opacity pulse is the safest choice.

Performance Tips

A spinner runs constantly, so small inefficiencies add up.

  • Animate only transform and opacity. Animating width, border-width, top, or margin forces layout or paint on every frame. Every spinner in this article sticks to the cheap properties.
  • Don't leave hidden spinners animating. A spinner hidden with opacity: 0 or visibility: hidden may still be animating. Use display: none or remove it from the DOM when loading finishes.
  • Avoid will-change everywhere. Browsers already promote animated transforms to their own layer. Adding will-change: transform to dozens of elements can waste memory.
  • Delay short loaders. If a request usually finishes in 200ms, a spinner that flashes for a fraction of a second is more distracting than helpful. You can delay its appearance with CSS alone:
.spinner--delayed {
  opacity: 0;
  animation:
    spin 0.8s linear infinite,
    appear 0s linear 400ms forwards;
}

@keyframes appear {
  to {
    opacity: 1;
  }
}

The second animation has zero duration and a 400ms delay. It snaps the spinner to visible only if it's still on the page after 400ms, and forwards keeps it visible. Fast requests never show the spinner at all.

Common Pitfalls

  1. The spinner wobbles : If the element isn't a perfect square, or its dimensions produce subpixel values, the rotation looks off-center. Use aspect-ratio: 1 and whole-pixel or em sizes that resolve cleanly.
  2. It stutters on each turn : You're probably using the default ease timing. Rotation spinners need linear.
  3. It inherits a transform : If you already use transform on the spinner for positioning, such as translate(-50%, -50%) for centering, the keyframe rotate() replaces it and the spinner jumps. Either wrap it in a positioned parent, or use the individual rotate property in the keyframes (to { rotate: 360deg; }), which combines with transform instead of overriding it.
  4. It's invisible in dark mode : Hardcoded dark track colors disappear on dark backgrounds. Build track colors from currentColor with color-mix() or use semi-transparent values.
  5. No text alternative : A spinner with no label is silent for screen reader users. Always pair it with role="status" and text, or hide it and let nearby text carry the meaning.

Centering a Full-Page Loader

For a page-level loader that sits over content, a grid overlay is the simplest approach:

.loader-overlay {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgb(15 23 42 / 0.6);
  z-index: 1000;
}
<div class="loader-overlay">
  <div class="spinner spinner--lg" role="status">
    <span class="visually-hidden">Loading page...</span>
  </div>
</div>

place-items: center centers the spinner horizontally and vertically with no transforms, which also sidesteps pitfall number three.

Conclusion

A pure CSS spinner is a small project that touches a lot of fundamentals: keyframes, transforms, borders, gradients, masks, custom properties, and accessibility. Start with the classic border ring for most use cases, reach for the conic-gradient arc when you want something more polished, and use dots or pulses when rotation doesn't fit the context.

Whichever style you pick, keep the same checklist in mind: animate only transform and opacity, give the spinner a text alternative, honor prefers-reduced-motion, and remove it from the page as soon as loading is done. Do that, and your users will always know the page is working for them, even when they have to wait a second.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading