34 CSS Floating Buttons29 / 34
Floating Button with Loading Spinner State CSS
The three-state async button — idle, pending, done — with the transitions between them treated as seriously as the states themselves. The spinner uses an animated dash gap so it never appears to stall, the success check draws in with stroke-dashoffset, and the whole thing is announced through aria-busy and a status region rather than by colour alone.
Published Updated
The code
<section class="fb-29" aria-label="Floating button with loading state demo">
<div class="fb-29__stage">
<p class="fb-29__eyebrow">idle → loading → done → idle</p>
<h2 class="fb-29__title">Three states, one attribute</h2>
<p class="fb-29__sub">Click any button. The dash gap breathes so the spinner never looks frozen, the check draws in over 420ms, and it all resets after a beat.</p>
<div class="fb-29__row">
<button class="fb-29__fab" id="fb-29-a" type="button" data-state="idle" aria-label="Save document">
<span class="fb-29__ico" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M8 4v5h7M8 20v-6h8v6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin" aria-hidden="true"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</button>
<button class="fb-29__pill" id="fb-29-b" type="button" data-state="idle">
<span class="fb-29__iconstack" aria-hidden="true">
<span class="fb-29__ico"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</span>
<b class="fb-29__label" id="fb-29-blabel">Publish</b>
</button>
</div>
<p class="fb-29__live" id="fb-29-live" role="status" aria-live="polite">Ready</p>
<p class="fb-29__chip">aria-busy · 400ms spinner floor · auto-reset 1.8s</p>
</div>
</section><section class="fb-29" aria-label="Floating button with loading state demo">
<div class="fb-29__stage">
<p class="fb-29__eyebrow">idle → loading → done → idle</p>
<h2 class="fb-29__title">Three states, one attribute</h2>
<p class="fb-29__sub">Click any button. The dash gap breathes so the spinner never looks frozen, the check draws in over 420ms, and it all resets after a beat.</p>
<div class="fb-29__row">
<button class="fb-29__fab" id="fb-29-a" type="button" data-state="idle" aria-label="Save document">
<span class="fb-29__ico" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M8 4v5h7M8 20v-6h8v6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin" aria-hidden="true"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</button>
<button class="fb-29__pill" id="fb-29-b" type="button" data-state="idle">
<span class="fb-29__iconstack" aria-hidden="true">
<span class="fb-29__ico"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</span>
<b class="fb-29__label" id="fb-29-blabel">Publish</b>
</button>
</div>
<p class="fb-29__live" id="fb-29-live" role="status" aria-live="polite">Ready</p>
<p class="fb-29__chip">aria-busy · 400ms spinner floor · auto-reset 1.8s</p>
</div>
</section>.fb-29 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--fb-29-bg);
--fb-29-bg: linear-gradient(180deg,oklch(0.98 0.008 265),oklch(0.94 0.02 258));
--fb-29-brand: oklch(0.52 0.17 265);
--fb-29-brandDeep: oklch(0.38 0.15 268);
--fb-29-ok: oklch(0.62 0.16 148);
--fb-29-ink: oklch(0.22 0.025 265);
--fb-29-mut: oklch(0.53 0.015 265);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--fb-29-ink);
display: grid;
place-items: center;
padding: clamp(20px,5vw,56px);
}
.fb-29 *,
.fb-29 *::before,
.fb-29 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.fb-29__stage {
width: min(100%,56ch);
display: grid;
gap: clamp(16px,3vw,26px);
justify-items: center;
text-align: center;
}
.fb-29__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--fb-29-brand);
}
.fb-29__title {
font-size: clamp(26px,4.6vw,44px);
line-height: 1.05;
letter-spacing: -.03em;
font-weight: 700;
text-wrap: balance;
}
.fb-29__sub {
max-width: 50ch;
font-size: clamp(13.5px,1.7vw,16.5px);
line-height: 1.62;
color: var(--fb-29-mut);
text-wrap: pretty;
}
.fb-29__row {
display: flex;
flex-wrap: wrap;
gap: 22px;
justify-content: center;
align-items: center;
padding: 8px 0;
}
.fb-29__fab,
.fb-29__pill {
position: relative;
border: 0;
border-radius: 22px;
cursor: pointer;
color: oklch(0.99 0.01 265);
background: linear-gradient(150deg,oklch(0.6 0.18 262),var(--fb-29-brandDeep));
box-shadow: 0 14px 32px -12px oklch(0.38 0.15 268/.85),0 2px 6px oklch(0.3 0.07 268/.28);
transition: transform .26s cubic-bezier(.2,1.3,.4,1),background .3s ease,box-shadow .3s ease;
}
.fb-29__fab {
display: grid;
place-items: center;
inline-size: 64px;
block-size: 64px;
}
.fb-29__pill {
display: inline-flex;
align-items: center;
gap: 11px;
block-size: 64px;
padding-inline: 22px 26px;
border-radius: 99px;
font-family: inherit;
font-size: 16px;
font-weight: 640;
}
.fb-29__pill .fb-29__iconstack {
position: relative;
display: grid;
place-items: center;
inline-size: 30px;
block-size: 30px;
flex: none;
}
.fb-29__fab:hover,
.fb-29__pill:hover {
transform: translateY(-2px);
}
.fb-29__fab:active,
.fb-29__pill:active {
transform: scale(.96);
}
.fb-29__fab:focus-visible,
.fb-29__pill:focus-visible {
outline: 3px solid var(--fb-29-brandDeep);
outline-offset: 4px;
}
.fb-29__fab>.fb-29__ico,
.fb-29__fab>.fb-29__spin,
.fb-29__fab>.fb-29__ok,
.fb-29__pill .fb-29__iconstack>.fb-29__ico,
.fb-29__pill .fb-29__iconstack>.fb-29__spin,
.fb-29__pill .fb-29__iconstack>.fb-29__ok {
grid-area: 1/1;
display: grid;
place-items: center;
transition: opacity .2s ease,scale .28s cubic-bezier(.2,1.4,.4,1);
}
.fb-29__ico svg,
.fb-29__ok svg {
width: 26px;
height: 26px;
}
.fb-29__spin svg {
width: 30px;
height: 30px;
animation: fb-29-rot 1.1s linear infinite;
}
.fb-29__spin circle {
stroke-dasharray: 22 90;
animation: fb-29-dash 1.5s ease-in-out infinite;
}
@keyframes fb-29-rot {
to {
rotate: 360deg;
}
}
@keyframes fb-29-dash {
0% {
stroke-dasharray: 12 96;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 62 46;
stroke-dashoffset: -16;
}
100% {
stroke-dasharray: 12 96;
stroke-dashoffset: -104;
}
}
.fb-29__spin,
.fb-29__ok {
opacity: 0;
scale: .6;
pointer-events: none;
}
.fb-29__check {
stroke-dasharray: var(--fb-29-len,32);
stroke-dashoffset: var(--fb-29-len,32);
}
[data-state="loading"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="loading"] .fb-29__spin {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="done"] .fb-29__ok {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__check {
animation: fb-29-draw .42s cubic-bezier(.2,.9,.3,1) forwards;
}
@keyframes fb-29-draw {
to {
stroke-dashoffset: 0;
}
}
.fb-29__fab[data-state="done"],
.fb-29__pill[data-state="done"] {
background: linear-gradient(150deg,oklch(0.7 0.16 150),oklch(0.5 0.14 150));
box-shadow: 0 14px 32px -12px oklch(0.5 0.14 150/.8);
}
.fb-29__fab[data-state="loading"],
.fb-29__pill[data-state="loading"] {
cursor: progress;
}
.fb-29__label {
font-weight: 640;
letter-spacing: -.01em;
}
.fb-29__live {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
color: var(--fb-29-brandDeep);
background: oklch(0.52 0.17 265/.09);
border-radius: 10px;
padding: 8px 15px;
min-width: 12ch;
}
.fb-29__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--fb-29-mut);
padding: 8px 14px;
border: 1px solid oklch(0.22 0.025 265/.14);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.fb-29 * {
animation-duration: .01ms !important;
transition: none !important;
}
.fb-29__check {
stroke-dashoffset: 0;
}
}.fb-29 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--fb-29-bg);
--fb-29-bg: linear-gradient(180deg,oklch(0.98 0.008 265),oklch(0.94 0.02 258));
--fb-29-brand: oklch(0.52 0.17 265);
--fb-29-brandDeep: oklch(0.38 0.15 268);
--fb-29-ok: oklch(0.62 0.16 148);
--fb-29-ink: oklch(0.22 0.025 265);
--fb-29-mut: oklch(0.53 0.015 265);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--fb-29-ink);
display: grid;
place-items: center;
padding: clamp(20px,5vw,56px);
}
.fb-29 *,
.fb-29 *::before,
.fb-29 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.fb-29__stage {
width: min(100%,56ch);
display: grid;
gap: clamp(16px,3vw,26px);
justify-items: center;
text-align: center;
}
.fb-29__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--fb-29-brand);
}
.fb-29__title {
font-size: clamp(26px,4.6vw,44px);
line-height: 1.05;
letter-spacing: -.03em;
font-weight: 700;
text-wrap: balance;
}
.fb-29__sub {
max-width: 50ch;
font-size: clamp(13.5px,1.7vw,16.5px);
line-height: 1.62;
color: var(--fb-29-mut);
text-wrap: pretty;
}
.fb-29__row {
display: flex;
flex-wrap: wrap;
gap: 22px;
justify-content: center;
align-items: center;
padding: 8px 0;
}
.fb-29__fab,
.fb-29__pill {
position: relative;
border: 0;
border-radius: 22px;
cursor: pointer;
color: oklch(0.99 0.01 265);
background: linear-gradient(150deg,oklch(0.6 0.18 262),var(--fb-29-brandDeep));
box-shadow: 0 14px 32px -12px oklch(0.38 0.15 268/.85),0 2px 6px oklch(0.3 0.07 268/.28);
transition: transform .26s cubic-bezier(.2,1.3,.4,1),background .3s ease,box-shadow .3s ease;
}
.fb-29__fab {
display: grid;
place-items: center;
inline-size: 64px;
block-size: 64px;
}
.fb-29__pill {
display: inline-flex;
align-items: center;
gap: 11px;
block-size: 64px;
padding-inline: 22px 26px;
border-radius: 99px;
font-family: inherit;
font-size: 16px;
font-weight: 640;
}
.fb-29__pill .fb-29__iconstack {
position: relative;
display: grid;
place-items: center;
inline-size: 30px;
block-size: 30px;
flex: none;
}
.fb-29__fab:hover,
.fb-29__pill:hover {
transform: translateY(-2px);
}
.fb-29__fab:active,
.fb-29__pill:active {
transform: scale(.96);
}
.fb-29__fab:focus-visible,
.fb-29__pill:focus-visible {
outline: 3px solid var(--fb-29-brandDeep);
outline-offset: 4px;
}
.fb-29__fab>.fb-29__ico,
.fb-29__fab>.fb-29__spin,
.fb-29__fab>.fb-29__ok,
.fb-29__pill .fb-29__iconstack>.fb-29__ico,
.fb-29__pill .fb-29__iconstack>.fb-29__spin,
.fb-29__pill .fb-29__iconstack>.fb-29__ok {
grid-area: 1/1;
display: grid;
place-items: center;
transition: opacity .2s ease,scale .28s cubic-bezier(.2,1.4,.4,1);
}
.fb-29__ico svg,
.fb-29__ok svg {
width: 26px;
height: 26px;
}
.fb-29__spin svg {
width: 30px;
height: 30px;
animation: fb-29-rot 1.1s linear infinite;
}
.fb-29__spin circle {
stroke-dasharray: 22 90;
animation: fb-29-dash 1.5s ease-in-out infinite;
}
@keyframes fb-29-rot {
to {
rotate: 360deg;
}
}
@keyframes fb-29-dash {
0% {
stroke-dasharray: 12 96;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 62 46;
stroke-dashoffset: -16;
}
100% {
stroke-dasharray: 12 96;
stroke-dashoffset: -104;
}
}
.fb-29__spin,
.fb-29__ok {
opacity: 0;
scale: .6;
pointer-events: none;
}
.fb-29__check {
stroke-dasharray: var(--fb-29-len,32);
stroke-dashoffset: var(--fb-29-len,32);
}
[data-state="loading"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="loading"] .fb-29__spin {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="done"] .fb-29__ok {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__check {
animation: fb-29-draw .42s cubic-bezier(.2,.9,.3,1) forwards;
}
@keyframes fb-29-draw {
to {
stroke-dashoffset: 0;
}
}
.fb-29__fab[data-state="done"],
.fb-29__pill[data-state="done"] {
background: linear-gradient(150deg,oklch(0.7 0.16 150),oklch(0.5 0.14 150));
box-shadow: 0 14px 32px -12px oklch(0.5 0.14 150/.8);
}
.fb-29__fab[data-state="loading"],
.fb-29__pill[data-state="loading"] {
cursor: progress;
}
.fb-29__label {
font-weight: 640;
letter-spacing: -.01em;
}
.fb-29__live {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
color: var(--fb-29-brandDeep);
background: oklch(0.52 0.17 265/.09);
border-radius: 10px;
padding: 8px 15px;
min-width: 12ch;
}
.fb-29__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--fb-29-mut);
padding: 8px 14px;
border: 1px solid oklch(0.22 0.025 265/.14);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.fb-29 * {
animation-duration: .01ms !important;
transition: none !important;
}
.fb-29__check {
stroke-dashoffset: 0;
}
}(() => {
const live = document.getElementById('fb-29-live');
const label = document.getElementById('fb-29-blabel');
const buttons = [document.getElementById('fb-29-a'), document.getElementById('fb-29-b')].filter(Boolean);
if (!buttons.length || buttons[0].dataset.fbWired) return;
buttons[0].dataset.fbWired = '1';
document.querySelectorAll('.fb-29__check').forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 32);
p.style.setProperty('--fb-29-len', len);
});
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
if (label && btn.contains(label)) label.textContent = 'Publishing…';
live.textContent = 'Working…';
setTimeout(() => {
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
if (label && btn.contains(label)) label.textContent = 'Published';
live.textContent = 'Done — saved just now';
setTimeout(() => {
btn.dataset.state = 'idle';
btn.disabled = false;
if (label && btn.contains(label)) label.textContent = 'Publish';
live.textContent = 'Ready';
}, 1800);
}, 1500);
});
});
})();(() => {
const live = document.getElementById('fb-29-live');
const label = document.getElementById('fb-29-blabel');
const buttons = [document.getElementById('fb-29-a'), document.getElementById('fb-29-b')].filter(Boolean);
if (!buttons.length || buttons[0].dataset.fbWired) return;
buttons[0].dataset.fbWired = '1';
document.querySelectorAll('.fb-29__check').forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 32);
p.style.setProperty('--fb-29-len', len);
});
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
if (label && btn.contains(label)) label.textContent = 'Publishing…';
live.textContent = 'Working…';
setTimeout(() => {
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
if (label && btn.contains(label)) label.textContent = 'Published';
live.textContent = 'Done — saved just now';
setTimeout(() => {
btn.dataset.state = 'idle';
btn.disabled = false;
if (label && btn.contains(label)) label.textContent = 'Publish';
live.textContent = 'Ready';
}, 1800);
}, 1500);
});
});
})();Here's a working CSS Floating 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: Floating Button with Loading Spinner State CSS
Source: https://codefronts.com/components/css-floating-buttons/fb11/
The three-state async button — idle, pending, done — with the transitions between them treated as seriously as the states themselves. The spinner uses an animated dash gap so it never appears to stall, the success check draws in with stroke-dashoffset, and the whole thing is announced through aria-busy and a status region rather than by colour alone.
## HTML
```html
<section class="fb-29" aria-label="Floating button with loading state demo">
<div class="fb-29__stage">
<p class="fb-29__eyebrow">idle → loading → done → idle</p>
<h2 class="fb-29__title">Three states, one attribute</h2>
<p class="fb-29__sub">Click any button. The dash gap breathes so the spinner never looks frozen, the check draws in over 420ms, and it all resets after a beat.</p>
<div class="fb-29__row">
<button class="fb-29__fab" id="fb-29-a" type="button" data-state="idle" aria-label="Save document">
<span class="fb-29__ico" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M8 4v5h7M8 20v-6h8v6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin" aria-hidden="true"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</button>
<button class="fb-29__pill" id="fb-29-b" type="button" data-state="idle">
<span class="fb-29__iconstack" aria-hidden="true">
<span class="fb-29__ico"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</span>
<b class="fb-29__label" id="fb-29-blabel">Publish</b>
</button>
</div>
<p class="fb-29__live" id="fb-29-live" role="status" aria-live="polite">Ready</p>
<p class="fb-29__chip">aria-busy · 400ms spinner floor · auto-reset 1.8s</p>
</div>
</section>
```
## CSS
```css
.fb-29 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--fb-29-bg);
--fb-29-bg: linear-gradient(180deg,oklch(0.98 0.008 265),oklch(0.94 0.02 258));
--fb-29-brand: oklch(0.52 0.17 265);
--fb-29-brandDeep: oklch(0.38 0.15 268);
--fb-29-ok: oklch(0.62 0.16 148);
--fb-29-ink: oklch(0.22 0.025 265);
--fb-29-mut: oklch(0.53 0.015 265);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--fb-29-ink);
display: grid;
place-items: center;
padding: clamp(20px,5vw,56px);
}
.fb-29 *,
.fb-29 *::before,
.fb-29 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.fb-29__stage {
width: min(100%,56ch);
display: grid;
gap: clamp(16px,3vw,26px);
justify-items: center;
text-align: center;
}
.fb-29__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--fb-29-brand);
}
.fb-29__title {
font-size: clamp(26px,4.6vw,44px);
line-height: 1.05;
letter-spacing: -.03em;
font-weight: 700;
text-wrap: balance;
}
.fb-29__sub {
max-width: 50ch;
font-size: clamp(13.5px,1.7vw,16.5px);
line-height: 1.62;
color: var(--fb-29-mut);
text-wrap: pretty;
}
.fb-29__row {
display: flex;
flex-wrap: wrap;
gap: 22px;
justify-content: center;
align-items: center;
padding: 8px 0;
}
.fb-29__fab,
.fb-29__pill {
position: relative;
border: 0;
border-radius: 22px;
cursor: pointer;
color: oklch(0.99 0.01 265);
background: linear-gradient(150deg,oklch(0.6 0.18 262),var(--fb-29-brandDeep));
box-shadow: 0 14px 32px -12px oklch(0.38 0.15 268/.85),0 2px 6px oklch(0.3 0.07 268/.28);
transition: transform .26s cubic-bezier(.2,1.3,.4,1),background .3s ease,box-shadow .3s ease;
}
.fb-29__fab {
display: grid;
place-items: center;
inline-size: 64px;
block-size: 64px;
}
.fb-29__pill {
display: inline-flex;
align-items: center;
gap: 11px;
block-size: 64px;
padding-inline: 22px 26px;
border-radius: 99px;
font-family: inherit;
font-size: 16px;
font-weight: 640;
}
.fb-29__pill .fb-29__iconstack {
position: relative;
display: grid;
place-items: center;
inline-size: 30px;
block-size: 30px;
flex: none;
}
.fb-29__fab:hover,
.fb-29__pill:hover {
transform: translateY(-2px);
}
.fb-29__fab:active,
.fb-29__pill:active {
transform: scale(.96);
}
.fb-29__fab:focus-visible,
.fb-29__pill:focus-visible {
outline: 3px solid var(--fb-29-brandDeep);
outline-offset: 4px;
}
.fb-29__fab>.fb-29__ico,
.fb-29__fab>.fb-29__spin,
.fb-29__fab>.fb-29__ok,
.fb-29__pill .fb-29__iconstack>.fb-29__ico,
.fb-29__pill .fb-29__iconstack>.fb-29__spin,
.fb-29__pill .fb-29__iconstack>.fb-29__ok {
grid-area: 1/1;
display: grid;
place-items: center;
transition: opacity .2s ease,scale .28s cubic-bezier(.2,1.4,.4,1);
}
.fb-29__ico svg,
.fb-29__ok svg {
width: 26px;
height: 26px;
}
.fb-29__spin svg {
width: 30px;
height: 30px;
animation: fb-29-rot 1.1s linear infinite;
}
.fb-29__spin circle {
stroke-dasharray: 22 90;
animation: fb-29-dash 1.5s ease-in-out infinite;
}
@keyframes fb-29-rot {
to {
rotate: 360deg;
}
}
@keyframes fb-29-dash {
0% {
stroke-dasharray: 12 96;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 62 46;
stroke-dashoffset: -16;
}
100% {
stroke-dasharray: 12 96;
stroke-dashoffset: -104;
}
}
.fb-29__spin,
.fb-29__ok {
opacity: 0;
scale: .6;
pointer-events: none;
}
.fb-29__check {
stroke-dasharray: var(--fb-29-len,32);
stroke-dashoffset: var(--fb-29-len,32);
}
[data-state="loading"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="loading"] .fb-29__spin {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="done"] .fb-29__ok {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__check {
animation: fb-29-draw .42s cubic-bezier(.2,.9,.3,1) forwards;
}
@keyframes fb-29-draw {
to {
stroke-dashoffset: 0;
}
}
.fb-29__fab[data-state="done"],
.fb-29__pill[data-state="done"] {
background: linear-gradient(150deg,oklch(0.7 0.16 150),oklch(0.5 0.14 150));
box-shadow: 0 14px 32px -12px oklch(0.5 0.14 150/.8);
}
.fb-29__fab[data-state="loading"],
.fb-29__pill[data-state="loading"] {
cursor: progress;
}
.fb-29__label {
font-weight: 640;
letter-spacing: -.01em;
}
.fb-29__live {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
color: var(--fb-29-brandDeep);
background: oklch(0.52 0.17 265/.09);
border-radius: 10px;
padding: 8px 15px;
min-width: 12ch;
}
.fb-29__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--fb-29-mut);
padding: 8px 14px;
border: 1px solid oklch(0.22 0.025 265/.14);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.fb-29 * {
animation-duration: .01ms !important;
transition: none !important;
}
.fb-29__check {
stroke-dashoffset: 0;
}
}
```
## JavaScript
```js
(() => {
const live = document.getElementById('fb-29-live');
const label = document.getElementById('fb-29-blabel');
const buttons = [document.getElementById('fb-29-a'), document.getElementById('fb-29-b')].filter(Boolean);
if (!buttons.length || buttons[0].dataset.fbWired) return;
buttons[0].dataset.fbWired = '1';
document.querySelectorAll('.fb-29__check').forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 32);
p.style.setProperty('--fb-29-len', len);
});
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
if (label && btn.contains(label)) label.textContent = 'Publishing…';
live.textContent = 'Working…';
setTimeout(() => {
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
if (label && btn.contains(label)) label.textContent = 'Published';
live.textContent = 'Done — saved just now';
setTimeout(() => {
btn.dataset.state = 'idle';
btn.disabled = false;
if (label && btn.contains(label)) label.textContent = 'Publish';
live.textContent = 'Ready';
}, 1800);
}, 1500);
});
});
})();
```Here's a working CSS Floating 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: Floating Button with Loading Spinner State CSS
Source: https://codefronts.com/components/css-floating-buttons/fb11/
The three-state async button — idle, pending, done — with the transitions between them treated as seriously as the states themselves. The spinner uses an animated dash gap so it never appears to stall, the success check draws in with stroke-dashoffset, and the whole thing is announced through aria-busy and a status region rather than by colour alone.
## HTML
```html
<section class="fb-29" aria-label="Floating button with loading state demo">
<div class="fb-29__stage">
<p class="fb-29__eyebrow">idle → loading → done → idle</p>
<h2 class="fb-29__title">Three states, one attribute</h2>
<p class="fb-29__sub">Click any button. The dash gap breathes so the spinner never looks frozen, the check draws in over 420ms, and it all resets after a beat.</p>
<div class="fb-29__row">
<button class="fb-29__fab" id="fb-29-a" type="button" data-state="idle" aria-label="Save document">
<span class="fb-29__ico" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M8 4v5h7M8 20v-6h8v6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin" aria-hidden="true"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</button>
<button class="fb-29__pill" id="fb-29-b" type="button" data-state="idle">
<span class="fb-29__iconstack" aria-hidden="true">
<span class="fb-29__ico"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="fb-29__spin"><svg viewBox="0 0 44 44" focusable="false"><circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/></svg></span>
<span class="fb-29__ok"><svg viewBox="0 0 24 24" focusable="false"><path class="fb-29__check" d="M5 12.6l4.6 4.6L19 7.4" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</span>
<b class="fb-29__label" id="fb-29-blabel">Publish</b>
</button>
</div>
<p class="fb-29__live" id="fb-29-live" role="status" aria-live="polite">Ready</p>
<p class="fb-29__chip">aria-busy · 400ms spinner floor · auto-reset 1.8s</p>
</div>
</section>
```
## CSS
```css
.fb-29 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--fb-29-bg);
--fb-29-bg: linear-gradient(180deg,oklch(0.98 0.008 265),oklch(0.94 0.02 258));
--fb-29-brand: oklch(0.52 0.17 265);
--fb-29-brandDeep: oklch(0.38 0.15 268);
--fb-29-ok: oklch(0.62 0.16 148);
--fb-29-ink: oklch(0.22 0.025 265);
--fb-29-mut: oklch(0.53 0.015 265);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--fb-29-ink);
display: grid;
place-items: center;
padding: clamp(20px,5vw,56px);
}
.fb-29 *,
.fb-29 *::before,
.fb-29 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.fb-29__stage {
width: min(100%,56ch);
display: grid;
gap: clamp(16px,3vw,26px);
justify-items: center;
text-align: center;
}
.fb-29__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--fb-29-brand);
}
.fb-29__title {
font-size: clamp(26px,4.6vw,44px);
line-height: 1.05;
letter-spacing: -.03em;
font-weight: 700;
text-wrap: balance;
}
.fb-29__sub {
max-width: 50ch;
font-size: clamp(13.5px,1.7vw,16.5px);
line-height: 1.62;
color: var(--fb-29-mut);
text-wrap: pretty;
}
.fb-29__row {
display: flex;
flex-wrap: wrap;
gap: 22px;
justify-content: center;
align-items: center;
padding: 8px 0;
}
.fb-29__fab,
.fb-29__pill {
position: relative;
border: 0;
border-radius: 22px;
cursor: pointer;
color: oklch(0.99 0.01 265);
background: linear-gradient(150deg,oklch(0.6 0.18 262),var(--fb-29-brandDeep));
box-shadow: 0 14px 32px -12px oklch(0.38 0.15 268/.85),0 2px 6px oklch(0.3 0.07 268/.28);
transition: transform .26s cubic-bezier(.2,1.3,.4,1),background .3s ease,box-shadow .3s ease;
}
.fb-29__fab {
display: grid;
place-items: center;
inline-size: 64px;
block-size: 64px;
}
.fb-29__pill {
display: inline-flex;
align-items: center;
gap: 11px;
block-size: 64px;
padding-inline: 22px 26px;
border-radius: 99px;
font-family: inherit;
font-size: 16px;
font-weight: 640;
}
.fb-29__pill .fb-29__iconstack {
position: relative;
display: grid;
place-items: center;
inline-size: 30px;
block-size: 30px;
flex: none;
}
.fb-29__fab:hover,
.fb-29__pill:hover {
transform: translateY(-2px);
}
.fb-29__fab:active,
.fb-29__pill:active {
transform: scale(.96);
}
.fb-29__fab:focus-visible,
.fb-29__pill:focus-visible {
outline: 3px solid var(--fb-29-brandDeep);
outline-offset: 4px;
}
.fb-29__fab>.fb-29__ico,
.fb-29__fab>.fb-29__spin,
.fb-29__fab>.fb-29__ok,
.fb-29__pill .fb-29__iconstack>.fb-29__ico,
.fb-29__pill .fb-29__iconstack>.fb-29__spin,
.fb-29__pill .fb-29__iconstack>.fb-29__ok {
grid-area: 1/1;
display: grid;
place-items: center;
transition: opacity .2s ease,scale .28s cubic-bezier(.2,1.4,.4,1);
}
.fb-29__ico svg,
.fb-29__ok svg {
width: 26px;
height: 26px;
}
.fb-29__spin svg {
width: 30px;
height: 30px;
animation: fb-29-rot 1.1s linear infinite;
}
.fb-29__spin circle {
stroke-dasharray: 22 90;
animation: fb-29-dash 1.5s ease-in-out infinite;
}
@keyframes fb-29-rot {
to {
rotate: 360deg;
}
}
@keyframes fb-29-dash {
0% {
stroke-dasharray: 12 96;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 62 46;
stroke-dashoffset: -16;
}
100% {
stroke-dasharray: 12 96;
stroke-dashoffset: -104;
}
}
.fb-29__spin,
.fb-29__ok {
opacity: 0;
scale: .6;
pointer-events: none;
}
.fb-29__check {
stroke-dasharray: var(--fb-29-len,32);
stroke-dashoffset: var(--fb-29-len,32);
}
[data-state="loading"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="loading"] .fb-29__spin {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__ico {
opacity: 0;
scale: .6;
}
[data-state="done"] .fb-29__ok {
opacity: 1;
scale: 1;
}
[data-state="done"] .fb-29__check {
animation: fb-29-draw .42s cubic-bezier(.2,.9,.3,1) forwards;
}
@keyframes fb-29-draw {
to {
stroke-dashoffset: 0;
}
}
.fb-29__fab[data-state="done"],
.fb-29__pill[data-state="done"] {
background: linear-gradient(150deg,oklch(0.7 0.16 150),oklch(0.5 0.14 150));
box-shadow: 0 14px 32px -12px oklch(0.5 0.14 150/.8);
}
.fb-29__fab[data-state="loading"],
.fb-29__pill[data-state="loading"] {
cursor: progress;
}
.fb-29__label {
font-weight: 640;
letter-spacing: -.01em;
}
.fb-29__live {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
color: var(--fb-29-brandDeep);
background: oklch(0.52 0.17 265/.09);
border-radius: 10px;
padding: 8px 15px;
min-width: 12ch;
}
.fb-29__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--fb-29-mut);
padding: 8px 14px;
border: 1px solid oklch(0.22 0.025 265/.14);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.fb-29 * {
animation-duration: .01ms !important;
transition: none !important;
}
.fb-29__check {
stroke-dashoffset: 0;
}
}
```
## JavaScript
```js
(() => {
const live = document.getElementById('fb-29-live');
const label = document.getElementById('fb-29-blabel');
const buttons = [document.getElementById('fb-29-a'), document.getElementById('fb-29-b')].filter(Boolean);
if (!buttons.length || buttons[0].dataset.fbWired) return;
buttons[0].dataset.fbWired = '1';
document.querySelectorAll('.fb-29__check').forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 32);
p.style.setProperty('--fb-29-len', len);
});
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
if (label && btn.contains(label)) label.textContent = 'Publishing…';
live.textContent = 'Working…';
setTimeout(() => {
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
if (label && btn.contains(label)) label.textContent = 'Published';
live.textContent = 'Done — saved just now';
setTimeout(() => {
btn.dataset.state = 'idle';
btn.disabled = false;
if (label && btn.contains(label)) label.textContent = 'Publish';
live.textContent = 'Ready';
}, 1800);
}, 1500);
});
});
})();
```How this works
A single data-state attribute drives the CSS; the JS only ever changes that one value:
[data-state="loading"] .icon{ opacity:0; scale:.6 }
[data-state="loading"] .spinner{ opacity:1 }
[data-state="done"] .check{ animation:draw .42s cubic-bezier(.2,.9,.3,1) forwards }
@keyframes draw{ to{ stroke-dashoffset:0 } }The spinner is an SVG circle with stroke-dasharray animated between two lengths while the whole element rotates. A fixed-length arc spinning at constant speed reads as frozen when it happens to sync with your eye; a breathing dash gap never does.
The check mark is one path with its stroke-dasharray and stroke-dashoffset both set to the path length, animated to zero — the standard draw-on technique, and the most convincing "complete" cue there is, because it takes time the way a real confirmation does.
Accessibility: the button gets aria-busy="true" and disabled while pending (so a double-click cannot fire twice), a role="status" region announces "Saving" and then "Saved", and after 1.8s it returns to idle. Never leave a button permanently in a success state — the user's next action needs the idle affordance back.
Make it yours
- Progress instead of spin: if you know the percentage, animate stroke-dashoffset from the real value — a determinate ring always beats a spinner.
- Error state: a fourth data-state with a shake (three 4px translations) and an X draw, returning to idle after 2.5s.
- Extended pill: the same three states work on a labelled pill where the text swaps Save → Saving… → Saved.
- Optimistic UI: skip the pending state for actions under ~300ms and go straight to done — a spinner that flashes is worse than none.
Gotchas — read before shipping
- Disabling the button without aria-busy tells assistive tech the control is unavailable, not that it is working. Use both.
- A spinner shown for under 200ms flashes and reads as a glitch. Either delay showing it, or hold it for a 400ms minimum once shown.
- Do not change the button's size between states; a shrinking button moves the pointer target mid-interaction.
- Success states that persist indefinitely block the next action. Always time out back to idle.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.
SVG stroke animation and keyframes work in every browser. getTotalLength() is used to measure the check path and is supported since IE9.