CSS-Only Loading Spinners and Skeleton Shimmer Screens with @keyframes

A loading state is the first thing a user sees and the last thing most teams polish. The narrow problem this guide solves: render a smooth rotating spinner and a content-shaped skeleton shimmer using nothing but markup and @keyframes, keep both pinned to the compositor thread so they hold 60fps, and degrade to a calm static state when the visitor has asked the operating system to reduce motion. This page sits within the broader keyframe animation patterns guide, which covers the family of repeating animations these loaders belong to.

What you will build:

  • An indeterminate spinner that rotates an arc with transform only.
  • A skeleton block whose shimmer is a moving background-position, swept by @keyframes.
  • A prefers-reduced-motion path that strips all motion without breaking layout.
  • Accessible announcements so screen readers know the page is busy.

Why CSS Instead of a JavaScript Loader

A loading indicator runs while the main thread is busy fetching, parsing, and hydrating. That is precisely the moment a JavaScript-driven animation will jank, because the script driving it is competing for the same thread that is blocked on work. A CSS animation declared with @keyframes is handed to the browser's compositor and keeps ticking even when the main thread is saturated, which is the whole reason a CSS spinner stays smooth during a heavy page load while a requestAnimationFrame loop stalls.

The tradeoff is control. CSS cannot read fetch progress, so a CSS loader is inherently indeterminate — it communicates "something is happening", not "47% complete". For determinate progress bars you still need a sliver of script to set a width or a custom property. For the common case of "show motion until content arrives", CSS wins on smoothness, payload, and resilience. The accessibility cost is small but real: because CSS only paints, you must supply the semantics yourself with role="status" and an accessible name, and you must honour reduced-motion preferences explicitly rather than relying on a library default.


Complete Working Implementation

The following is self-contained. The spinner rotates a single element whose border forms an arc; the skeleton is a stack of blocks with a sweeping highlight. Both motion paths are gated behind prefers-reduced-motion: no-preference so the default render is static.

Spinner arc and skeleton shimmer Left: a circle with one quarter drawn as a coloured arc that rotates. Right: three grey bars with a diagonal highlight band sweeping left to right. Spinner (rotate) transform: rotate(360deg) Skeleton (shimmer) background-position sweep highlight moves left to right
<!-- Indeterminate spinner -->
<div class="spinner" role="status" aria-label="Loading"></div>

<!-- Skeleton placeholder for a text card -->
<div class="skeleton-card" role="status" aria-label="Loading content" aria-busy="true">
  <div class="skeleton skeleton--line"></div>
  <div class="skeleton skeleton--line"></div>
  <div class="skeleton skeleton--line skeleton--short"></div>
</div>
/* ---- Spinner: a ring with one coloured quarter, rotated ---- */
.spinner {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  /* Full ring is faint; the top border is the bright arc. */
  border: 4px solid color-mix(in srgb, currentColor 18%, transparent);
  border-top-color: #3b82f6;
  /* Only transform is animated, so this runs on the compositor. */
  animation: spinner-rotate 0.8s linear infinite;
}

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

/* ---- Skeleton: grey blocks with a sweeping highlight ---- */
.skeleton-card {
  display: grid;
  gap: 12px;
  max-width: 360px;
  padding: 16px;
}

.skeleton {
  height: 16px;
  border-radius: 6px;
  /* Base grey + a diagonal highlight band that we slide across. */
  background:
    linear-gradient(
      100deg,
      #e2e8f0 30%,
      #f1f5f9 50%,
      #e2e8f0 70%
    );
  /* The gradient is wider than the box so there is room to slide. */
  background-size: 200% 100%;
}

.skeleton--line { width: 100%; }
.skeleton--short { width: 60%; }

/* Default (no animation request) still gets a calm pulse-free sweep
   ONLY when motion is allowed. The static base above is the fallback. */
@media (prefers-reduced-motion: no-preference) {
  .skeleton {
    animation: skeleton-shimmer 1.4s ease-in-out infinite;
  }
}

@keyframes skeleton-shimmer {
  /* Slide the oversized gradient from right to left. */
  from { background-position: 100% 0; }
  to   { background-position: -100% 0; }
}

/* ---- Reduced-motion: freeze everything, keep the shapes ---- */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    /* Replace rotation with a static dashed ring so the state is
       still legible as "busy" without continuous motion. */
    animation: none;
    border-top-color: currentColor;
    border-style: dashed;
  }
  .skeleton {
    animation: none;
    background: #e2e8f0; /* flat grey, no sweep */
  }
}

The spinner never touches layout: the only animated declaration is transform: rotate(), and rotation is a compositor-only operation. The skeleton animates background-position, which paints but does not reflow, so a long list of skeleton rows stays cheap even with dozens on screen.


The Key Technique: An Oversized Gradient Sliding Behind a Fixed Box

The shimmer illusion has no moving DOM at all. The trick is background-size: 200% 100% — the gradient is painted twice as wide as the element. That extra width is the runway. @keyframes skeleton-shimmer then animates background-position from 100% 0 to -100% 0, dragging the bright middle stop across the visible window. Because the box itself never resizes and no child elements move, the browser only repaints the element's own pixels; there is no layout pass and no effect on neighbours. Building the highlight as a three-stop linear-gradient (dim → bright → dim) means the bright band has soft edges, which reads as a polished sweep rather than a hard bar.


Variation: Dark Mode and a Dots Loader

Skeletons must match the surface they sit on, so swap the gradient stops under a dark scheme, and offer a non-rotational loader for layouts where a spinning ring feels heavy.

@media (prefers-color-scheme: dark) {
  .skeleton {
    background: linear-gradient(
      100deg,
      #1e293b 30%,
      #334155 50%,
      #1e293b 70%
    );
    background-size: 200% 100%;
  }
}

/* Three pulsing dots: opacity-only, staggered with negative delay. */
.dots { display: inline-flex; gap: 6px; }
.dots span {
  width: 8px; height: 8px; border-radius: 50%;
  background: currentColor;
  animation: dot-pulse 1.2s ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: 0.2s; }
.dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes dot-pulse {
  0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); }
  40%           { opacity: 1;    transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  .dots span { animation: none; opacity: 0.6; }
}

The dots animate only opacity and transform, the two properties that stay on the compositor — the same discipline covered in the guide on optimizing CSS animations for 60fps.


Browser Support

Every property used here is broadly supported. @keyframes, transform, and multi-stop linear-gradient backgrounds work in all browsers since well before Chrome/Edge 80, Safari 13, and Firefox 75. prefers-reduced-motion has been honoured since Chrome 74, Safari 10.1, and Firefox 63, so no fallback is needed for the media query itself. The only modern syntax is color-mix() in the spinner's faint ring, supported in Chrome/Edge 111+, Safari 16.2+, and Firefox 113+; replace it with a plain rgba() value if you must support older engines via @supports (color: color-mix(in srgb, red, blue)).


FAQ

Why does my spinner stutter or drop frames? It is almost always because the keyframes animate a layout or paint property instead of transform. Rotate the element with transform: rotate() and the animation runs on the compositor, staying smooth at 60fps even while the main thread is busy fetching.

How do I make a skeleton shimmer respect reduced motion? Wrap the animated background sweep in a prefers-reduced-motion: no-preference media query, and provide a static dimmed placeholder as the default. Users who request reduced motion then see a calm grey block with no sweeping highlight.

Should I use a spinner or a skeleton screen? Use a skeleton when you know the rough shape of the incoming content and the wait is short, because it reduces perceived latency. Use a spinner for indeterminate or whole-page waits where the layout is unknown.

Do I need aria attributes on a CSS-only loader? Yes. CSS only draws pixels, so add role="status" and an accessible name (or aria-busy on the region) so assistive technology announces that content is loading.


Related articles

More pages in the same section.