99tools

CSS Loader Generator

runs in your browser

Pure-CSS spinners, dots, bars and progress loaders: set size, colour and speed, and copy accessible HTML and CSS with a reduced-motion fallback built in.

Loader

Loader

Preview

paused

A ring, 40px, one cycle every 1s, paused on the frame it opens with.

Settings

px

px

s

Reduced motion

HTML

<div class="loader" role="status">
  <span class="loader-label">Loading…</span>
</div>

role="status" makes the loader a polite live region and the moving parts are empty, so the label is all there is to read. Some screen readers stay quiet when a live region arrives with its words already in it; see below.

CSS

.loader {
  --loader-duration: 1s;
  display: inline-block;
  width: 40px;
  height: 40px;
  box-sizing: border-box;
  border-radius: 50%;
  border: 4px solid #0f6b4533;
  border-top-color: #0f6b45;
  animation: loader-spin var(--loader-duration) linear infinite;
}

.loader-label {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

@keyframes loader-spin {
  from {
    transform: rotate(0deg);
  }

  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .loader {
    animation: loader-pulse calc(var(--loader-duration) * 2) ease-in-out infinite;
  }
}

@keyframes loader-pulse {
  0%, 100% {
    opacity: 1;
  }

  50% {
    opacity: 0.4;
  }
}

Every length of time reads from --loader-duration, so changing that one value speeds up or slows the whole loader, stagger included.

about this tool

Eight loaders from one set of settings

Pick a ring, a dual ring, an arc, bouncing dots, fading dots, bars, a ripple or an indeterminate progress bar, then set its size, stroke, colour, the length of one cycle and, for dots and bars, how many. The HTML and CSS below update as you change anything, and the demo page download is the two together in a file you can open on its own.

Every loader is plain CSS: no images, no SVG and no JavaScript. The rings spin with a transform: rotate() from 0deg to 360deg, so the last frame is the first one again and the loop never jumps. Dots and bars share one animation and start at different points in it with negative animation-delay, so the whole row is already moving on the first frame rather than waiting its turn. Only transform and opacity animate, the two properties a browser can move without laying the page out again.

Timing in one place

The loader carries a custom property, --loader-duration for a loader named loader. The animation reads it, and every stagger delay is a calc() of it. Change that one value, in the CSS or on the loader element itself (an inline style works), and the whole loader speeds up or slows down with its stagger kept in proportion. It is declared on the loader, so setting it on a parent changes nothing. The easing is fixed per loader: linear for the turning ones, where anything else would pulse, ease-out for the ripple and ease-in-out for the rest. To use a curve from the cubic-bezier editor instead, paste it over the easing keyword in the animation line.

Accessible by default

A spinner says nothing to someone who cannot see it. The HTML puts role="status" on the loader, which makes it a polite live region, and inside it a label hidden the way Bootstrap's .visually-hidden hides one: out of sight, still read. The moving parts are empty elements, so the label is all there is to read. The label is escaped, so whatever you type appears as text, and direction-override characters are dropped from it.

A live region is announced when its content changes, and some screen readers say nothing when one arrives with its words already inside. If yours is added to the page only when loading starts, keep an empty element with role="status" on the page from the start and put the loader into it. When the work is done, remove the loader or change its label to say so.

The reduced-motion choice decides what people who have asked their system for less motion see. Fade instead replaces the movement with a slow opacity pulse on the whole loader; slow down keeps the motion at a third of the speed; leave as is writes no @media (prefers-reduced-motion: reduce) block at all. The preview here is paused on the frame it opens with until you press play, and on a device that asks for less motion it plays what that setting gives. Under fade instead, the progress bar's fill spans the whole track, so a bar that no longer slides does not read as partly done.

Colours and names

The colour takes hex, colour names, rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch() and color(); var(), color-mix() and relative colours are not read. Hex, names and rgb() are written as hex, as a browser would clamp them anyway. The rest keep their own notation, so wide-gamut colours such as oklch(), color(display-p3 …) or color(srgb …) past 0–1 are not clipped. currentColor passes through, so a loader can take the text colour around it. The faint track behind the plain ring and the progress bar is the same colour at a fifth of its alpha. The colour picker knows only opaque sRGB, so the tool says when picking would replace what you typed. Nothing you type is copied into the CSS as it stands: the colour is read and written back, and the class name has to be a plain identifier.

Everything is named after the class: the children, the label, the keyframes and the duration property. Two loaders with different names can share a page without touching each other, as long as one name is not the other with -dot, -bar, -ring, -fill or -label on the end.

Limits

The progress bar has a fixed width in pixels; set width: 100% on it to fill its container. The arc is cut out with mask, which older Safari reads only as -webkit-mask, so both are written. Sizes run from 12 to 240 pixels and a cycle from 0.2 to 10 seconds in steps of 0.05; a number typed past either end is held there and the tool says so. A ring's stroke stays a pixel under half its size so the middle stays open. Nothing is stored or sent anywhere.

questions

How do I make a loading spinner with only CSS?
Give an element a width, a height, a round border-radius and a border in a faint colour, colour one side of the border solid, and rotate it with an infinite linear animation from 0deg to 360deg. That is the ring here. The dots, bars, ripple and progress bar use the same idea with children that share one animation, each started at a different point in its cycle with a negative animation-delay, so the row moves as a wave from the very first frame.
How do I make a loading spinner accessible?
The motion means nothing to a screen reader, so the words have to be there too. The generated HTML puts role="status" on the loader, which makes it a polite live region, and a visually hidden label inside it such as "Loading…". The dots and bars are empty elements, so nothing else is read. Some screen readers stay silent when a live region arrives with its words already in it, so keep an empty role="status" element on the page and put the loader into it. Remove the loader, or change its label to say what finished, when the work is done.
What happens under prefers-reduced-motion?
You choose. Fade instead swaps the movement for a slow opacity pulse on the whole loader, so it still reads as busy without anything travelling across the screen. Slow down keeps the motion at a third of the speed. Leave as is writes no media query at all. The first two only change the loader for people who have asked their system for less motion.
Why does everything use one custom property for the duration?
So the timing lives in one place. The animation reads the --name-duration value and every stagger delay is a calc() of it, so changing it, in the CSS or on the loader element itself, speeds up or slows the whole loader and keeps the spacing between dots in proportion. The reduced-motion block changes only that value, which is how slow down keeps its shape.
Will the class name clash with my other CSS?
Everything is named after the class you give it: the loader, its children, its label, its keyframes and its duration property, so two loaders with different names on one page do not interfere, as long as one name is not the other plus -dot, -bar, -ring, -fill or -label. Use a name nothing else on the site uses. The preview on this page is written under a name of its own, so typing a class like flex here restyles nothing but the output.