Free pure-CSS spinner builder
CSS loader generator for one element, no library
This CSS loader generator is free and needs no signup: pick one of eight spinners — a border ring, a counter-rotating dual ring, a conic sweep, a twelve-blade tick, fading dots, bouncing dots, a box-shadow pulse or equaliser bars — and set the diameter from 16px to 160px, the stroke, the color and how long one turn takes. Every one of them is a single div with a keyframes block behind it, so what lands on your clipboard is a stylesheet rule and three lines of HTML rather than a dependency. The markup comes with role="status", a polite live region and an accessible name already on it, and the CSS comes with a prefers-reduced-motion block, because a spinner nobody can hear and a spinner nobody can turn off are the two ways this component usually fails an audit.
- 100% free
- No signup
- 8 loaders
- One div each
- role=status markup
A full border in the track color with border-top-color overridden — the 90° arc is one declaration, not a shape.
5.77:1 clears the 3:1 that WCAG 2.2 criterion 1.4.11 asks of a non-text component on the light panel.
Press Ctrl+V anywhere on the page to drop in a hex code — no need to click the field first.
The stylesheet
.loader {
box-sizing: border-box;
width: 48px;
height: 48px;
border: 5px solid rgba(79, 70, 229, 0.18);
border-top-color: #4f46e5;
border-radius: 50%;
animation: loader-spin 900ms linear infinite;
}
@keyframes loader-spin {
to { transform: rotate(1turn); }
}
@media (prefers-reduced-motion: reduce) {
.loader {
animation-duration: 2700ms;
}
}
.loader-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}The markup
<div class="loader" role="status" aria-live="polite"> <span class="loader-label">Loading…</span> </div>
The span is clipped to a 1px box rather than hidden with display: none, because display: none and visibility: hidden take an element out of the accessibility tree along with its text. Swap “Loading…” for “Loaded” in the same node when the request settles and the live region announces the change. The preview above is marked aria-hidden so this page does not announce a status that is only a picture of one.
How to build a CSS loader
Mechanism, then geometry, then the two things most generators leave out.
Choose the mechanism, not the picture
Each of the eight is labelled with the property doing the painting, because that is what decides where the loader can live. The two border rings paint strictly inside their own width and height, so they are safe in a cell with overflow: hidden. The conic sweep and the twelve-blade tick need a mask to punch the hole, which is the pair with a vendor prefix attached. The dots and the pulse are box-shadows, and a shadow paints outside the box — give them room, or an ancestor will clip the halo off.
Size it, then look at the contrast readout
Diameter runs 16px to 160px and the stroke is clamped to whatever still leaves a hole: half the diameter on a plain ring, a sixth of it on the dual ring, a third on the bars. Then switch the backdrop between the light panel and the dark overlay. The number on the right composites your accent over that backdrop and reports the ratio, because a spinner is a non-text component and WCAG 2.2 criterion 1.4.11 asks for 3:1 — the check that pale grey spinners on white loading screens have never passed.
Take the rule, the markup and a reduced-motion policy
Copy the CSS gives you the class, the @keyframes and, unless you switch it off, a prefers-reduced-motion block that either slows the turn to three times its length or holds the shape still. Copy the markup gives you the div with role=status, a polite live region and the accessible name, in either the clipped-span form or the aria-label form. Download writes the same stylesheet to a .css file named after your class, so it can go straight into a components folder.
Technical specifications
| Loaders | 8 — 2 driven by border, 1 by conic-gradient, 2 by mask and 3 by box-shadow, each a single element plus at most one ::after |
|---|---|
| Diameter | 16px to 160px in 2px steps; the bars render at 60% of that height |
| Stroke | 1px up to half the diameter minus 1 on the rings, a sixth of it on the dual ring and a third on the bars — clamped, with the reason stated |
| Period | 200ms to 5,000ms in 50ms steps; the dual ring's inner arc runs at 1.6× the outer so the pair never repeats a pose |
| Timing functions | 7 — linear, ease, ease-out, ease-in-out, Material's (0.4, 0, 0.2, 1), steps(8) and steps(12); the blade tick is locked to steps(12) |
| Markup emitted | One div with role="status" and aria-live="polite", naming itself either through a 1px clipped span or through aria-label |
| Vendor prefixes | -webkit-mask and -webkit-mask-composite: source-in written beside mask and mask-composite: intersect, for Safari before 15.4 and Chrome before 120 |
| Output | The class, the @keyframes and an optional prefers-reduced-motion block, as a clipboard copy or a .css download built in the tab — nothing is uploaded |
Frequently asked questions
Why is the middle dot of my three-dot loader invisible?
Because an outer box-shadow is never painted inside the border box of the element that casts it. A shadow at 0 0 with no spread sits exactly under the element and is clipped away in full, so the classic box-shadow: -20px 0, 0 0, 20px 0 draws two dots and not three. The fix is to let the element itself be the middle dot — give it the background color and animate that alongside the two shadows — or to add a spread large enough to reach past the edge. Both dot loaders here take the first route.
Does my spinner keep turning while the page is busy?
Only if it animates transform or opacity, which are the two the compositor can run on its own thread. That is the whole reason the ring, the dual ring, the conic sweep and the blade tick are all rotations: they survive a blocked main thread, which is the exact condition a loader exists to sit through. The dots, the pulse and the bars animate color, box-shadow and mask-size, all of which are repainted on the main thread — they look better and they will visibly stutter or stall while a long task runs. Pick a rotation for anything that covers a real network wait.
Do I still need -webkit-mask, or is the unprefixed property enough?
You still need it if your support matrix reaches back at all. Safari only accepted the unprefixed mask shorthand from 15.4 in March 2022 and Chrome from version 120 in December 2023, so a stylesheet shipping only the standard property silently loses the hole in the middle of the ring on anything older and shows a solid disc instead. Both lines are emitted here, prefix first, and the same applies to mask-composite, whose WebKit spelling is source-in where the standard word is intersect.
Why is the twelve-blade spinner locked to steps(12)?
Because a continuous rotation smears the twelve ticks into a flat grey ring. The blade shape comes from a repeating-conic-gradient with a 9° tick inside every 30° sector; rotating it smoothly moves each tick into the position of the next one and the eye integrates the whole thing into a solid annulus. steps(12) advances exactly one sector per step, so each frame lands a tick where a tick already was and only the brightness moves — which is the illusion the iOS and macOS spinners have always used.
My loader is a different size after I pasted it into another project. Why?
The stylesheet you pasted it into is using content-box sizing. A 48px ring with a 5px border measures 48px under border-box and 58px under content-box, because in the second case the border is added to the width rather than taken out of it. Tailwind, Bootstrap and most resets set border-box globally, so the difference only bites in a codebase that has no reset — which is why both border loaders here write box-sizing: border-box into the rule itself rather than relying on yours.
Should a loading spinner stop when someone asks for reduced motion?
The motion should go; the indicator must not. A spinner is not decoration — it is the only evidence the page gives that something is still happening, so deleting it under prefers-reduced-motion leaves a user staring at a screen with no signal at all. The two honest answers are to slow the cycle down until it is no longer a fast repeating rotation, or to keep the shape on screen with animation: none and let the live-region text carry the state. This generator writes whichever of the two you pick, and the shape stays visible in both.
How does a screen reader learn that loading has finished?
By the text inside the live region changing, not by the spinner disappearing. role="status" is a polite live region, so replacing “Loading…” with “Results ready” in that same node queues an announcement at the next pause; removing the whole element instead announces nothing, because a node that has left the document cannot notify anyone. Two habits that follow from that: render the status node before the wait starts rather than injecting it with the spinner, and put aria-busy on the region being replaced, not on the spinner itself.
About pure-CSS loaders
A loader is the one component where the interesting constraint is the markup budget: one element, because the spinner is usually injected into a slot that has no room for a wrapper and twelve children. That budget leaves four painting mechanisms, and each buys something the others cannot. A border gives you arcs, but only at the four 45° seams where adjacent borders meet — there is no way to get a 30° arc out of border-top-color, which is why every border spinner in the world is a quarter, a half or three quarters of a circle. A conic-gradient plus a radial mask gives you any arc you like and, more importantly, a tail that fades, which is the effect an SVG needs stroke-dasharray and a second animation to reach; the stop list is an ordinary gradient, so the CSS gradient generator and the gradient generator are where you go to pick the two ends. A box-shadow gives you unlimited copies of the element's silhouette for free, which is how three dots come out of one div — the syntax and the spread behavior are the same ones laid out on the box shadow generator. And a mask lets you animate geometry instead of color, which is what makes the bars shorten without the layout engine hearing about it. The circle itself, in every case, is the same 50% the border radius generator explains; a silhouette that is not a circle is a job for the clip path generator instead, with the caveat that clipping cuts the box-shadow copies off along with everything else.
Two failures show up again and again in generated loaders, and both are invisible in a demo. The first is the frozen spinner: a loader that animates background-color, box-shadow or mask-size is repainted by the main thread, so it stutters or stops dead during exactly the long task it was put there to cover, while a rotation of transform is handed to the compositor and keeps moving through a blocked thread. That is not an argument against the dot and bar styles — they are lovely on a fast, mostly-idle page — it is an argument for using a rotation whenever the wait is a real network round trip. The second is the silent spinner. A bare div with a class on it has no role and no name, so a screen reader reaching that point in the page finds nothing and reports nothing; the user is told neither that something is loading nor, later, that it finished. That is why the markup pane here is not optional decoration, and why a spinner sitting inside a pending button — the pattern the CSS button generator produces — needs the button's own disabled state and label change as well, not just the animation.
The color question is smaller but it fails audits more often than the other two. A spinner is a non-text user-interface component, so WCAG 2.2 criterion 1.4.11 asks for a 3:1 contrast ratio between the part that carries the meaning and what is behind it — and the default track color of almost every loader on the web, a light grey at low alpha, is nowhere near that. The distinction the criterion makes is useful here: the track does not have to pass, because it carries no information, but the moving arc does, because it is the only thing telling the user the page is alive. Anti-aliasing then eats into whatever margin is left, since a 2px arc on a high-density display is rendered at partial coverage along both edges and measures lighter than the value you set. The readout beside the preview composites your accent over the chosen backdrop before it measures, which is the only way the number means anything once alpha is involved.
Where the spinner is assembled
Every number on this page is worked out by JavaScript running in the tab you are reading it in. Nothing you type, paste or open is uploaded, logged or kept, which is also why the tools carry on working after you disconnect from the network.
Every spinner here is painted by a stylesheet the page injects into itself and rewrites on each control change — none of it survives the tab. The .css download is a Blob assembled from that same string and passed to your own browser rather than fetched, and the reduced-motion choice above the preview is forgotten the moment you navigate away.