GODRICH

9 CSS Loading Spinners: Copy-Paste, Single Element

A css loading spinner is a small shape — a ring, three dots, a bar — that loops in place to tell someone a page or action is still working before there

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine below run from the plainest shape to the most produced. 01 through 04 lean on nothing more than a border, a transform, or a background — the shapes most sites already reach for first. 05 through 07 pull in newer CSS: a conic-gradient, an SVG stroke, and an @property-registered custom value that can genuinely tween through a number. 08 and 09 are the idea-form entries, moving the spinner into a real button and into a soft AI-thinking orb. Watch the grid above cycle through all nine on its own — every css loading spinner animation below runs inside a fixed, non-scrollable iframe with nothing to click, so it just loops.

01Basic ring spinner

A single circular border spins in place with only its top edge colored differently, and that color difference is the entire animation — no absolute positioning, no pseudo-elements underneath it. Reach for it as the default full-page loading state or the waiting screen right after a button click, anywhere a spinner just needs to say the page is still working.

1초 회전border 4px단색
.spinner {
  width: clamp(48px, 14vw, 72px);
  height: clamp(48px, 14vw, 72px);
  border-radius: 50%;
  border: 6px solid rgba($subject-cream, .22);
  border-top-color: $color;
  animation: spin $duration $easing infinite;
}

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

02Dual counter-rotating ring

An outer ring and an inner ring spin in opposite directions at two different speeds, so the shape reads as layered rather than flat even though every piece is still a plain circular border. It suits modal loading states and dashboard cards pulling in fresh data, where one flat ring feels too thin against a card that already has visual depth.

역방향1.2초 · 0.9초두 겹
.spinner__ring--outer {
  border-top-color: $color;
  border-bottom-color: $color;
  animation: spin $duration-outer linear infinite;
}
.spinner__ring--inner {
  inset: 13px;
  border-left-color: $subject-blue;
  border-right-color: $subject-blue;
  animation: spin-rev $duration-inner linear infinite;
}

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

033-dot bounce

Three dots bounce upward one after another on a fixed delay, so the motion reads as a small wave passing through the row rather than three dots blinking at random. It is the shape people already read as a chat window waiting on a reply, and it fits a search box's autocomplete wait just as naturally.

지연 0.16초점 3개translateY
.dots span {
  width: clamp(12px, 4vw, 18px);
  height: clamp(12px, 4vw, 18px);
  border-radius: 50%;
  background: $color;
  animation: bounce $duration ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: $delay; }
.dots span:nth-child(3) { animation-delay: $delay * 2; }

@keyframes bounce {
  0%, 80%, 100% { transform: translateY(0); opacity: .5; }
  40%           { transform: translateY(-14px); opacity: 1; }
}

04Indeterminate bar

A short bar sweeps from one side of a thin track to the other, scaling itself down at both ends of the sweep so it never looks like it slams flat into the track's edge. Use it before a real percentage exists yet — the moment right before a file upload starts, or a slim progress line pinned across the top of a page.

가로 100%1.5초ease-in-out
.bar__fill {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: $color;
  transform-origin: left center;
  animation: sweep $duration $easing infinite;
}

@keyframes sweep {
  0%   { transform: translateX(-100%) scaleX(.3); }
  50%  { transform: translateX(20%) scaleX(.6); }
  100% { transform: translateX(100%) scaleX(.3); }
}

05Conic-gradient sweep

A single conic-gradient spins on its own, with a radial-gradient mask cut into the middle so the filled circle reads as a thin ring without any separate border or pseudo-element drawing it. Conic gradients now render across every modern browser, which makes this the shape to reach for on a loading screen that has to carry an exact brand color, like a checkout wait.

conic-gradientmask 처리그라디언트
.spinner {
  width: clamp(56px, 16vw, 84px);
  height: clamp(56px, 16vw, 84px);
  border-radius: 50%;
  background: conic-gradient($color 0deg 270deg, rgba($subject-cream, .18) 270deg 360deg);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
          mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
  animation: spin $duration $easing infinite;
}

06SVG stroke-dashoffset circle

An SVG circle animates only its stroke-dashoffset, so the colored arc appears to chase itself around the track while the whole element also spins slowly underneath it. It suits a wait icon that has no real percentage behind it yet, such as an app's splash screen before the first network call resolves.

stroke-dasharraySVG1.4초
.spinner__arc {
  stroke: $color;
  stroke-linecap: round;
  stroke-dasharray: 113;
  stroke-dashoffset: 90;
  animation: dash $duration-dash ease-in-out infinite;
}

@keyframes dash {
  0%   { stroke-dashoffset: 100; }
  50%  { stroke-dashoffset: 20; }
  100% { stroke-dashoffset: 100; }
}

07@property percent ring

A custom property named --percent is registered with @property as a plain number, so animating it from 0 to 100 tweens smoothly through every step instead of snapping straight from empty to full. Support for @property only reached Chrome 85, Safari 16.4, and Firefox 128, so ship a static fallback ring for anything older, and reach for this version wherever the number behind the ring is real, like upload progress or an onboarding completion meter.

@property0~100%conic-gradient
@property --percent {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

.ring {
  --percent: 0;
  background: conic-gradient($color calc(var(--percent) * 1%), rgba($subject-ink, .18) 0);
  animation: fill $duration $easing infinite;
}

@keyframes fill {
  0%   { --percent: 0; }
  50%  { --percent: 100; }
  100% { --percent: 0; }
}

08Inline button loading state

Clicking the button does not swap it for a new element — the label fades and slides down while a small ring fades in on the same grid cell, and the button's own width stays fixed the whole time so nothing shifts around it. Wire this into save buttons and checkout buttons, anywhere someone needs to see their own click acknowledged without the page jumping under the cursor.

버튼 폭 고정0.9초disabled 처리
.btn {
  &__label, &__spin { grid-area: 1 / 1; transition: opacity $duration, transform $duration; }
  &__spin {
    width: 20px;
    height: 20px;
    border-radius: 50%;
    border: 3px solid rgba($subject-cream, .35);
    border-top-color: $subject-cream;
    opacity: 0;
    transform: scale(.6);
    animation: spin $spin linear infinite;
  }
  &.is-loading &__label { opacity: 0; transform: translateY(6px); }
  &.is-loading &__spin  { opacity: 1; transform: scale(1); }
}

09AI-app style pulse orb

A blurred glow sits behind a soft gradient core, and both grow and shrink on a slow two-second pulse, timed just out of phase so the whole shape reads as breathing rather than mechanically ticking. Reach for it on an AI chatbot's response wait or a voice assistant's listening indicator, anywhere a plain ring would look too literal for what is really a black box thinking.

blur 그라디언트2초 펄스box-shadow 글로우
.orb::before {
  background: radial-gradient(circle, rgba($color, .55), transparent 70%);
  filter: blur(6px);
  animation: glow $duration $easing infinite;
}
.orb::after {
  background: radial-gradient(circle at 35% 35%, $subject-cream, $color 75%);
  animation: core $duration $easing infinite;
}

@keyframes core {
  0%, 100% { transform: scale(.88); opacity: .88; }
  50%      { transform: scale(1.08); opacity: 1; }
}

Where it breaks — the traps

Two of these nine lean on browser support in a way the rest don't, and both fail silently rather than throwing an error. Skip the @property block above item 07 and the browser treats --percent as an opaque string instead of a registered number, so the ring stops tweening through the fill entirely and just jumps from empty to full partway through the loop instead of counting up. Item 05 has a quieter version of the same problem: drop the -webkit-mask line and keep only the unprefixed mask, and Safari older than 15.4 paints the full conic-gradient disc with no hole cut into the middle, so what should read as a ring shows up as a solid wedge of color instead (MDN's mask compatibility table lists 15.4 as the first unprefixed version). Both fixes already sit inside the demos above, and the whole set — every fallback included — is worth grabbing from the archive rather than retyping nine keyframe blocks by hand; unzip it with qda5pgmf typed exactly as it appears on this line, no spaces added.

Accessibility

Every one of the nine respects prefers-reduced-motion, but each drops a different amount of motion rather than all going fully still. 01 through 04, 06, and 09 stop spinning, bouncing, or pulsing outright and settle on a static dimmed shape, since a loading indicator that no longer moves still communicates "wait" through its color and position alone. 05 simply freezes its rotation in place. 07 holds --percent at a fixed 62 rather than looping through zero, so the ring shows a plausible snapshot instead of an empty or full circle that might read as done or broken. 08's button keeps its ring visible but static the instant a real click starts loading, so the disabled state still reads clearly without any spin at all. MDN documents prefers-reduced-motion and the operating-system settings that flip it on.

@media (prefers-reduced-motion: reduce) {
  .spinner,
  .spinner__ring--outer,
  .spinner__ring--inner,
  .dots span,
  .bar__fill,
  .spinner__arc,
  .ring,
  .btn__spin,
  .orb::before,
  .orb::after {
    animation: none !important;
  }
}

The rest of the CSS collection lives under the CSS category hub, and what this site actually sells — and doesn't — is explained on the about page.

FAQ

Will these still work as a css loading spinner full screen overlay?

Yes — every shape here is a single element sized with clamp(), so wrapping it in a fixed, full-viewport container with a dim backdrop turns any of the nine into a full-screen overlay without changing the spinner's own markup or animation at all. Item 01's basic ring and item 05's conic sweep are the two that read cleanest at a larger size, since both are drawn from pure shape rather than several small parts.

Can I put a css loading spinner with text next to it?

Yes, and it is usually better for people using screen readers than the spinner alone. Add a sibling span with the wait message, keep the spinner itself marked aria-hidden the way each demo above already does, and use the parent's role="status" or aria-label attribute to carry the real label instead of hiding it inside decorative markup.

Do these need a build tool, a framework, or a spinner generator?

No — every pattern compiles from a handful of SCSS variables into plain CSS, and nothing here depends on a specific framework or a generator to produce it. Paste the vanilla SCSS into any SCSS playground to preview one pattern on its own, and the zip's react/ folder wraps each of the nine in its own component for projects that would rather import than copy classes by hand.

Enter the archive password

The password is inside this article. You will find it as you read.