CSS Loader Generator
runs in your browserPure-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
Preview
pausedA ring, 40px, one cycle every 1s, paused on the frame it opens with.
Settings
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.