Skip to content
HexSlate

CSS Loader Generator

HTML
<div class="loader" role="status" aria-label="Loading"></div>
CSS
.loader {
  width: 40px;
  height: 40px;
  border: 4px solid #00767933;
  border-top-color: #007679;
  border-radius: 50%;
  animation: hex-spin 1s linear infinite;
}

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

@media (prefers-reduced-motion: reduce) {
  .loader {
    animation: hex-fade 2s ease-in-out infinite;
  }
}

@keyframes hex-fade {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
1s per cycle0 child elementsreduced-motion guard included

The markup ships with the CSS

A spinner is one element and a border. A dots loader is three children with staggered animation delays, and no amount of CSS makes three dots out of an empty <div> unless you are willing to spend both pseudo-elements and still come up one short. So both boxes are the output here: the HTML and the stylesheet, together, because a generator that hands over only the rule has done the easy half.

The markup carries role="status" and a label. That is not decoration: without it a screen reader announces nothing at all while your page is loading, and the user is left with silence where a sighted user sees motion. role="status" is a polite live region, so it is read when the user is between other announcements rather than interrupting.

Reduced motion is generated, not offered

A continuously rotating spinner is one of the clearest triggers for vestibular disorders, which affect roughly 35% of adults over 40 in the United States at some point according to the NIH's own figures. Users who need it set prefers-reduced-motion: reduce at the operating-system level, and every export from this page honours it.

How it honours it is the part worth arguing about. The common approach is animation: none, which leaves a static ring on screen and tells the user nothing. This emits a 2-second opacity fade instead: no rotation, no translation, nothing that moves across the visual field, but still a clear "working on it". That is the spirit of the media query rather than the letter.

How each family is built

The spinner is a full border in the colour at 20% alpha with the top edge at full strength, rotated forever. One element, no pseudo-elements. Dots and bars stagger three children by a third and a sixth of the cycle, which is what makes them read as one wave rather than as three separate animations. Shimmer slides a gradient with background-position over a 200% background size, so it works at any width without a pseudo-element and without knowing the container.

Common problems

  • The spinner is a filled disc. The border is thicker than the radius. The thickness here is capped at half the size for that reason.
  • It jumps at the end of each loop. The keyframes do not close: the value at 100% has to equal the value at 0%. The bounce and stretch curves here both return to their starting transform for that reason.
  • The loader keeps announcing itself. A live region that stays in the DOM re-announces when its content changes. Remove the element when loading finishes rather than hiding it with display: none.
  • Everything else on the page stutters while it spins. Only transform and opacity animate on the compositor. A loader that animates width, top or border-width forces layout every frame. All five families here animate only compositor-safe properties, except shimmer, which animates a background position. That is cheap, but it repaints instead of compositing.

Frequently asked questions

When should I show a loader at all?

Above roughly one second. Below about 300ms a spinner that flashes and vanishes reads as a glitch and makes the interface feel less reliable, not more. The usual pattern is a delay before showing it and a minimum display time once shown, so it never blinks.

Spinner or skeleton?

A skeleton where you know the shape of what is coming, a spinner where you do not. A skeleton that matches the final layout removes the reflow when content arrives, which is the larger win: it is doing layout-stability work as well as progress work.

Can I use a CSS variable for the colour?

Yes. Replace the hex values with var(--brand). The only care needed is the spinner's faint track, which is written as an eight-digit hex with an alpha suffix; with a variable, use color-mix(in oklab, var(--brand) 20%, transparent) instead.

Does the preview run the same code I copy?

Yes, literally. The stylesheet in the box above is the generated one, scoped to the preview container. Nothing about the animation is reimplemented for display, which is the only way to be sure that what you see is what you paste.