18 / 20
All 20 in 20 Tailwind CSS Animated Buttons
- 01Tailwind Neon Glow Button with Pulsing Halo
- 02Tailwind Background Slide Hover Button (Four Directions)
- 03Tailwind Ripple Effect Button with No JavaScript
- 04Tailwind Animated Border Draw Button with Staged Delays
- 05Tailwind 3D Press Effect Button with Keycap Depth
- 06Tailwind Liquid Fill Button with Animated Wave Crest
- 07Tailwind Moving Gradient CTA Button
- 08Tailwind Shine Sweep Hover Button with Gloss Streak
- 09Tailwind Jelly Bounce Button with Elastic Squash
- 10Tailwind Text Flip Button Hover with 3D Rotation
- 11Tailwind Animated CTA Hero Button with Beacon Rings & Particle Burst
- 12Tailwind Submit Button Success State with Checkmark Draw
- 13Tailwind Animated Download Button with Progress Fill
- 14Tailwind Hamburger to Close Button with aria-expanded Variants
- 15Tailwind Add to Cart Button with Flying Product Dot
- 16Tailwind Social Share Buttons with Brand Hover & Copy Link
- 17Tailwind Animated Toggle Switch with peer-checked & has-checked
- 18Tailwind Loading Spinner Inside Button with State Machine
- 19Tailwind Next & Prev Arrow Buttons with Chasing Icons
- 20Tailwind Ghost Button with Cursor-Aware Background Reveal
Tailwind Loading Spinner Inside Button with State Machine
Idle → loading → success → idle, with a spinner built from a single after: ring. The port replaces measured inline widths with min-w-40, so the button no longer needs JavaScript to know how wide it is.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tab-18 w-full min-h-svh grid place-items-center bg-tab18-bg font-tab18-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab18-mut m-0">Click to simulate a request</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-white bg-tab18-primary border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,translate] duration-300 hover:not-disabled:brightness-108 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-white/30 after:border-t-white after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Save Changes</span>
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-tab18-primary bg-transparent border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,color,translate] duration-300 hover:not-disabled:bg-tab18-primary/8 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done data-[state=success]:text-white after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-tab18-primary/30 after:border-t-tab18-primary after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Subscribe</span>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
</div>
<p id="tab18-status" role="status" aria-live="polite" class="text-[0.8rem] text-tab18-mut min-h-5"></p>
</div>
</section><section class="tab-18 w-full min-h-svh grid place-items-center bg-tab18-bg font-tab18-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab18-mut m-0">Click to simulate a request</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-white bg-tab18-primary border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,translate] duration-300 hover:not-disabled:brightness-108 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-white/30 after:border-t-white after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Save Changes</span>
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-tab18-primary bg-transparent border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,color,translate] duration-300 hover:not-disabled:bg-tab18-primary/8 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done data-[state=success]:text-white after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-tab18-primary/30 after:border-t-tab18-primary after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Subscribe</span>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
</div>
<p id="tab18-status" role="status" aria-live="polite" class="text-[0.8rem] text-tab18-mut min-h-5"></p>
</div>
</section>@import "tailwindcss";
@theme {
--color-tab18-bg: #f1f5f9;
--color-tab18-bg: oklch(0.97 0.006 250);
--color-tab18-mut: #64748b;
--color-tab18-mut: oklch(0.55 0.03 255);
--color-tab18-primary: #4f46e5;
--color-tab18-primary: oklch(0.51 0.24 277);
--color-tab18-done: #059669;
--color-tab18-done: oklch(0.58 0.14 163);
--font-tab18-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab18-spin: tab18-spin 0.8s linear infinite;
--animate-tab18-draw: tab18-draw 0.4s ease 0.1s forwards;
}
@keyframes tab18-spin {
to {
rotate: 360deg;
}
}
@keyframes tab18-draw {
from {
stroke-dashoffset: 24;
}
to {
stroke-dashoffset: 0;
}
}
/* State-driven child rules: the label fade and the checkmark reveal both read the
button's data-state, which is more legible here than three nested variants. */
.tab-18 [data-state="loading"] > span,
.tab-18 [data-state="success"] > span {
opacity: 0;
}
.tab-18 [data-state="success"] > svg {
opacity: 1;
scale: 1;
}
.tab-18 [data-state="success"] polyline {
animation: var(--animate-tab18-draw);
}
.tab-18 {
width: 100%;
min-height: 100svh;
}
.tab-18 ::selection {
background: var(--color-tab18-primary);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-18 [data-state="success"] polyline {
animation: none;
stroke-dashoffset: 0;
}
}@import "tailwindcss";
@theme {
--color-tab18-bg: #f1f5f9;
--color-tab18-bg: oklch(0.97 0.006 250);
--color-tab18-mut: #64748b;
--color-tab18-mut: oklch(0.55 0.03 255);
--color-tab18-primary: #4f46e5;
--color-tab18-primary: oklch(0.51 0.24 277);
--color-tab18-done: #059669;
--color-tab18-done: oklch(0.58 0.14 163);
--font-tab18-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab18-spin: tab18-spin 0.8s linear infinite;
--animate-tab18-draw: tab18-draw 0.4s ease 0.1s forwards;
}
@keyframes tab18-spin {
to {
rotate: 360deg;
}
}
@keyframes tab18-draw {
from {
stroke-dashoffset: 24;
}
to {
stroke-dashoffset: 0;
}
}
/* State-driven child rules: the label fade and the checkmark reveal both read the
button's data-state, which is more legible here than three nested variants. */
.tab-18 [data-state="loading"] > span,
.tab-18 [data-state="success"] > span {
opacity: 0;
}
.tab-18 [data-state="success"] > svg {
opacity: 1;
scale: 1;
}
.tab-18 [data-state="success"] polyline {
animation: var(--animate-tab18-draw);
}
.tab-18 {
width: 100%;
min-height: 100svh;
}
.tab-18 ::selection {
background: var(--color-tab18-primary);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-18 [data-state="success"] polyline {
animation: none;
stroke-dashoffset: 0;
}
}(function () {
var status = document.getElementById('tab18-status');
document.querySelectorAll('.tab-18 .tab18-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
if (status) status.textContent = 'Saving\u2026';
setTimeout(function () {
btn.dataset.state = 'success';
btn.setAttribute('aria-busy', 'false');
if (status) status.textContent = 'Saved.';
setTimeout(function () {
btn.dataset.state = 'idle';
btn.disabled = false;
if (status) status.textContent = '';
}, 1800);
}, 2000);
});
});
})();(function () {
var status = document.getElementById('tab18-status');
document.querySelectorAll('.tab-18 .tab18-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
if (status) status.textContent = 'Saving\u2026';
setTimeout(function () {
btn.dataset.state = 'success';
btn.setAttribute('aria-busy', 'false');
if (status) status.textContent = 'Saved.';
setTimeout(function () {
btn.dataset.state = 'idle';
btn.disabled = false;
if (status) status.textContent = '';
}, 1800);
}, 2000);
});
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tab18-bg: #f1f5f9;
--color-tab18-bg: oklch(0.97 0.006 250);
--color-tab18-mut: #64748b;
--color-tab18-mut: oklch(0.55 0.03 255);
--color-tab18-primary: #4f46e5;
--color-tab18-primary: oklch(0.51 0.24 277);
--color-tab18-done: #059669;
--color-tab18-done: oklch(0.58 0.14 163);
--font-tab18-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab18-spin: tab18-spin 0.8s linear infinite;
--animate-tab18-draw: tab18-draw 0.4s ease 0.1s forwards;
}
@keyframes tab18-spin {
to {
rotate: 360deg;
}
}
@keyframes tab18-draw {
from {
stroke-dashoffset: 24;
}
to {
stroke-dashoffset: 0;
}
}
/* State-driven child rules: the label fade and the checkmark reveal both read the
button's data-state, which is more legible here than three nested variants. */
.tab-18 [data-state="loading"] > span,
.tab-18 [data-state="success"] > span {
opacity: 0;
}
.tab-18 [data-state="success"] > svg {
opacity: 1;
scale: 1;
}
.tab-18 [data-state="success"] polyline {
animation: var(--animate-tab18-draw);
}
.tab-18 {
width: 100%;
min-height: 100svh;
}
.tab-18 ::selection {
background: var(--color-tab18-primary);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-18 [data-state="success"] polyline {
animation: none;
stroke-dashoffset: 0;
}
}@import "tailwindcss";
@theme {
--color-tab18-bg: #f1f5f9;
--color-tab18-bg: oklch(0.97 0.006 250);
--color-tab18-mut: #64748b;
--color-tab18-mut: oklch(0.55 0.03 255);
--color-tab18-primary: #4f46e5;
--color-tab18-primary: oklch(0.51 0.24 277);
--color-tab18-done: #059669;
--color-tab18-done: oklch(0.58 0.14 163);
--font-tab18-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab18-spin: tab18-spin 0.8s linear infinite;
--animate-tab18-draw: tab18-draw 0.4s ease 0.1s forwards;
}
@keyframes tab18-spin {
to {
rotate: 360deg;
}
}
@keyframes tab18-draw {
from {
stroke-dashoffset: 24;
}
to {
stroke-dashoffset: 0;
}
}
/* State-driven child rules: the label fade and the checkmark reveal both read the
button's data-state, which is more legible here than three nested variants. */
.tab-18 [data-state="loading"] > span,
.tab-18 [data-state="success"] > span {
opacity: 0;
}
.tab-18 [data-state="success"] > svg {
opacity: 1;
scale: 1;
}
.tab-18 [data-state="success"] polyline {
animation: var(--animate-tab18-draw);
}
.tab-18 {
width: 100%;
min-height: 100svh;
}
.tab-18 ::selection {
background: var(--color-tab18-primary);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-18 [data-state="success"] polyline {
animation: none;
stroke-dashoffset: 0;
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
min-w-40deletes the original's width-measuring JavaScript outright — the clearest case in the collection of a utility replacing imperative code rather than just relocating it.hover:not-disabled:stacks v4'snot-*variant onto hover, which is exactly the:hover:not([disabled])the CSS version wrote by hand.- UI change from the CSS original: a visible
aria-livestatus line under the row, plusaria-busyon the button. A spinner alone tells a screen reader nothing.
Here's a working CSS demo 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: Tailwind Loading Spinner Inside Button with State Machine
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-loading-spinner-inside-button/
Idle → loading → success → idle, with a spinner built from a single after: ring. The port replaces measured inline widths with min-w-40, so the button no longer needs JavaScript to know how wide it is.
## HTML
```html
<section class="tab-18 w-full min-h-svh grid place-items-center bg-tab18-bg font-tab18-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab18-mut m-0">Click to simulate a request</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-white bg-tab18-primary border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,translate] duration-300 hover:not-disabled:brightness-108 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-white/30 after:border-t-white after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Save Changes</span>
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-tab18-primary bg-transparent border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,color,translate] duration-300 hover:not-disabled:bg-tab18-primary/8 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done data-[state=success]:text-white after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-tab18-primary/30 after:border-t-tab18-primary after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Subscribe</span>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
</div>
<p id="tab18-status" role="status" aria-live="polite" class="text-[0.8rem] text-tab18-mut min-h-5"></p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tab18-bg: #f1f5f9;
--color-tab18-bg: oklch(0.97 0.006 250);
--color-tab18-mut: #64748b;
--color-tab18-mut: oklch(0.55 0.03 255);
--color-tab18-primary: #4f46e5;
--color-tab18-primary: oklch(0.51 0.24 277);
--color-tab18-done: #059669;
--color-tab18-done: oklch(0.58 0.14 163);
--font-tab18-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab18-spin: tab18-spin 0.8s linear infinite;
--animate-tab18-draw: tab18-draw 0.4s ease 0.1s forwards;
}
@keyframes tab18-spin {
to {
rotate: 360deg;
}
}
@keyframes tab18-draw {
from {
stroke-dashoffset: 24;
}
to {
stroke-dashoffset: 0;
}
}
/* State-driven child rules: the label fade and the checkmark reveal both read the
button's data-state, which is more legible here than three nested variants. */
.tab-18 [data-state="loading"] > span,
.tab-18 [data-state="success"] > span {
opacity: 0;
}
.tab-18 [data-state="success"] > svg {
opacity: 1;
scale: 1;
}
.tab-18 [data-state="success"] polyline {
animation: var(--animate-tab18-draw);
}
.tab-18 {
width: 100%;
min-height: 100svh;
}
.tab-18 ::selection {
background: var(--color-tab18-primary);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-18 [data-state="success"] polyline {
animation: none;
stroke-dashoffset: 0;
}
}
```
## JavaScript
```js
(function () {
var status = document.getElementById('tab18-status');
document.querySelectorAll('.tab-18 .tab18-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
if (status) status.textContent = 'Saving\u2026';
setTimeout(function () {
btn.dataset.state = 'success';
btn.setAttribute('aria-busy', 'false');
if (status) status.textContent = 'Saved.';
setTimeout(function () {
btn.dataset.state = 'idle';
btn.disabled = false;
if (status) status.textContent = '';
}, 1800);
}, 2000);
});
});
})();
```Here's a working CSS demo 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: Tailwind Loading Spinner Inside Button with State Machine
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-loading-spinner-inside-button/
Idle → loading → success → idle, with a spinner built from a single after: ring. The port replaces measured inline widths with min-w-40, so the button no longer needs JavaScript to know how wide it is.
## HTML
```html
<section class="tab-18 w-full min-h-svh grid place-items-center bg-tab18-bg font-tab18-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab18-mut m-0">Click to simulate a request</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-white bg-tab18-primary border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,translate] duration-300 hover:not-disabled:brightness-108 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-white/30 after:border-t-white after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Save Changes</span>
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
<button type="button" data-state="idle" class="tab18-btn relative inline-flex items-center justify-center overflow-hidden min-w-40 min-h-11 px-8 text-[0.95rem] font-bold text-tab18-primary bg-transparent border-2 border-tab18-primary rounded-[10px] cursor-pointer transition-[background-color,border-color,color,translate] duration-300 hover:not-disabled:bg-tab18-primary/8 hover:not-disabled:-translate-y-px active:not-disabled:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab18-primary disabled:cursor-not-allowed disabled:opacity-85 data-[state=success]:bg-tab18-done data-[state=success]:border-tab18-done data-[state=success]:text-white after:content-[''] after:absolute after:size-[18px] after:rounded-full after:border-2 after:border-tab18-primary/30 after:border-t-tab18-primary after:opacity-0 after:scale-0 after:pointer-events-none after:transition-[opacity,scale] after:duration-200 data-[state=loading]:after:opacity-100 data-[state=loading]:after:scale-100 data-[state=loading]:after:animate-tab18-spin motion-reduce:transition-none motion-reduce:data-[state=loading]:after:animate-none">
<span class="transition-opacity duration-[250ms]">Subscribe</span>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-5 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-200">
<polyline points="16 5 8 14 4 10" pathLength="24" stroke-dasharray="24" stroke-dashoffset="24"/>
</svg>
</button>
</div>
<p id="tab18-status" role="status" aria-live="polite" class="text-[0.8rem] text-tab18-mut min-h-5"></p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tab18-bg: #f1f5f9;
--color-tab18-bg: oklch(0.97 0.006 250);
--color-tab18-mut: #64748b;
--color-tab18-mut: oklch(0.55 0.03 255);
--color-tab18-primary: #4f46e5;
--color-tab18-primary: oklch(0.51 0.24 277);
--color-tab18-done: #059669;
--color-tab18-done: oklch(0.58 0.14 163);
--font-tab18-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab18-spin: tab18-spin 0.8s linear infinite;
--animate-tab18-draw: tab18-draw 0.4s ease 0.1s forwards;
}
@keyframes tab18-spin {
to {
rotate: 360deg;
}
}
@keyframes tab18-draw {
from {
stroke-dashoffset: 24;
}
to {
stroke-dashoffset: 0;
}
}
/* State-driven child rules: the label fade and the checkmark reveal both read the
button's data-state, which is more legible here than three nested variants. */
.tab-18 [data-state="loading"] > span,
.tab-18 [data-state="success"] > span {
opacity: 0;
}
.tab-18 [data-state="success"] > svg {
opacity: 1;
scale: 1;
}
.tab-18 [data-state="success"] polyline {
animation: var(--animate-tab18-draw);
}
.tab-18 {
width: 100%;
min-height: 100svh;
}
.tab-18 ::selection {
background: var(--color-tab18-primary);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-18 [data-state="success"] polyline {
animation: none;
stroke-dashoffset: 0;
}
}
```
## JavaScript
```js
(function () {
var status = document.getElementById('tab18-status');
document.querySelectorAll('.tab-18 .tab18-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'loading';
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
if (status) status.textContent = 'Saving\u2026';
setTimeout(function () {
btn.dataset.state = 'success';
btn.setAttribute('aria-busy', 'false');
if (status) status.textContent = 'Saved.';
setTimeout(function () {
btn.dataset.state = 'idle';
btn.disabled = false;
if (status) status.textContent = '';
}, 1800);
}, 2000);
});
});
})();
```How this works
The spinner is a pseudo-element ring. Three sides at 30% alpha, the top side opaque, rounded-full, rotating. Hidden at after:scale-0 after:opacity-0 and revealed only in the loading state, so there is nothing to clean up between requests.
The width problem is designed out. The CSS original measured offsetWidth and wrote an inline style.width to stop the button shrinking when its label faded. min-w-40 makes the same guarantee declaratively — and survives a label change, which the measured version did not.
One state value, three visuals. data-state drives the label fade, the spinner, and the success fill plus checkmark draw. Because it is one attribute, the loading and success states cannot both apply, which is what makes the disabled handling trivial.
Make it yours
- Spinner tint:
after:border-t-white→ any accent; the outline variant already swaps both border colours. - Request duration: the controller's first
setTimeout. - Add
data-state="error"with a red fill and an × icon — a fourth branch, not a fourth boolean.
Gotchas — read before shipping
- A pseudo-element cannot animate from
display:none. Useopacityandscale, as here. - Keep the spinner at 18-20px. Below 16px, a rotating
border-radius:50%ring shows sub-pixel jitter on Safari. - Re-enable the button in the same handler that clears the state, or a failed request leaves a permanently dead control.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Keyframe rotation and opacity transitions are universally supported; the floor is Tailwind v4's generated sheet.