20 CSS Loading Buttons17 / 20

Pure CSSMIT licensed

Icon Only Circular Loading Button

A 44px circular control where the icon rotates out and a ring rotates in. The important part is not the animation: an icon-only button has no visible label, so its accessible name must change with the state or a screen reader user has no idea anything happened. Each state ships its own button element with its own name, swapped by CSS.

Published

Live Demo
Try it

The code

<div class="lb-17">
  <div class="lb-17__stage">
    <section class="lb-17__player" aria-labelledby="lb-17-h">
      <div class="lb-17__meta">
        <p class="lb-17__kicker">Episode 41</p>
        <h2 class="lb-17__h" id="lb-17-h">Rewriting the sync engine</h2>
        <p class="lb-17__by">With Sam Rivera · 48 min</p>
      </div>

      <div class="lb-17__bar">
        <button class="lb-17__ic" type="button" aria-label="Download episode">
          <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 3.5v9M6 9l4 4 4-4M4 16.5h12"/></svg>
        </button>
        <button class="lb-17__ic" type="button" aria-label="Refresh feed">
          <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M16 10a6 6 0 1 1-1.9-4.4"/><path d="M16.2 3v3.2H13"/></svg>
        </button>

        <span class="lb-17__slot">
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--idle" type="button" aria-label="Save to bookmarks" aria-busy="false">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5.5 3.5h9v13l-4.5-3.4L5.5 16.5z"/></svg>
          </button>
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--pending" type="button" aria-label="Saving bookmark" aria-busy="true" disabled>
            <svg class="lb-17__ring" viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="10" cy="10" r="7"/></svg>
          </button>
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--done" type="button" aria-label="Saved to bookmarks, remove" aria-busy="false">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="currentColor" stroke="none" aria-hidden="true"><path d="M5.5 3.5h9v13l-4.5-3.4L5.5 16.5z"/></svg>
          </button>
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--error" type="button" aria-label="Saving failed, retry bookmark" aria-busy="false">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 3.6 17.6 16.6H2.4z"/><path d="M10 8.2v3.2"/><circle cx="10" cy="13.9" r=".9" fill="currentColor" stroke="none"/></svg>
          </button>
        </span>
      </div>

      <fieldset class="lb-17__states">
        <legend class="lb-17__legend">Bookmark state</legend>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="idle" checked> Idle</label>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="pending" class="lb-17__is-pending"> Pending</label>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="done" class="lb-17__is-done"> Saved</label>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="error" class="lb-17__is-error"> Failed</label>
      </fieldset>
    </section>
  </div>
</div>
.lb-17 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #eceef1;
  --surface: #ffffff;
  --ink: #1c2126;
  --muted: #6a7480;
  --rule: #dce0e6;
  --accent: #33414f;
  --ok: #1e7a54;
  --bad: #ad3626;
  font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  padding: 40px 16px;
}

.lb-17 *,
.lb-17 *::before,
.lb-17 *::after {
  box-sizing: border-box;
}

.lb-17__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.lb-17__player {
  inline-size: 100%;
  max-inline-size: 31rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 14px;
  padding: 20px;
  box-shadow: 0 10px 26px -22px rgba(28, 33, 38, .6);
}

.lb-17__kicker {
  margin: 0;
  font-size: 10.5px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

.lb-17__h {
  margin: 6px 0 0;
  font-size: 18px;
  font-weight: 620;
  letter-spacing: -.015em;
  min-inline-size: 0;
}

.lb-17__by {
  margin: 5px 0 0;
  font-size: 12px;
  color: var(--muted);
}

.lb-17__bar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: 18px 0 0;
  padding-block-start: 16px;
  border-block-start: 1px solid var(--rule);
  min-inline-size: 0;
}

.lb-17__ic {
  appearance: none;
  inline-size: 44px;
  block-size: 44px;
  flex: none;
  border-radius: 50%;
  border: 1px solid var(--rule);
  background: var(--surface);
  color: var(--muted);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: color 150ms ease, border-color 150ms ease, background 150ms ease;
}

.lb-17__ic:hover {
  color: var(--ink);
  border-color: var(--accent);
}

.lb-17__ic:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

.lb-17__slot {
  display: inline-flex;
}

.lb-17__st {
  display: none;
}

.lb-17:has(input[value="idle"]:checked) .lb-17__st--idle,
.lb-17:has(.lb-17__is-pending:checked) .lb-17__st--pending,
.lb-17:has(.lb-17__is-done:checked) .lb-17__st--done,
.lb-17:has(.lb-17__is-error:checked) .lb-17__st--error {
  display: grid;
}

.lb-17__st--pending {
  color: var(--accent);
  cursor: progress;
}

.lb-17__st--done {
  color: var(--ok);
  border-color: var(--ok);
  background: rgba(30, 122, 84, .08);
}

.lb-17__st--error {
  color: var(--bad);
  border-color: var(--bad);
}

.lb-17__ring {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  stroke-dasharray: 16 30;
  animation: lb-17-spin 760ms linear infinite;
}

@keyframes lb-17-spin {
  to {
    transform: rotate(1turn);
  }
}

.lb-17__states {
  margin: 18px 0 0;
  border: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  min-inline-size: 0;
}

.lb-17__legend {
  padding: 0;
  font-size: 10.5px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

.lb-17__radio {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  color: var(--muted);
  min-height: 44px;
  cursor: pointer;
}

.lb-17__radio input {
  accent-color: var(--accent);
  inline-size: 15px;
  block-size: 15px;
}

.lb-17__radio input:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .lb-17__ring {
    animation: none;
    stroke-dasharray: 23 23;
  }

  .lb-17__ic {
    transition: none;
  }
}

@media (prefers-color-scheme: dark) {
  .lb-17 {
    --bg: #15181c;
    --surface: #1d2126;
    --ink: #e8ebef;
    --muted: #939ca8;
    --rule: #2b3138;
    --accent: #b3c1d0;
    --ok: #4fbb8a;
    --bad: #ee8171;
  }

  .lb-17__st--done {
    background: rgba(79, 187, 138, .12);
  }
}

[data-theme="dark"] .lb-17 {
  --bg: #15181c;
  --surface: #1d2126;
  --ink: #e8ebef;
  --muted: #939ca8;
  --rule: #2b3138;
  --accent: #b3c1d0;
  --ok: #4fbb8a;
  --bad: #ee8171;
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Loading Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Icon Only Circular Loading Button
Source: https://codefronts.com/components/css-loading-buttons/icon-only-circular-loading-button/

A 44px circular control where the icon rotates out and a ring rotates in. The important part is not the animation: an icon-only button has no visible label, so its accessible name must change with the state or a screen reader user has no idea anything happened. Each state ships its own button element with its own name, swapped by CSS.
## HTML
```html
<div class="lb-17">
  <div class="lb-17__stage">
    <section class="lb-17__player" aria-labelledby="lb-17-h">
      <div class="lb-17__meta">
        <p class="lb-17__kicker">Episode 41</p>
        <h2 class="lb-17__h" id="lb-17-h">Rewriting the sync engine</h2>
        <p class="lb-17__by">With Sam Rivera · 48 min</p>
      </div>

      <div class="lb-17__bar">
        <button class="lb-17__ic" type="button" aria-label="Download episode">
          <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 3.5v9M6 9l4 4 4-4M4 16.5h12"/></svg>
        </button>
        <button class="lb-17__ic" type="button" aria-label="Refresh feed">
          <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M16 10a6 6 0 1 1-1.9-4.4"/><path d="M16.2 3v3.2H13"/></svg>
        </button>

        <span class="lb-17__slot">
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--idle" type="button" aria-label="Save to bookmarks" aria-busy="false">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5.5 3.5h9v13l-4.5-3.4L5.5 16.5z"/></svg>
          </button>
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--pending" type="button" aria-label="Saving bookmark" aria-busy="true" disabled>
            <svg class="lb-17__ring" viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="10" cy="10" r="7"/></svg>
          </button>
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--done" type="button" aria-label="Saved to bookmarks, remove" aria-busy="false">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="currentColor" stroke="none" aria-hidden="true"><path d="M5.5 3.5h9v13l-4.5-3.4L5.5 16.5z"/></svg>
          </button>
          <button class="lb-17__ic lb-17__ic--mark lb-17__st lb-17__st--error" type="button" aria-label="Saving failed, retry bookmark" aria-busy="false">
            <svg viewBox="0 0 20 20" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 3.6 17.6 16.6H2.4z"/><path d="M10 8.2v3.2"/><circle cx="10" cy="13.9" r=".9" fill="currentColor" stroke="none"/></svg>
          </button>
        </span>
      </div>

      <fieldset class="lb-17__states">
        <legend class="lb-17__legend">Bookmark state</legend>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="idle" checked> Idle</label>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="pending" class="lb-17__is-pending"> Pending</label>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="done" class="lb-17__is-done"> Saved</label>
        <label class="lb-17__radio"><input type="radio" name="lb-17-state" value="error" class="lb-17__is-error"> Failed</label>
      </fieldset>
    </section>
  </div>
</div>
```
## CSS
```css
.lb-17 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #eceef1;
  --surface: #ffffff;
  --ink: #1c2126;
  --muted: #6a7480;
  --rule: #dce0e6;
  --accent: #33414f;
  --ok: #1e7a54;
  --bad: #ad3626;
  font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  padding: 40px 16px;
}

.lb-17 *,
.lb-17 *::before,
.lb-17 *::after {
  box-sizing: border-box;
}

.lb-17__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.lb-17__player {
  inline-size: 100%;
  max-inline-size: 31rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 14px;
  padding: 20px;
  box-shadow: 0 10px 26px -22px rgba(28, 33, 38, .6);
}

.lb-17__kicker {
  margin: 0;
  font-size: 10.5px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

.lb-17__h {
  margin: 6px 0 0;
  font-size: 18px;
  font-weight: 620;
  letter-spacing: -.015em;
  min-inline-size: 0;
}

.lb-17__by {
  margin: 5px 0 0;
  font-size: 12px;
  color: var(--muted);
}

.lb-17__bar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: 18px 0 0;
  padding-block-start: 16px;
  border-block-start: 1px solid var(--rule);
  min-inline-size: 0;
}

.lb-17__ic {
  appearance: none;
  inline-size: 44px;
  block-size: 44px;
  flex: none;
  border-radius: 50%;
  border: 1px solid var(--rule);
  background: var(--surface);
  color: var(--muted);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: color 150ms ease, border-color 150ms ease, background 150ms ease;
}

.lb-17__ic:hover {
  color: var(--ink);
  border-color: var(--accent);
}

.lb-17__ic:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

.lb-17__slot {
  display: inline-flex;
}

.lb-17__st {
  display: none;
}

.lb-17:has(input[value="idle"]:checked) .lb-17__st--idle,
.lb-17:has(.lb-17__is-pending:checked) .lb-17__st--pending,
.lb-17:has(.lb-17__is-done:checked) .lb-17__st--done,
.lb-17:has(.lb-17__is-error:checked) .lb-17__st--error {
  display: grid;
}

.lb-17__st--pending {
  color: var(--accent);
  cursor: progress;
}

.lb-17__st--done {
  color: var(--ok);
  border-color: var(--ok);
  background: rgba(30, 122, 84, .08);
}

.lb-17__st--error {
  color: var(--bad);
  border-color: var(--bad);
}

.lb-17__ring {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  stroke-dasharray: 16 30;
  animation: lb-17-spin 760ms linear infinite;
}

@keyframes lb-17-spin {
  to {
    transform: rotate(1turn);
  }
}

.lb-17__states {
  margin: 18px 0 0;
  border: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  min-inline-size: 0;
}

.lb-17__legend {
  padding: 0;
  font-size: 10.5px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
}

.lb-17__radio {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  color: var(--muted);
  min-height: 44px;
  cursor: pointer;
}

.lb-17__radio input {
  accent-color: var(--accent);
  inline-size: 15px;
  block-size: 15px;
}

.lb-17__radio input:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .lb-17__ring {
    animation: none;
    stroke-dasharray: 23 23;
  }

  .lb-17__ic {
    transition: none;
  }
}

@media (prefers-color-scheme: dark) {
  .lb-17 {
    --bg: #15181c;
    --surface: #1d2126;
    --ink: #e8ebef;
    --muted: #939ca8;
    --rule: #2b3138;
    --accent: #b3c1d0;
    --ok: #4fbb8a;
    --bad: #ee8171;
  }

  .lb-17__st--done {
    background: rgba(79, 187, 138, .12);
  }
}

[data-theme="dark"] .lb-17 {
  --bg: #15181c;
  --surface: #1d2126;
  --ink: #e8ebef;
  --muted: #939ca8;
  --rule: #2b3138;
  --accent: #b3c1d0;
  --ok: #4fbb8a;
  --bad: #ee8171;
}
```

How this works

An icon-only button's name is its whole label. Swap a bookmark glyph for a spinner and a sighted user sees the change while a screen reader user hears the same stale "Save to bookmarks" — because aria-label did not move. The state has to be in the name: "Saving bookmark", "Saved to bookmarks", "Saving failed, retry".

Without script, that means one button per state. CSS cannot rewrite an attribute, so each state ships its own <button> with the right aria-label, aria-busy and disabled, and the stylesheet shows exactly one. Hidden states use display: none, which removes them from the accessibility tree entirely, so there is never more than one bookmark control present.

44px is the target, not the icon. The circle is 44px × 44px with a 17px glyph centred by display: grid; place-items: center. Do not build the target from padding around a small icon and hope — set the box explicitly, because WCAG 2.2 target size is measured on the control, and a 24px button with a big hit area is still a 24px button to a pointer test.

The trap is a ring that spins about the wrong point. An SVG spinner rotates around the element's box origin unless you set transform-box: fill-box and transform-origin: 50% 50%, so a stroked circle inside a viewBox wobbles instead of spinning. In a script-driven build, remember the counterpart trap: changing the glyph but not the name, which is the exact bug this demo exists to prevent.

Make it yours

  • Change the ring's stroke-dasharray to make the spinner arc longer or shorter.
  • Swap the bookmark glyph for download or refresh and rewrite all four accessible names to match.
  • Raise the button to 48px if the toolbar sits on a touch-first surface.
  • Retint --accent to move the active state and the focus ring together.
  • Replace the radio group with data-state on a single button once script is available.
  • Add a tooltip on hover, but keep the accessible name authoritative — a tooltip is not a name.

Gotchas — read before shipping

  • Changing the glyph without changing aria-label leaves screen reader users with no feedback at all.
  • Hiding a state with visibility: hidden rather than display: none leaves two bookmark buttons in the accessibility tree.
  • An SVG spinner without transform-box: fill-box orbits its box origin instead of spinning in place.
  • Building the 44px target out of padding around a 20px button fails a pointer-target audit even though it feels fine with a mouse.

Browser support

ChromeSafariFirefoxEdge
105+15.4+121+105+

:has() sets the floor because the radio group drives which button is shown — Chrome 105, Safari 15.4, Firefox 121. transform-box has been supported far longer, so a script-driven version of this demo runs on much older engines.

Techniques used in this demo

Search CodeFronts

Loading…