20 CSS Loading Buttons16 / 20

Pure CSSMIT licensed

Ghost Outline Loading Button

Border-only at rest, with the pending state carried by the border itself: a dash travels the perimeter while the request runs, so the outline is the indicator and nothing inside the button moves. No script — an SVG rect stroke does the drawing, and a checkbox stands in for the request so the whole state machine is CSS.

Published

Live Demo
Try it

The code

<div class="lb-16">
  <div class="lb-16__stage">
    <section class="lb-16__card" aria-labelledby="lb-16-h">
      <span class="lb-16__glyph" aria-hidden="true">
        <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M3.5 7.5h17v12h-17z"/><path d="M3.5 7.5 6 4.5h12l2.5 3M12 11v5M9.5 13.5 12 16l2.5-2.5"/></svg>
      </span>
      <h2 class="lb-16__h" id="lb-16-h">Nothing archived yet</h2>
      <p class="lb-16__sub">Older threads move here after 90 days. Loading the archive can take a moment.</p>

      <button class="lb-16__btn" type="button" data-state="idle" aria-busy="false">
        <svg class="lb-16__trace" aria-hidden="true" preserveAspectRatio="none" viewBox="0 0 200 48">
          <rect class="lb-16__rect" x="1" y="1" width="198" height="46" rx="23" pathLength="100" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke"/>
        </svg>
        <span class="lb-16__faces">
          <span class="lb-16__face lb-16__face--idle">Load archive</span>
          <span class="lb-16__face lb-16__face--pending">Loading archive</span>
        </span>
      </button>

      <div class="lb-16__control">
        <input class="lb-16__switch" type="checkbox" id="lb-16-switch">
        <label class="lb-16__lab" for="lb-16-switch">Hold the request open</label>
      </div>
    </section>
  </div>
</div>
.lb-16 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #f3f6fb;
  --surface: #ffffff;
  --ink: #14243a;
  --muted: #63799a;
  --rule: #dbe4f0;
  --accent: #1d54b8;
  font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  padding: 40px 16px;
}

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

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

.lb-16__card {
  inline-size: 100%;
  max-inline-size: 28rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 12px;
  padding: 32px 26px 26px;
  text-align: center;
}

.lb-16__glyph {
  display: inline-flex;
  color: var(--muted);
  margin-block-end: 14px;
}

.lb-16__h {
  margin: 0;
  font-size: 18px;
  font-weight: 640;
  letter-spacing: -.015em;
}

.lb-16__sub {
  margin: 8px auto 22px;
  font-size: 13px;
  line-height: 1.6;
  color: var(--muted);
  max-inline-size: 34ch;
}

.lb-16__btn {
  appearance: none;
  position: relative;
  border: 0;
  background: transparent;
  color: var(--accent);
  border-radius: 999px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 600;
  padding: 0 26px;
  min-block-size: 48px;
  min-inline-size: 11rem;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.lb-16__btn:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

.lb-16__trace {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  overflow: visible;
}

.lb-16__rect {
  stroke-dasharray: 100 0;
  stroke-dashoffset: 0;
  opacity: .55;
}

.lb-16__faces {
  display: grid;
  place-items: center;
}

.lb-16__face {
  grid-area: 1 / 1;
  white-space: nowrap;
  visibility: hidden;
}

.lb-16__face--idle {
  visibility: visible;
}

.lb-16:has(.lb-16__switch:checked) .lb-16__face--idle {
  visibility: hidden;
}

.lb-16:has(.lb-16__switch:checked) .lb-16__face--pending {
  visibility: visible;
}

.lb-16:has(.lb-16__switch:checked) .lb-16__rect {
  opacity: 1;
  stroke-dasharray: 22 78;
  animation: lb-16-trace 1.6s linear infinite;
}

@keyframes lb-16-trace {
  to {
    stroke-dashoffset: -100;
  }
}

.lb-16__control {
  display: flex;
  justify-content: center;
  margin-block-start: 20px;
}

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

.lb-16__switch {
  accent-color: var(--accent);
  inline-size: 16px;
  block-size: 16px;
  margin: 0;
}

.lb-16__switch:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .lb-16:has(.lb-16__switch:checked) .lb-16__rect {
    animation: none;
    stroke-dasharray: 50 50;
  }
}

@media (prefers-color-scheme: dark) {
  .lb-16 {
    --bg: #0d141d;
    --surface: #141d28;
    --ink: #e6edf6;
    --muted: #8ba0bb;
    --rule: #223040;
    --accent: #7fb0ff;
  }
}

[data-theme="dark"] .lb-16 {
  --bg: #0d141d;
  --surface: #141d28;
  --ink: #e6edf6;
  --muted: #8ba0bb;
  --rule: #223040;
  --accent: #7fb0ff;
}
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: Ghost Outline Loading Button
Source: https://codefronts.com/components/css-loading-buttons/ghost-outline-loading-button/

Border-only at rest, with the pending state carried by the border itself: a dash travels the perimeter while the request runs, so the outline is the indicator and nothing inside the button moves. No script — an SVG rect stroke does the drawing, and a checkbox stands in for the request so the whole state machine is CSS.
## HTML
```html
<div class="lb-16">
  <div class="lb-16__stage">
    <section class="lb-16__card" aria-labelledby="lb-16-h">
      <span class="lb-16__glyph" aria-hidden="true">
        <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M3.5 7.5h17v12h-17z"/><path d="M3.5 7.5 6 4.5h12l2.5 3M12 11v5M9.5 13.5 12 16l2.5-2.5"/></svg>
      </span>
      <h2 class="lb-16__h" id="lb-16-h">Nothing archived yet</h2>
      <p class="lb-16__sub">Older threads move here after 90 days. Loading the archive can take a moment.</p>

      <button class="lb-16__btn" type="button" data-state="idle" aria-busy="false">
        <svg class="lb-16__trace" aria-hidden="true" preserveAspectRatio="none" viewBox="0 0 200 48">
          <rect class="lb-16__rect" x="1" y="1" width="198" height="46" rx="23" pathLength="100" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke"/>
        </svg>
        <span class="lb-16__faces">
          <span class="lb-16__face lb-16__face--idle">Load archive</span>
          <span class="lb-16__face lb-16__face--pending">Loading archive</span>
        </span>
      </button>

      <div class="lb-16__control">
        <input class="lb-16__switch" type="checkbox" id="lb-16-switch">
        <label class="lb-16__lab" for="lb-16-switch">Hold the request open</label>
      </div>
    </section>
  </div>
</div>
```
## CSS
```css
.lb-16 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #f3f6fb;
  --surface: #ffffff;
  --ink: #14243a;
  --muted: #63799a;
  --rule: #dbe4f0;
  --accent: #1d54b8;
  font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  padding: 40px 16px;
}

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

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

.lb-16__card {
  inline-size: 100%;
  max-inline-size: 28rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 12px;
  padding: 32px 26px 26px;
  text-align: center;
}

.lb-16__glyph {
  display: inline-flex;
  color: var(--muted);
  margin-block-end: 14px;
}

.lb-16__h {
  margin: 0;
  font-size: 18px;
  font-weight: 640;
  letter-spacing: -.015em;
}

.lb-16__sub {
  margin: 8px auto 22px;
  font-size: 13px;
  line-height: 1.6;
  color: var(--muted);
  max-inline-size: 34ch;
}

.lb-16__btn {
  appearance: none;
  position: relative;
  border: 0;
  background: transparent;
  color: var(--accent);
  border-radius: 999px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 600;
  padding: 0 26px;
  min-block-size: 48px;
  min-inline-size: 11rem;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.lb-16__btn:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

.lb-16__trace {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  overflow: visible;
}

.lb-16__rect {
  stroke-dasharray: 100 0;
  stroke-dashoffset: 0;
  opacity: .55;
}

.lb-16__faces {
  display: grid;
  place-items: center;
}

.lb-16__face {
  grid-area: 1 / 1;
  white-space: nowrap;
  visibility: hidden;
}

.lb-16__face--idle {
  visibility: visible;
}

.lb-16:has(.lb-16__switch:checked) .lb-16__face--idle {
  visibility: hidden;
}

.lb-16:has(.lb-16__switch:checked) .lb-16__face--pending {
  visibility: visible;
}

.lb-16:has(.lb-16__switch:checked) .lb-16__rect {
  opacity: 1;
  stroke-dasharray: 22 78;
  animation: lb-16-trace 1.6s linear infinite;
}

@keyframes lb-16-trace {
  to {
    stroke-dashoffset: -100;
  }
}

.lb-16__control {
  display: flex;
  justify-content: center;
  margin-block-start: 20px;
}

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

.lb-16__switch {
  accent-color: var(--accent);
  inline-size: 16px;
  block-size: 16px;
  margin: 0;
}

.lb-16__switch:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .lb-16:has(.lb-16__switch:checked) .lb-16__rect {
    animation: none;
    stroke-dasharray: 50 50;
  }
}

@media (prefers-color-scheme: dark) {
  .lb-16 {
    --bg: #0d141d;
    --surface: #141d28;
    --ink: #e6edf6;
    --muted: #8ba0bb;
    --rule: #223040;
    --accent: #7fb0ff;
  }
}

[data-theme="dark"] .lb-16 {
  --bg: #0d141d;
  --surface: #141d28;
  --ink: #e6edf6;
  --muted: #8ba0bb;
  --rule: #223040;
  --accent: #7fb0ff;
}
```

How this works

The outline is the indicator. An absolutely positioned SVG fills the button and draws a single rounded rect with the same radius as the border. Give the rect pathLength="100" and its dash values become percentages of the perimeter regardless of the button's real size, so stroke-dasharray: 22 78 is always a dash covering just under a quarter of the way round.

Animating the offset moves the dash. One keyframe from stroke-dashoffset: 0 to -100 walks the dash all the way round and loops seamlessly, because 100 is the full normalised length. Add vector-effect: non-scaling-stroke and the trace keeps its 1.5 px weight whether the button is 120 px or 320 px wide — without it, the stroke thickens with the viewBox scale.

The state comes from a checkbox because the mechanism is the lesson. .lb-16:has(.lb-16__switch:checked) flips the button's faces and starts the trace, which keeps the demo dependency-free and shows that nothing here needs JS to animate. In production the same CSS is driven by data-state="pending" written by your request handler; the selector is the only line that changes.

The trap is what the checkbox cannot do. CSS can paint a pending state but it cannot set aria-busy, so a production build must still write that attribute from script — a purely CSS pending state is invisible to assistive tech. The second trap is a ghost button's contrast: a 1 px border at 40% opacity often fails the 3:1 requirement for a control boundary, so keep the outline at full strength and use the surface for subtlety.

Make it yours

  • Change stroke-dasharray to 40 60 for a longer trace, or 8 92 for a running dot.
  • Reverse the animation direction by animating stroke-dashoffset to 100 instead.
  • Speed the trace by lowering the animation duration; 1.6 s reads as calm, 700 ms as urgent.
  • Retint --accent to move the border, the label and the trace together.
  • Drop the radius on both the border and the rect for a sharp-cornered ghost button.
  • Swap the checkbox for [data-state="pending"] when you wire this to a real request.

Gotchas — read before shipping

  • Without pathLength the dash values depend on the button's real perimeter, so the trace length changes with the label.
  • Omitting vector-effect: non-scaling-stroke makes the stroke weight scale with the button and look inconsistent across sizes.
  • A CSS-only pending state cannot set aria-busy — production still needs that attribute written from script.
  • Ghost buttons commonly fail the 3:1 non-text contrast requirement when the border is faded; keep the outline at full strength.

Browser support

ChromeSafariFirefoxEdge
105+15.4+121+105+

:has() sets the floor because the checkbox drives the state — Chrome 105, Safari 15.4, Firefox 121. The SVG dash trace itself works far earlier, so wiring this to a data-state attribute instead of a checkbox drops the floor to Chrome 49.

Techniques used in this demo

Search CodeFronts

Loading…