ToolPal
Long exposure photo of light trails forming circular spinning patterns

CSS Loaders Without JavaScript — Six Pure-CSS Spinners and When to Reach for Each

📷 Pixabay / Pexels

CSS Loaders Without JavaScript — Six Pure-CSS Spinners and When to Reach for Each

Animated loading indicators in pure CSS. The six patterns I actually ship, the accessibility flags people forget, and the rule about when not to show a spinner at all.

DBy Daniel ParkApril 29, 202612 min read

The first thing I do when starting a new project is build the loading state. Not the layout, not the form, not the data fetch — the spinner that shows up when the data is on its way. This used to feel like procrastination, and maybe it still is, but it has a practical reason. By the time you have something to show the user, you usually need a placeholder for what is about to appear, and writing the placeholder first means you do not slap something together at three in the morning the night before a deploy.

The other reason: loaders are surprisingly hard to get right. There are seven or eight separate decisions to make — style, color, size, speed, accessibility, fallback, when to show it — and each one has a wrong answer. A bad loader actively hurts the experience. It makes a page feel slower than no spinner at all, it can clobber accessibility, and it can chew through frame budget on devices that can least afford it.

This guide is the loader rules I have collected over too many side projects and a few real ones. The companion piece is the CSS Loader Generator — six pure-CSS spinners with knobs for color, size, speed, and stroke thickness, with the HTML and CSS you can copy straight into a project.

Why CSS-only loaders win for almost everything

The first time you build a loader, the easy path is to grab a GIF off a free icon site or pull in a JavaScript library. Both feel reasonable. Both are usually wrong.

A 64-by-64-pixel loader GIF is around 20 KB if it is a small one, and 50–100 KB if anyone tried to make it look smooth at 30 frames per second. Multiply that across the loaders you would put on a typical SaaS dashboard — three on the navigation, one on each table row, one on every async button — and you have shipped a megabyte of dancing dots before the user has done anything.

A CSS spinner is a few hundred bytes, gets parsed once into your existing stylesheet, and runs on the GPU compositor for the rest of the page lifetime. It costs nothing after the initial parse. You can theme it through CSS variables and get free dark mode out of the box. You can resize it without it pixelating, because the browser draws it from math instead of from baked-in pixels.

The argument for a Lottie or SVG animation is that complex motion paths are nicer to author there. That is true. But ninety percent of loaders are some shape rotating, scaling, or fading on a timer, and CSS does that perfectly well in fewer characters than the Lottie boilerplate.

The six patterns I actually ship

After enough years of building these, I have noticed that I reach for the same handful over and over. The generator above gives you all six. Here is when each one earns its place.

The classic spinner (border with one colored arc)

This is the loader equivalent of a black t-shirt. You can put it anywhere, on any product, in any color, and it will not look out of place. The recipe is a circle with a border, where one side of the border is your brand color and the rest is a faint gray track. Rotate the whole circle on a 1-second timer. Done.

I use it for buttons, table cells, and the inline state on form fields after the user clicks Save. It is also my default for any case where I am not sure what I want — it is impossible to be wrong with the classic spinner, only boring, and the best loaders are the ones nobody notices.

The one trick: make the colored arc less than a full quarter of the circle. A spinner that fills half its circumference looks chunky and slow. Around 25 percent of the perimeter is the visual sweet spot.

The dual ring (two arcs spinning opposite directions)

This is the spinner you reach for when the classic feels too quiet. Two arcs, pointing in opposite directions, rotating together. It feels more deliberate than the classic, more focused. I use it for "this might take a while" operations — file uploads, exporting reports, anything that the user is going to stare at for more than two seconds.

The dual ring also reads better at smaller sizes than the classic, because the contrast between the two arcs makes the rotation visible even when the spinner is only 24 pixels wide.

Three-dot bounce

Friendly. Casual. Reads as "we are thinking" rather than "the system is busy." I use this in chat interfaces, AI assistants, and onboarding flows where I want the page to feel low-stakes.

It does not work everywhere. On a serious admin tool the three-dot bounce reads as flippant. On a checkout page it makes me wonder if I am about to lose my order. Match the tone to the context. The classic spinner is universal; the three-dot bounce has a personality.

Bars equalizer

Four or five vertical bars rising and falling like an equalizer. This is the loader I use specifically for media-related interfaces — audio players, video editors, anything that involves sound or rhythm. The visual metaphor sells it. It also works well for "syncing" or "indexing" operations because the back-and-forth motion implies progress through a list.

Pulse

A single circle that scales and fades on a timer. The most minimal loader. Use it when the rest of the UI is already busy and a multi-shape spinner would compete for attention. I have used it in dense data dashboards and in any case where the loader needs to disappear visually as soon as the content loads. Pulse loaders also tend to read more elegantly when they are colored to match a primary CTA — they look like a button breathing rather than a separate widget.

Ripple (concentric rings expanding outward)

The flashiest of the six, and the one I use the least. The ripple draws attention. That is the whole point. Use it when you want the user to look at the loader, which is honestly rarely. Splash screens, full-page route transitions, the moment after an account is created and before the dashboard loads. Anywhere else, it is too much.

Sizing them right is half the battle

A loader at the wrong size looks broken even when it works. The rules I follow:

  • Inside a button: 16 to 20 pixels. The spinner should sit comfortably alongside the button text without changing the button height. Replace the icon, do not stack the spinner on top of the icon.
  • Inline next to a form field or a row in a table: 24 to 32 pixels. Big enough to be unmistakable, small enough not to dominate the row.
  • As a page-level loader: 48 to 64 pixels. The user is looking at the page and the loader is the focal point. Anything smaller gets lost; anything bigger looks panicked.
  • Full-screen splash: 80 to 120 pixels. This is the only context where a giant spinner makes sense. Use it when the whole page is the loader and there is nothing else to see.

The mistake I see most often is a 64-pixel spinner inside a 32-pixel-tall button. It looks like the button has thrown up. Match the spinner to its container.

Speed: the most-undervalued setting

The speed of a spinner is the single biggest contributor to whether a page feels fast or broken, and almost nobody tunes it. The defaults from copy-pasted snippets are usually 0.6 or 0.8 seconds per rotation, which is too fast. Faster does not mean snappier. A spinner racing at 0.4 seconds per rotation reads as anxious — like the system is in trouble. A spinner crawling at 2 seconds per rotation reads as broken.

The sweet spot is between 0.9 and 1.2 seconds for one full cycle. That cadence reads as "working" without reading as "panicking." For loaders that are about to be replaced quickly, you can push it down to 0.7 seconds. For loaders the user will stare at for ten or twenty seconds, slow it down to 1.4 seconds — at that duration, anything faster becomes irritating.

The generator linked above defaults to 1 second, which is right for almost every case.

Accessibility, for real this time

Three things are non-negotiable.

One: announce the loader to assistive technology. Add role="status" to the loader element. This tells screen readers it is a live region, and they will announce changes in that region as they happen. Pair it with aria-label="Loading" (or something more specific like aria-label="Loading search results") so the screen reader has something to actually say.

Two: respect prefers-reduced-motion. A small but real percentage of users get nauseated by spinning shapes. The OS-level "reduce motion" setting reports through CSS, and you can use it like this:

@media (prefers-reduced-motion: no-preference) {
  .loader { animation: spin 1s linear infinite; }
}

@media (prefers-reduced-motion: reduce) {
  .loader::after {
    content: "Loading…";
    /* swap to a static fallback */
  }
}

Three: never use color alone to convey state. A spinner that is just colored differently from the surrounding UI does not communicate "loading" to a colorblind user. The motion is the signal. The color is decoration.

When not to show a spinner at all

This is the rule almost no one follows. Do not show a loading indicator for operations that complete in under 300 milliseconds. The flash of the spinner registers as a frame change, and your eye perceives the page as having jumped, which is actually slower than if you had shown nothing at all.

The fix is to debounce. Show the spinner only after a delay — usually 200 milliseconds. If the operation finishes before that, no spinner. If it goes over, the spinner appears.

let spinnerTimeout = setTimeout(() => showSpinner(), 200);
fetch('/api/data').then(() => {
  clearTimeout(spinnerTimeout);
  hideSpinner();
});

The other case where you should skip the spinner is when a skeleton screen would be more informative. Skeletons preview the structure of the content that is about to appear, which research shows reduces perceived wait time by 30 to 50 percent compared to a generic spinner. For predictable layouts — article lists, profile pages, dashboards — skeletons win. Spinners are best for unbounded waits where the result is unpredictable.

The CSS-variable trick that makes loaders themable

Here is the pattern that turns a snippet into a building block. Define your loader using CSS variables, then theme it from a single place:

.loader {
  --loader-color: hsl(240 80% 60%);
  --loader-size: 48px;
  --loader-speed: 1s;

  width: var(--loader-size);
  height: var(--loader-size);
  border: 4px solid color-mix(in srgb, var(--loader-color) 20%, transparent);
  border-top-color: var(--loader-color);
  border-radius: 50%;
  animation: spin var(--loader-speed) linear infinite;
}

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

Now you can change the color anywhere by overriding the variable:

.dark-mode .loader { --loader-color: hsl(180 80% 70%); }
.danger .loader { --loader-color: hsl(0 80% 60%); }
.compact .loader { --loader-size: 24px; }

This is the version of the loader I actually ship. It scales to a design system with no extra effort, dark mode comes for free if the rest of your design uses CSS variables, and you can A/B test variations by swapping a single value.

Common bugs and their fixes

A handful of issues show up over and over.

The spinner blurs or jitters on Safari. Add transform: translateZ(0) to force a compositor layer. This stabilizes subpixel rendering. It is hacky but it works.

The spinner shifts the layout when it appears. Reserve space with width and height on the container, not just on the spinner. Otherwise the surrounding content reflows when the spinner mounts.

The spinner is invisible on mobile Safari. Check whether you set display: inline-block somewhere up the tree — Safari sometimes loses dimensions on inline-block elements that wrap absolutely positioned children. Set display: inline-flex or display: block to fix it.

The spinner runs at the wrong speed in Firefox. Firefox respects the system "Reduce Motion" setting through prefers-reduced-motion, and if your CSS does not handle that case, your animation will run at 0 speed (i.e., not at all). Add the @media block above and the issue disappears.

The honest version

Most of the time, the spinner is not the most important thing on the page. The user did not load your site to see a beautiful loading animation. They loaded it to do something, and the loading state is the friction between them and what they came for.

The best loaders are the ones the user does not remember. They appear briefly, signal that work is in progress, and disappear without leaving an impression. The classic spinner — colored to match the brand, sized appropriately to the container, running at one second per rotation, with role="status" and a prefers-reduced-motion fallback — covers ninety percent of cases and ages well.

The generator link is at the top of the page. Pick a style, tune it for your context, paste the CSS, and move on. The work the loader is hiding is what you are actually being paid for.

Frequently Asked Questions

D

About the author

Daniel Park

Senior frontend engineer based in Seoul. Seven years of experience building web applications at Korean SaaS companies, with a focus on developer tooling, web performance, and privacy-first architecture. Open-source contributor to the JavaScript ecosystem and founder of ToolPal.

Learn more

Share this article

XLinkedIn

Related Posts