20 CSS Loading Buttons01 / 20
Button with Inline Spinner Swap
The reference build the rest of the collection reuses: the label crossfades into a spinner that sits exactly where the text was, both stacked in one grid cell so nothing reflows. One data-state attribute on the button carries idle, pending, done and error, and attribute selectors do all the styling. Reach for it whenever a click starts work the user has to wait for.
Published
The code
<div class="lb-01">
<div class="lb-01__stage">
<section class="lb-01__card" aria-labelledby="lb-01-h">
<header class="lb-01__head">
<p class="lb-01__eyebrow">Account</p>
<h2 class="lb-01__h" id="lb-01-h">Profile</h2>
</header>
<div class="lb-01__row">
<div class="lb-01__field">
<span class="lb-01__flabel">Display name</span>
<span class="lb-01__fvalue">Alex Chen</span>
</div>
<div class="lb-01__field">
<span class="lb-01__flabel">Role</span>
<span class="lb-01__fvalue">Engineering, Platform</span>
</div>
</div>
<footer class="lb-01__foot">
<div class="lb-01__mode" role="group" aria-label="Simulated response">
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="ok" checked> Succeeds</label>
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="fail"> Fails</label>
</div>
<button class="lb-01__btn" type="button" id="lb-01-btn" data-state="idle" aria-busy="false">
<span class="lb-01__faces">
<span class="lb-01__face lb-01__face--idle">Save changes</span>
<span class="lb-01__face lb-01__face--pending">
<svg class="lb-01__spin" viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="10" cy="10" r="7.5" stroke-opacity=".28"/><path d="M17.5 10A7.5 7.5 0 0 0 10 2.5"/></svg>
Saving
</span>
<span class="lb-01__face lb-01__face--done">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M4.5 10.5l3.5 3.5 7.5-8"/></svg>
Saved
</span>
<span class="lb-01__face lb-01__face--error">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M10 5.5v5.5"/><circle cx="10" cy="14.6" r=".9" fill="currentColor" stroke="none"/></svg>
Retry
</span>
</span>
</button>
</footer>
<p class="lb-01__status" id="lb-01-live" role="status" aria-live="polite">Ready</p>
</section>
</div>
</div><div class="lb-01">
<div class="lb-01__stage">
<section class="lb-01__card" aria-labelledby="lb-01-h">
<header class="lb-01__head">
<p class="lb-01__eyebrow">Account</p>
<h2 class="lb-01__h" id="lb-01-h">Profile</h2>
</header>
<div class="lb-01__row">
<div class="lb-01__field">
<span class="lb-01__flabel">Display name</span>
<span class="lb-01__fvalue">Alex Chen</span>
</div>
<div class="lb-01__field">
<span class="lb-01__flabel">Role</span>
<span class="lb-01__fvalue">Engineering, Platform</span>
</div>
</div>
<footer class="lb-01__foot">
<div class="lb-01__mode" role="group" aria-label="Simulated response">
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="ok" checked> Succeeds</label>
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="fail"> Fails</label>
</div>
<button class="lb-01__btn" type="button" id="lb-01-btn" data-state="idle" aria-busy="false">
<span class="lb-01__faces">
<span class="lb-01__face lb-01__face--idle">Save changes</span>
<span class="lb-01__face lb-01__face--pending">
<svg class="lb-01__spin" viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="10" cy="10" r="7.5" stroke-opacity=".28"/><path d="M17.5 10A7.5 7.5 0 0 0 10 2.5"/></svg>
Saving
</span>
<span class="lb-01__face lb-01__face--done">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M4.5 10.5l3.5 3.5 7.5-8"/></svg>
Saved
</span>
<span class="lb-01__face lb-01__face--error">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M10 5.5v5.5"/><circle cx="10" cy="14.6" r=".9" fill="currentColor" stroke="none"/></svg>
Retry
</span>
</span>
</button>
</footer>
<p class="lb-01__status" id="lb-01-live" role="status" aria-live="polite">Ready</p>
</section>
</div>
</div>.lb-01 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #17161a;
--muted: #6e6c74;
--rule: #e5e3dc;
--accent: #17161a;
--on-accent: #ffffff;
--ok: #1c7a55;
--bad: #b4342a;
--dur: 220ms;
--hold: 1500ms;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--paper);
color: var(--ink);
padding: 40px 18px;
}
.lb-01 *,
.lb-01 *::before,
.lb-01 *::after {
box-sizing: border-box;
}
.lb-01__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-01__card {
inline-size: 100%;
max-inline-size: 30rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 22px 22px 18px;
}
.lb-01__eyebrow {
margin: 0;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.lb-01__h {
margin: 4px 0 0;
font-size: 20px;
font-weight: 640;
letter-spacing: -.015em;
}
.lb-01__row {
display: grid;
gap: 14px;
margin: 18px 0;
padding: 16px 0;
border-block: 1px solid var(--rule);
}
.lb-01__field {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__flabel {
font-size: 12.5px;
color: var(--muted);
min-inline-size: 0;
}
.lb-01__fvalue {
font-size: 13px;
font-weight: 560;
min-inline-size: 0;
}
.lb-01__foot {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__mode {
display: flex;
gap: 12px;
flex-wrap: wrap;
min-inline-size: 0;
}
.lb-01__radio {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-01__radio input {
accent-color: var(--accent);
inline-size: 15px;
block-size: 15px;
}
.lb-01__btn {
appearance: none;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--on-accent);
border-radius: 6px;
font: inherit;
font-size: 13.5px;
font-weight: 560;
padding: 0 18px;
min-block-size: 44px;
min-inline-size: 0;
cursor: pointer;
transition: background var(--dur) ease, border-color var(--dur) ease, transform 120ms ease;
}
.lb-01__btn:active {
transform: translateY(1px);
}
.lb-01__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.lb-01__btn[data-state="pending"] {
cursor: progress;
}
.lb-01__btn[data-state="done"] {
background: var(--ok);
border-color: var(--ok);
}
.lb-01__btn[data-state="error"] {
background: var(--bad);
border-color: var(--bad);
}
.lb-01__faces {
display: grid;
place-items: center;
}
.lb-01__face {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transform: scale(.92);
transition: opacity var(--dur) ease, transform var(--dur) ease, visibility var(--dur);
}
.lb-01__btn[data-state="idle"] .lb-01__face--idle,
.lb-01__btn[data-state="pending"] .lb-01__face--pending,
.lb-01__btn[data-state="done"] .lb-01__face--done,
.lb-01__btn[data-state="error"] .lb-01__face--error {
opacity: 1;
visibility: visible;
transform: none;
}
.lb-01__spin {
animation: lb-01-spin 720ms linear infinite;
}
@keyframes lb-01-spin {
to {
transform: rotate(1turn);
}
}
.lb-01__status {
margin: 14px 0 0;
font-size: 12px;
color: var(--muted);
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-01__spin {
animation: none;
}
.lb-01__face {
transition: none;
}
}
@media (prefers-color-scheme: dark) {
.lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}
}
[data-theme="dark"] .lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}.lb-01 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #17161a;
--muted: #6e6c74;
--rule: #e5e3dc;
--accent: #17161a;
--on-accent: #ffffff;
--ok: #1c7a55;
--bad: #b4342a;
--dur: 220ms;
--hold: 1500ms;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--paper);
color: var(--ink);
padding: 40px 18px;
}
.lb-01 *,
.lb-01 *::before,
.lb-01 *::after {
box-sizing: border-box;
}
.lb-01__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-01__card {
inline-size: 100%;
max-inline-size: 30rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 22px 22px 18px;
}
.lb-01__eyebrow {
margin: 0;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.lb-01__h {
margin: 4px 0 0;
font-size: 20px;
font-weight: 640;
letter-spacing: -.015em;
}
.lb-01__row {
display: grid;
gap: 14px;
margin: 18px 0;
padding: 16px 0;
border-block: 1px solid var(--rule);
}
.lb-01__field {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__flabel {
font-size: 12.5px;
color: var(--muted);
min-inline-size: 0;
}
.lb-01__fvalue {
font-size: 13px;
font-weight: 560;
min-inline-size: 0;
}
.lb-01__foot {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__mode {
display: flex;
gap: 12px;
flex-wrap: wrap;
min-inline-size: 0;
}
.lb-01__radio {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-01__radio input {
accent-color: var(--accent);
inline-size: 15px;
block-size: 15px;
}
.lb-01__btn {
appearance: none;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--on-accent);
border-radius: 6px;
font: inherit;
font-size: 13.5px;
font-weight: 560;
padding: 0 18px;
min-block-size: 44px;
min-inline-size: 0;
cursor: pointer;
transition: background var(--dur) ease, border-color var(--dur) ease, transform 120ms ease;
}
.lb-01__btn:active {
transform: translateY(1px);
}
.lb-01__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.lb-01__btn[data-state="pending"] {
cursor: progress;
}
.lb-01__btn[data-state="done"] {
background: var(--ok);
border-color: var(--ok);
}
.lb-01__btn[data-state="error"] {
background: var(--bad);
border-color: var(--bad);
}
.lb-01__faces {
display: grid;
place-items: center;
}
.lb-01__face {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transform: scale(.92);
transition: opacity var(--dur) ease, transform var(--dur) ease, visibility var(--dur);
}
.lb-01__btn[data-state="idle"] .lb-01__face--idle,
.lb-01__btn[data-state="pending"] .lb-01__face--pending,
.lb-01__btn[data-state="done"] .lb-01__face--done,
.lb-01__btn[data-state="error"] .lb-01__face--error {
opacity: 1;
visibility: visible;
transform: none;
}
.lb-01__spin {
animation: lb-01-spin 720ms linear infinite;
}
@keyframes lb-01-spin {
to {
transform: rotate(1turn);
}
}
.lb-01__status {
margin: 14px 0 0;
font-size: 12px;
color: var(--muted);
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-01__spin {
animation: none;
}
.lb-01__face {
transition: none;
}
}
@media (prefers-color-scheme: dark) {
.lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}
}
[data-theme="dark"] .lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}const btn = document.getElementById('lb-01-btn');
const live = document.getElementById('lb-01-live');
let timer;
const set = (state, message) => {
btn.dataset.state = state;
btn.setAttribute('aria-busy', String(state === 'pending'));
live.textContent = message;
};
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearTimeout(timer);
set('pending', 'Saving profile changes');
const fails = document.querySelector('input[name="lb-01-mode"]:checked').value === 'fail';
timer = setTimeout(() => {
if (fails) {
set('error', 'Could not save. Press the button to retry.');
} else {
set('done', 'Profile saved');
}
timer = setTimeout(() => set('idle', 'Ready'), 1500);
}, 1400);
});const btn = document.getElementById('lb-01-btn');
const live = document.getElementById('lb-01-live');
let timer;
const set = (state, message) => {
btn.dataset.state = state;
btn.setAttribute('aria-busy', String(state === 'pending'));
live.textContent = message;
};
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearTimeout(timer);
set('pending', 'Saving profile changes');
const fails = document.querySelector('input[name="lb-01-mode"]:checked').value === 'fail';
timer = setTimeout(() => {
if (fails) {
set('error', 'Could not save. Press the button to retry.');
} else {
set('done', 'Profile saved');
}
timer = setTimeout(() => set('idle', 'Ready'), 1500);
}, 1400);
});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: Button with Inline Spinner Swap
Source: https://codefronts.com/components/css-loading-buttons/button-with-inline-spinner-swap/
The reference build the rest of the collection reuses: the label crossfades into a spinner that sits exactly where the text was, both stacked in one grid cell so nothing reflows. One data-state attribute on the button carries idle, pending, done and error, and attribute selectors do all the styling. Reach for it whenever a click starts work the user has to wait for.
## HTML
```html
<div class="lb-01">
<div class="lb-01__stage">
<section class="lb-01__card" aria-labelledby="lb-01-h">
<header class="lb-01__head">
<p class="lb-01__eyebrow">Account</p>
<h2 class="lb-01__h" id="lb-01-h">Profile</h2>
</header>
<div class="lb-01__row">
<div class="lb-01__field">
<span class="lb-01__flabel">Display name</span>
<span class="lb-01__fvalue">Alex Chen</span>
</div>
<div class="lb-01__field">
<span class="lb-01__flabel">Role</span>
<span class="lb-01__fvalue">Engineering, Platform</span>
</div>
</div>
<footer class="lb-01__foot">
<div class="lb-01__mode" role="group" aria-label="Simulated response">
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="ok" checked> Succeeds</label>
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="fail"> Fails</label>
</div>
<button class="lb-01__btn" type="button" id="lb-01-btn" data-state="idle" aria-busy="false">
<span class="lb-01__faces">
<span class="lb-01__face lb-01__face--idle">Save changes</span>
<span class="lb-01__face lb-01__face--pending">
<svg class="lb-01__spin" viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="10" cy="10" r="7.5" stroke-opacity=".28"/><path d="M17.5 10A7.5 7.5 0 0 0 10 2.5"/></svg>
Saving
</span>
<span class="lb-01__face lb-01__face--done">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M4.5 10.5l3.5 3.5 7.5-8"/></svg>
Saved
</span>
<span class="lb-01__face lb-01__face--error">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M10 5.5v5.5"/><circle cx="10" cy="14.6" r=".9" fill="currentColor" stroke="none"/></svg>
Retry
</span>
</span>
</button>
</footer>
<p class="lb-01__status" id="lb-01-live" role="status" aria-live="polite">Ready</p>
</section>
</div>
</div>
```
## CSS
```css
.lb-01 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #17161a;
--muted: #6e6c74;
--rule: #e5e3dc;
--accent: #17161a;
--on-accent: #ffffff;
--ok: #1c7a55;
--bad: #b4342a;
--dur: 220ms;
--hold: 1500ms;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--paper);
color: var(--ink);
padding: 40px 18px;
}
.lb-01 *,
.lb-01 *::before,
.lb-01 *::after {
box-sizing: border-box;
}
.lb-01__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-01__card {
inline-size: 100%;
max-inline-size: 30rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 22px 22px 18px;
}
.lb-01__eyebrow {
margin: 0;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.lb-01__h {
margin: 4px 0 0;
font-size: 20px;
font-weight: 640;
letter-spacing: -.015em;
}
.lb-01__row {
display: grid;
gap: 14px;
margin: 18px 0;
padding: 16px 0;
border-block: 1px solid var(--rule);
}
.lb-01__field {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__flabel {
font-size: 12.5px;
color: var(--muted);
min-inline-size: 0;
}
.lb-01__fvalue {
font-size: 13px;
font-weight: 560;
min-inline-size: 0;
}
.lb-01__foot {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__mode {
display: flex;
gap: 12px;
flex-wrap: wrap;
min-inline-size: 0;
}
.lb-01__radio {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-01__radio input {
accent-color: var(--accent);
inline-size: 15px;
block-size: 15px;
}
.lb-01__btn {
appearance: none;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--on-accent);
border-radius: 6px;
font: inherit;
font-size: 13.5px;
font-weight: 560;
padding: 0 18px;
min-block-size: 44px;
min-inline-size: 0;
cursor: pointer;
transition: background var(--dur) ease, border-color var(--dur) ease, transform 120ms ease;
}
.lb-01__btn:active {
transform: translateY(1px);
}
.lb-01__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.lb-01__btn[data-state="pending"] {
cursor: progress;
}
.lb-01__btn[data-state="done"] {
background: var(--ok);
border-color: var(--ok);
}
.lb-01__btn[data-state="error"] {
background: var(--bad);
border-color: var(--bad);
}
.lb-01__faces {
display: grid;
place-items: center;
}
.lb-01__face {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transform: scale(.92);
transition: opacity var(--dur) ease, transform var(--dur) ease, visibility var(--dur);
}
.lb-01__btn[data-state="idle"] .lb-01__face--idle,
.lb-01__btn[data-state="pending"] .lb-01__face--pending,
.lb-01__btn[data-state="done"] .lb-01__face--done,
.lb-01__btn[data-state="error"] .lb-01__face--error {
opacity: 1;
visibility: visible;
transform: none;
}
.lb-01__spin {
animation: lb-01-spin 720ms linear infinite;
}
@keyframes lb-01-spin {
to {
transform: rotate(1turn);
}
}
.lb-01__status {
margin: 14px 0 0;
font-size: 12px;
color: var(--muted);
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-01__spin {
animation: none;
}
.lb-01__face {
transition: none;
}
}
@media (prefers-color-scheme: dark) {
.lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}
}
[data-theme="dark"] .lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}
```
## JavaScript
```js
const btn = document.getElementById('lb-01-btn');
const live = document.getElementById('lb-01-live');
let timer;
const set = (state, message) => {
btn.dataset.state = state;
btn.setAttribute('aria-busy', String(state === 'pending'));
live.textContent = message;
};
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearTimeout(timer);
set('pending', 'Saving profile changes');
const fails = document.querySelector('input[name="lb-01-mode"]:checked').value === 'fail';
timer = setTimeout(() => {
if (fails) {
set('error', 'Could not save. Press the button to retry.');
} else {
set('done', 'Profile saved');
}
timer = setTimeout(() => set('idle', 'Ready'), 1500);
}, 1400);
});
```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: Button with Inline Spinner Swap
Source: https://codefronts.com/components/css-loading-buttons/button-with-inline-spinner-swap/
The reference build the rest of the collection reuses: the label crossfades into a spinner that sits exactly where the text was, both stacked in one grid cell so nothing reflows. One data-state attribute on the button carries idle, pending, done and error, and attribute selectors do all the styling. Reach for it whenever a click starts work the user has to wait for.
## HTML
```html
<div class="lb-01">
<div class="lb-01__stage">
<section class="lb-01__card" aria-labelledby="lb-01-h">
<header class="lb-01__head">
<p class="lb-01__eyebrow">Account</p>
<h2 class="lb-01__h" id="lb-01-h">Profile</h2>
</header>
<div class="lb-01__row">
<div class="lb-01__field">
<span class="lb-01__flabel">Display name</span>
<span class="lb-01__fvalue">Alex Chen</span>
</div>
<div class="lb-01__field">
<span class="lb-01__flabel">Role</span>
<span class="lb-01__fvalue">Engineering, Platform</span>
</div>
</div>
<footer class="lb-01__foot">
<div class="lb-01__mode" role="group" aria-label="Simulated response">
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="ok" checked> Succeeds</label>
<label class="lb-01__radio"><input type="radio" name="lb-01-mode" value="fail"> Fails</label>
</div>
<button class="lb-01__btn" type="button" id="lb-01-btn" data-state="idle" aria-busy="false">
<span class="lb-01__faces">
<span class="lb-01__face lb-01__face--idle">Save changes</span>
<span class="lb-01__face lb-01__face--pending">
<svg class="lb-01__spin" viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="10" cy="10" r="7.5" stroke-opacity=".28"/><path d="M17.5 10A7.5 7.5 0 0 0 10 2.5"/></svg>
Saving
</span>
<span class="lb-01__face lb-01__face--done">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M4.5 10.5l3.5 3.5 7.5-8"/></svg>
Saved
</span>
<span class="lb-01__face lb-01__face--error">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M10 5.5v5.5"/><circle cx="10" cy="14.6" r=".9" fill="currentColor" stroke="none"/></svg>
Retry
</span>
</span>
</button>
</footer>
<p class="lb-01__status" id="lb-01-live" role="status" aria-live="polite">Ready</p>
</section>
</div>
</div>
```
## CSS
```css
.lb-01 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #17161a;
--muted: #6e6c74;
--rule: #e5e3dc;
--accent: #17161a;
--on-accent: #ffffff;
--ok: #1c7a55;
--bad: #b4342a;
--dur: 220ms;
--hold: 1500ms;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--paper);
color: var(--ink);
padding: 40px 18px;
}
.lb-01 *,
.lb-01 *::before,
.lb-01 *::after {
box-sizing: border-box;
}
.lb-01__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-01__card {
inline-size: 100%;
max-inline-size: 30rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 22px 22px 18px;
}
.lb-01__eyebrow {
margin: 0;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.lb-01__h {
margin: 4px 0 0;
font-size: 20px;
font-weight: 640;
letter-spacing: -.015em;
}
.lb-01__row {
display: grid;
gap: 14px;
margin: 18px 0;
padding: 16px 0;
border-block: 1px solid var(--rule);
}
.lb-01__field {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__flabel {
font-size: 12.5px;
color: var(--muted);
min-inline-size: 0;
}
.lb-01__fvalue {
font-size: 13px;
font-weight: 560;
min-inline-size: 0;
}
.lb-01__foot {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
justify-content: space-between;
min-inline-size: 0;
}
.lb-01__mode {
display: flex;
gap: 12px;
flex-wrap: wrap;
min-inline-size: 0;
}
.lb-01__radio {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-01__radio input {
accent-color: var(--accent);
inline-size: 15px;
block-size: 15px;
}
.lb-01__btn {
appearance: none;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--on-accent);
border-radius: 6px;
font: inherit;
font-size: 13.5px;
font-weight: 560;
padding: 0 18px;
min-block-size: 44px;
min-inline-size: 0;
cursor: pointer;
transition: background var(--dur) ease, border-color var(--dur) ease, transform 120ms ease;
}
.lb-01__btn:active {
transform: translateY(1px);
}
.lb-01__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.lb-01__btn[data-state="pending"] {
cursor: progress;
}
.lb-01__btn[data-state="done"] {
background: var(--ok);
border-color: var(--ok);
}
.lb-01__btn[data-state="error"] {
background: var(--bad);
border-color: var(--bad);
}
.lb-01__faces {
display: grid;
place-items: center;
}
.lb-01__face {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transform: scale(.92);
transition: opacity var(--dur) ease, transform var(--dur) ease, visibility var(--dur);
}
.lb-01__btn[data-state="idle"] .lb-01__face--idle,
.lb-01__btn[data-state="pending"] .lb-01__face--pending,
.lb-01__btn[data-state="done"] .lb-01__face--done,
.lb-01__btn[data-state="error"] .lb-01__face--error {
opacity: 1;
visibility: visible;
transform: none;
}
.lb-01__spin {
animation: lb-01-spin 720ms linear infinite;
}
@keyframes lb-01-spin {
to {
transform: rotate(1turn);
}
}
.lb-01__status {
margin: 14px 0 0;
font-size: 12px;
color: var(--muted);
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-01__spin {
animation: none;
}
.lb-01__face {
transition: none;
}
}
@media (prefers-color-scheme: dark) {
.lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}
}
[data-theme="dark"] .lb-01 {
--paper: #131316;
--surface: #1b1b20;
--ink: #edecf1;
--muted: #9b99a4;
--rule: #2b2b33;
--accent: #edecf1;
--on-accent: #131316;
--ok: #4fbd90;
--bad: #f0857a;
}
```
## JavaScript
```js
const btn = document.getElementById('lb-01-btn');
const live = document.getElementById('lb-01-live');
let timer;
const set = (state, message) => {
btn.dataset.state = state;
btn.setAttribute('aria-busy', String(state === 'pending'));
live.textContent = message;
};
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearTimeout(timer);
set('pending', 'Saving profile changes');
const fails = document.querySelector('input[name="lb-01-mode"]:checked').value === 'fail';
timer = setTimeout(() => {
if (fails) {
set('error', 'Could not save. Press the button to retry.');
} else {
set('done', 'Profile saved');
}
timer = setTimeout(() => set('idle', 'Ready'), 1500);
}, 1400);
});
```How this works
One attribute is the whole state machine. The button carries data-state="idle" and the script only ever writes btn.dataset.state = 'pending' and friends. Every visual difference is an attribute selector — [data-state="pending"] .lb-01__face--pending { opacity: 1 } — which means the CSS is the single source of truth for what each state looks like and the JS never touches a style property.
The faces are stacked, not swapped. The three faces (label, spinner plus pending text, result) all live in one grid cell via grid-area: 1 / 1, so the button is always as wide as its widest face and a state change cannot move a pixel of the surrounding layout. That is the opposite of the naive approach, which sets textContent and lets the button shrink around a shorter string.
Hidden faces must actually be hidden. Fading a face to opacity: 0 leaves its text in the accessibility tree and its links in the tab order, so a screen reader user hears all three labels at once. Pair the fade with visibility: hidden — it still animates as a discrete step at the end of the transition, so you keep the crossfade and lose the ghost content.
The trap is the un-cleared timer. A double click schedules two resets, and the first one drops the button back to idle while the second request is still in flight. Keep the handle in a module-level variable and call clearTimeout before every setTimeout — the same guard every later demo in this collection uses.
Make it yours
- Change
--accenton the root to retint the button, its focus ring and the spinner together. - Swap the crossfade for a vertical slide by animating
translateYon the faces instead ofscale. - Set
--durto0msto see the state machine with the animation removed. - Give the button
border-radius: 999pxfor a pill or0for the flat editorial cut. - Lengthen
--holdto keep the success face on screen longer before it reverts. - Point the failure branch at a real request and keep the error face until the user retries, instead of auto-reverting.
Gotchas — read before shipping
- Setting
textContentinstead of stacking faces makes the button resize mid-request and shoves everything beside it. - A face faded with
opacity: 0alone stays in the accessibility tree, so the button announces its label, its pending text and its result at once. - Forgetting
clearTimeoutmeans a rapid second click resets the label early and the button looks idle while work is still running. - Animating
widthormarginrather thantransformandopacitydrops the crossfade off the compositor and it visibly stutters on a busy page.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 57+ | 10.1+ | 52+ | 57+ |
Nothing here is new: CSS grid, custom properties and attribute selectors set the floor, and the state machine is plain setTimeout. Older engines without grid render the faces stacked in flow rather than overlapping, which still works — it just makes the button taller.