06 / 51
All 51 in 51 Tailwind CSS Buttons
- 01Tailwind Submit Button (Form Primary)
- 02Tailwind Primary and Secondary Button Pair
- 03Tailwind Ghost / Outline Button
- 04Tailwind Icon-Only Button with Accessible Tooltip
- 05Tailwind Gradient CTA Button
- 06Tailwind Loading Spinner Button (State Transition)
- 07Tailwind Disabled Button State (Full State Reference)
- 08Tailwind Button Group Component (Segmented Radio-Like)
- 09Tailwind Add to Cart Button
- 10Tailwind Download Progress Button
- 11Tailwind Like Heart Button
- 12Tailwind Confirm Delete Button
- 13Tailwind Play Pause Button
- 14Tailwind Subscribe Confetti Button
- 15Tailwind Magnetic Mercury Ripple Button
- 16Tailwind Brutalist Glitch Button
- 17Tailwind Neon Plasma Burst Button
- 18Tailwind Clay Press Button
- 19Tailwind Editorial Ink Spread Button
- 20Tailwind Particle Shard Burst Button
- 21Tailwind Brushed Brass Plate Button
- 22Tailwind Washi Paper Crane Button
- 23Tailwind Frosted Ice Cube Button
- 24Tailwind Vintage Typewriter Key Button
- 25Tailwind Lacquered Walnut Wood Button
- 26Tailwind Pharma Gel Capsule Button
- 27Tailwind Aged Copper Coin Button
- 28Tailwind Origami Fortune Cookie Button
- 29Tailwind Holographic Foil Sticker Button
- 30Tailwind Brutalist Concrete Slab Button
- 31Tailwind Mercury Bead Morph Button
- 32Tailwind Soft Pill Modern SaaS Button
- 33Tailwind Masking Tape Label Button
- 34Tailwind Obsidian Glass Shard Button
- 35Tailwind Wine Velvet Ribbon Button
- 36Tailwind LED Segment Display Button
- 37Tailwind Cassette Mixtape Label Button
- 38Tailwind Chalkboard Menu Button
- 39Tailwind Vinyl Record Sleeve Ripple Button
- 40Tailwind Polaroid Snapshot Button
- 41Tailwind Airline Boarding Pass Button
- 42Tailwind Aged Brass Subway Token Button
- 43Tailwind Vintage Rx Pharmacy Label Button
- 44Tailwind Bakelite Radio Dial Toggle Button
- 45Tailwind Regal Postage Stamp Button
- 46Tailwind Victorian Cigar Band Foil Button
- 47Tailwind Vintage Calculator Key Button
- 48Tailwind Toolbox Latch Spotlight Button
- 49Tailwind Receipt Paper Total Count-Up Button
- 50Tailwind IBM Punch Card Magnetic Follow Button
- 51Tailwind Brass Periscope Lens Button
Tailwind Loading Spinner Button (State Transition)
Idle → pending → success in a button that never changes width, because all three labels share one grid cell. The port's headline is group-data-[state=busy]:: the CSS version needed three descendant selectors keyed off [data-state], and Tailwind states each slot's own visibility condition on the slot itself.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<div class="tb-06 w-full min-h-svh grid place-items-center bg-tb06-bg font-tb06-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" aria-describedby="tb-06-live" class="group relative cursor-pointer grid items-center min-h-13 px-7 border-0 rounded-[0.8rem] text-base font-semibold text-white bg-tb06-accent shadow-[0_10px_24px_-12px_var(--color-tb06-accent-glow)] transition-[background-color,translate,box-shadow] duration-300 ease-tb06-out data-[state=done]:bg-tb06-ok data-[state=done]:shadow-[0_10px_24px_-12px_var(--color-tb06-ok-glow)] data-[state=busy]:cursor-progress hover:data-[state=idle]:-translate-y-0.5 hover:data-[state=idle]:bg-tb06-accent-deep active:data-[state=idle]:translate-y-px focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb06-accent disabled:opacity-90 motion-reduce:transition-none">
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=idle]:opacity-100 group-data-[state=idle]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Save changes</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=busy]:opacity-100 group-data-[state=busy]:scale-100 motion-reduce:transition-none">
<span aria-hidden="true" class="size-[1.1rem] shrink-0 rounded-full bg-[conic-gradient(from_0deg,transparent_0_22%,currentColor)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-2.5px),#000_calc(100%-2.5px))] animate-tb06-spin motion-reduce:animate-tb06-pulse motion-reduce:bg-current motion-reduce:[mask-image:none]"></span>
<span>Saving</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=done]:opacity-100 group-data-[state=done]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="tb-06__tick size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Saved</span>
</span>
</button>
<span id="tb-06-live" role="status" aria-live="polite" class="sr-only"></span>
</div><div class="tb-06 w-full min-h-svh grid place-items-center bg-tb06-bg font-tb06-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" aria-describedby="tb-06-live" class="group relative cursor-pointer grid items-center min-h-13 px-7 border-0 rounded-[0.8rem] text-base font-semibold text-white bg-tb06-accent shadow-[0_10px_24px_-12px_var(--color-tb06-accent-glow)] transition-[background-color,translate,box-shadow] duration-300 ease-tb06-out data-[state=done]:bg-tb06-ok data-[state=done]:shadow-[0_10px_24px_-12px_var(--color-tb06-ok-glow)] data-[state=busy]:cursor-progress hover:data-[state=idle]:-translate-y-0.5 hover:data-[state=idle]:bg-tb06-accent-deep active:data-[state=idle]:translate-y-px focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb06-accent disabled:opacity-90 motion-reduce:transition-none">
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=idle]:opacity-100 group-data-[state=idle]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Save changes</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=busy]:opacity-100 group-data-[state=busy]:scale-100 motion-reduce:transition-none">
<span aria-hidden="true" class="size-[1.1rem] shrink-0 rounded-full bg-[conic-gradient(from_0deg,transparent_0_22%,currentColor)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-2.5px),#000_calc(100%-2.5px))] animate-tb06-spin motion-reduce:animate-tb06-pulse motion-reduce:bg-current motion-reduce:[mask-image:none]"></span>
<span>Saving</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=done]:opacity-100 group-data-[state=done]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="tb-06__tick size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Saved</span>
</span>
</button>
<span id="tb-06-live" role="status" aria-live="polite" class="sr-only"></span>
</div>@import "tailwindcss";
@theme {
--color-tb06-bg: #eef1f5;
--color-tb06-bg: oklch(0.955 0.008 250);
--color-tb06-accent: #1d4ed8;
--color-tb06-accent: oklch(0.48 0.22 265);
--color-tb06-accent-deep: #1740b4;
--color-tb06-accent-deep: oklch(0.42 0.2 265);
--color-tb06-accent-glow: #1d4ed8e6;
--color-tb06-accent-glow: oklch(0.48 0.22 265 / 0.9);
--color-tb06-ok: #059669;
--color-tb06-ok: oklch(0.56 0.13 162);
--color-tb06-ok-glow: #059669e6;
--color-tb06-ok-glow: oklch(0.56 0.13 162 / 0.9);
--font-tb06-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb06-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb06-spin: tb-06-spin 0.7s linear infinite;
--animate-tb06-pulse: tb-06-pulse 1s ease-in-out infinite;
}
@keyframes tb-06-spin {
to {
rotate: 1turn;
}
}
@keyframes tb-06-pulse {
50% {
opacity: 0.25;
}
}
@keyframes tb-06-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-06 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
}
/* Safari ≤15.3 mask shim — the utility emits the unprefixed property only. */
.tb-06 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
}
/* SVG path geometry: dasharray/dashoffset have no utility form. */
.tb-06__tick path {
stroke-dasharray: 26;
stroke-dashoffset: 26;
}
.tb-06 button[data-state="done"] .tb-06__tick path {
animation: tb-06-draw 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.tb-06__tick path {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: dark) {
.tb-06:not([data-theme="light"]) {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
}
.tb-06[data-theme="dark"] {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
@media (forced-colors: active) {
.tb-06 button {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
}
}@import "tailwindcss";
@theme {
--color-tb06-bg: #eef1f5;
--color-tb06-bg: oklch(0.955 0.008 250);
--color-tb06-accent: #1d4ed8;
--color-tb06-accent: oklch(0.48 0.22 265);
--color-tb06-accent-deep: #1740b4;
--color-tb06-accent-deep: oklch(0.42 0.2 265);
--color-tb06-accent-glow: #1d4ed8e6;
--color-tb06-accent-glow: oklch(0.48 0.22 265 / 0.9);
--color-tb06-ok: #059669;
--color-tb06-ok: oklch(0.56 0.13 162);
--color-tb06-ok-glow: #059669e6;
--color-tb06-ok-glow: oklch(0.56 0.13 162 / 0.9);
--font-tb06-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb06-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb06-spin: tb-06-spin 0.7s linear infinite;
--animate-tb06-pulse: tb-06-pulse 1s ease-in-out infinite;
}
@keyframes tb-06-spin {
to {
rotate: 1turn;
}
}
@keyframes tb-06-pulse {
50% {
opacity: 0.25;
}
}
@keyframes tb-06-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-06 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
}
/* Safari ≤15.3 mask shim — the utility emits the unprefixed property only. */
.tb-06 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
}
/* SVG path geometry: dasharray/dashoffset have no utility form. */
.tb-06__tick path {
stroke-dasharray: 26;
stroke-dashoffset: 26;
}
.tb-06 button[data-state="done"] .tb-06__tick path {
animation: tb-06-draw 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.tb-06__tick path {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: dark) {
.tb-06:not([data-theme="light"]) {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
}
.tb-06[data-theme="dark"] {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
@media (forced-colors: active) {
.tb-06 button {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
}
}(() => {
const btn = document.querySelector('.tb-06 button');
const live = document.getElementById('tb-06-live');
if (!btn) return;
const set = (state, msg) => {
btn.dataset.state = state;
btn.disabled = state === 'busy';
btn.setAttribute('aria-busy', String(state === 'busy'));
if (msg !== undefined) live.textContent = msg;
};
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
set('busy', 'Saving changes');
// Replace both timeouts with your real await fetch(...) resolution.
setTimeout(() => set('done', 'Changes saved'), 1500);
setTimeout(() => set('idle', ''), 3200);
});
})();(() => {
const btn = document.querySelector('.tb-06 button');
const live = document.getElementById('tb-06-live');
if (!btn) return;
const set = (state, msg) => {
btn.dataset.state = state;
btn.disabled = state === 'busy';
btn.setAttribute('aria-busy', String(state === 'busy'));
if (msg !== undefined) live.textContent = msg;
};
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
set('busy', 'Saving changes');
// Replace both timeouts with your real await fetch(...) resolution.
setTimeout(() => set('done', 'Changes saved'), 1500);
setTimeout(() => set('idle', ''), 3200);
});
})();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-tb06-bg: #eef1f5;
--color-tb06-bg: oklch(0.955 0.008 250);
--color-tb06-accent: #1d4ed8;
--color-tb06-accent: oklch(0.48 0.22 265);
--color-tb06-accent-deep: #1740b4;
--color-tb06-accent-deep: oklch(0.42 0.2 265);
--color-tb06-accent-glow: #1d4ed8e6;
--color-tb06-accent-glow: oklch(0.48 0.22 265 / 0.9);
--color-tb06-ok: #059669;
--color-tb06-ok: oklch(0.56 0.13 162);
--color-tb06-ok-glow: #059669e6;
--color-tb06-ok-glow: oklch(0.56 0.13 162 / 0.9);
--font-tb06-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb06-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb06-spin: tb-06-spin 0.7s linear infinite;
--animate-tb06-pulse: tb-06-pulse 1s ease-in-out infinite;
}
@keyframes tb-06-spin {
to {
rotate: 1turn;
}
}
@keyframes tb-06-pulse {
50% {
opacity: 0.25;
}
}
@keyframes tb-06-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-06 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
}
/* Safari ≤15.3 mask shim — the utility emits the unprefixed property only. */
.tb-06 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
}
/* SVG path geometry: dasharray/dashoffset have no utility form. */
.tb-06__tick path {
stroke-dasharray: 26;
stroke-dashoffset: 26;
}
.tb-06 button[data-state="done"] .tb-06__tick path {
animation: tb-06-draw 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.tb-06__tick path {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: dark) {
.tb-06:not([data-theme="light"]) {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
}
.tb-06[data-theme="dark"] {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
@media (forced-colors: active) {
.tb-06 button {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
}
}@import "tailwindcss";
@theme {
--color-tb06-bg: #eef1f5;
--color-tb06-bg: oklch(0.955 0.008 250);
--color-tb06-accent: #1d4ed8;
--color-tb06-accent: oklch(0.48 0.22 265);
--color-tb06-accent-deep: #1740b4;
--color-tb06-accent-deep: oklch(0.42 0.2 265);
--color-tb06-accent-glow: #1d4ed8e6;
--color-tb06-accent-glow: oklch(0.48 0.22 265 / 0.9);
--color-tb06-ok: #059669;
--color-tb06-ok: oklch(0.56 0.13 162);
--color-tb06-ok-glow: #059669e6;
--color-tb06-ok-glow: oklch(0.56 0.13 162 / 0.9);
--font-tb06-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb06-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb06-spin: tb-06-spin 0.7s linear infinite;
--animate-tb06-pulse: tb-06-pulse 1s ease-in-out infinite;
}
@keyframes tb-06-spin {
to {
rotate: 1turn;
}
}
@keyframes tb-06-pulse {
50% {
opacity: 0.25;
}
}
@keyframes tb-06-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-06 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
}
/* Safari ≤15.3 mask shim — the utility emits the unprefixed property only. */
.tb-06 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
}
/* SVG path geometry: dasharray/dashoffset have no utility form. */
.tb-06__tick path {
stroke-dasharray: 26;
stroke-dashoffset: 26;
}
.tb-06 button[data-state="done"] .tb-06__tick path {
animation: tb-06-draw 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.tb-06__tick path {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: dark) {
.tb-06:not([data-theme="light"]) {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
}
.tb-06[data-theme="dark"] {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
@media (forced-colors: active) {
.tb-06 button {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
}
}<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=Outfit: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=Outfit:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- Utilities: the grid stack, all three slot conditions, the button's state colours, the spinner's gradient and mask, and the reduced-motion swap. CSS: the keyframes, the
-webkit-maskshim and the tick'sstroke-dasharray/dashoffset— SVG path geometry has no utility form and should not. data-[state=done]:bg-tb06-okon the button andgroup-data-[state=done]:opacity-100on the slot are the same condition read from two sides. That symmetry is what makes the ported state machine readable.- Two animations as
@themetokens (--animate-tb06-spin,--animate-tb06-pulse) let the reduced-motion fallback be a one-class swap:motion-reduce:animate-tb06-pulse. In CSS it needed a nested keyframe block inside the media query. sr-onlyreplaces the hand-writtenclip-path:inset(50%)visually-hidden block. One class, and it is the same technique.
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 Button (State Transition)
Source: https://codefronts.com/components/tailwind-css-buttons/tailwind-loading-spinner-button-state-transition/
Idle → pending → success in a button that never changes width, because all three labels share one grid cell. The port's headline is group-data-[state=busy]:: the CSS version needed three descendant selectors keyed off [data-state], and Tailwind states each slot's own visibility condition on the slot itself.
## HTML
```html
<div class="tb-06 w-full min-h-svh grid place-items-center bg-tb06-bg font-tb06-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" aria-describedby="tb-06-live" class="group relative cursor-pointer grid items-center min-h-13 px-7 border-0 rounded-[0.8rem] text-base font-semibold text-white bg-tb06-accent shadow-[0_10px_24px_-12px_var(--color-tb06-accent-glow)] transition-[background-color,translate,box-shadow] duration-300 ease-tb06-out data-[state=done]:bg-tb06-ok data-[state=done]:shadow-[0_10px_24px_-12px_var(--color-tb06-ok-glow)] data-[state=busy]:cursor-progress hover:data-[state=idle]:-translate-y-0.5 hover:data-[state=idle]:bg-tb06-accent-deep active:data-[state=idle]:translate-y-px focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb06-accent disabled:opacity-90 motion-reduce:transition-none">
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=idle]:opacity-100 group-data-[state=idle]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Save changes</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=busy]:opacity-100 group-data-[state=busy]:scale-100 motion-reduce:transition-none">
<span aria-hidden="true" class="size-[1.1rem] shrink-0 rounded-full bg-[conic-gradient(from_0deg,transparent_0_22%,currentColor)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-2.5px),#000_calc(100%-2.5px))] animate-tb06-spin motion-reduce:animate-tb06-pulse motion-reduce:bg-current motion-reduce:[mask-image:none]"></span>
<span>Saving</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=done]:opacity-100 group-data-[state=done]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="tb-06__tick size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Saved</span>
</span>
</button>
<span id="tb-06-live" role="status" aria-live="polite" class="sr-only"></span>
</div>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tb06-bg: #eef1f5;
--color-tb06-bg: oklch(0.955 0.008 250);
--color-tb06-accent: #1d4ed8;
--color-tb06-accent: oklch(0.48 0.22 265);
--color-tb06-accent-deep: #1740b4;
--color-tb06-accent-deep: oklch(0.42 0.2 265);
--color-tb06-accent-glow: #1d4ed8e6;
--color-tb06-accent-glow: oklch(0.48 0.22 265 / 0.9);
--color-tb06-ok: #059669;
--color-tb06-ok: oklch(0.56 0.13 162);
--color-tb06-ok-glow: #059669e6;
--color-tb06-ok-glow: oklch(0.56 0.13 162 / 0.9);
--font-tb06-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb06-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb06-spin: tb-06-spin 0.7s linear infinite;
--animate-tb06-pulse: tb-06-pulse 1s ease-in-out infinite;
}
@keyframes tb-06-spin {
to {
rotate: 1turn;
}
}
@keyframes tb-06-pulse {
50% {
opacity: 0.25;
}
}
@keyframes tb-06-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-06 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
}
/* Safari ≤15.3 mask shim — the utility emits the unprefixed property only. */
.tb-06 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
}
/* SVG path geometry: dasharray/dashoffset have no utility form. */
.tb-06__tick path {
stroke-dasharray: 26;
stroke-dashoffset: 26;
}
.tb-06 button[data-state="done"] .tb-06__tick path {
animation: tb-06-draw 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.tb-06__tick path {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: dark) {
.tb-06:not([data-theme="light"]) {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
}
.tb-06[data-theme="dark"] {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
@media (forced-colors: active) {
.tb-06 button {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
}
}
```
## JavaScript
```js
(() => {
const btn = document.querySelector('.tb-06 button');
const live = document.getElementById('tb-06-live');
if (!btn) return;
const set = (state, msg) => {
btn.dataset.state = state;
btn.disabled = state === 'busy';
btn.setAttribute('aria-busy', String(state === 'busy'));
if (msg !== undefined) live.textContent = msg;
};
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
set('busy', 'Saving changes');
// Replace both timeouts with your real await fetch(...) resolution.
setTimeout(() => set('done', 'Changes saved'), 1500);
setTimeout(() => set('idle', ''), 3200);
});
})();
```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 Button (State Transition)
Source: https://codefronts.com/components/tailwind-css-buttons/tailwind-loading-spinner-button-state-transition/
Idle → pending → success in a button that never changes width, because all three labels share one grid cell. The port's headline is group-data-[state=busy]:: the CSS version needed three descendant selectors keyed off [data-state], and Tailwind states each slot's own visibility condition on the slot itself.
## HTML
```html
<div class="tb-06 w-full min-h-svh grid place-items-center bg-tb06-bg font-tb06-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" aria-describedby="tb-06-live" class="group relative cursor-pointer grid items-center min-h-13 px-7 border-0 rounded-[0.8rem] text-base font-semibold text-white bg-tb06-accent shadow-[0_10px_24px_-12px_var(--color-tb06-accent-glow)] transition-[background-color,translate,box-shadow] duration-300 ease-tb06-out data-[state=done]:bg-tb06-ok data-[state=done]:shadow-[0_10px_24px_-12px_var(--color-tb06-ok-glow)] data-[state=busy]:cursor-progress hover:data-[state=idle]:-translate-y-0.5 hover:data-[state=idle]:bg-tb06-accent-deep active:data-[state=idle]:translate-y-px focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb06-accent disabled:opacity-90 motion-reduce:transition-none">
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=idle]:opacity-100 group-data-[state=idle]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Save changes</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=busy]:opacity-100 group-data-[state=busy]:scale-100 motion-reduce:transition-none">
<span aria-hidden="true" class="size-[1.1rem] shrink-0 rounded-full bg-[conic-gradient(from_0deg,transparent_0_22%,currentColor)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-2.5px),#000_calc(100%-2.5px))] animate-tb06-spin motion-reduce:animate-tb06-pulse motion-reduce:bg-current motion-reduce:[mask-image:none]"></span>
<span>Saving</span>
</span>
<span class="col-start-1 row-start-1 flex items-center justify-center gap-2 opacity-0 scale-90 transition-[opacity,scale] duration-[220ms] ease-tb06-out group-data-[state=done]:opacity-100 group-data-[state=done]:scale-100 motion-reduce:transition-none">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="tb-06__tick size-[1.1rem]"><path d="M5 12.5 10 17.5 19 6.5"/></svg>
<span>Saved</span>
</span>
</button>
<span id="tb-06-live" role="status" aria-live="polite" class="sr-only"></span>
</div>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tb06-bg: #eef1f5;
--color-tb06-bg: oklch(0.955 0.008 250);
--color-tb06-accent: #1d4ed8;
--color-tb06-accent: oklch(0.48 0.22 265);
--color-tb06-accent-deep: #1740b4;
--color-tb06-accent-deep: oklch(0.42 0.2 265);
--color-tb06-accent-glow: #1d4ed8e6;
--color-tb06-accent-glow: oklch(0.48 0.22 265 / 0.9);
--color-tb06-ok: #059669;
--color-tb06-ok: oklch(0.56 0.13 162);
--color-tb06-ok-glow: #059669e6;
--color-tb06-ok-glow: oklch(0.56 0.13 162 / 0.9);
--font-tb06-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb06-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb06-spin: tb-06-spin 0.7s linear infinite;
--animate-tb06-pulse: tb-06-pulse 1s ease-in-out infinite;
}
@keyframes tb-06-spin {
to {
rotate: 1turn;
}
}
@keyframes tb-06-pulse {
50% {
opacity: 0.25;
}
}
@keyframes tb-06-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-06 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
}
/* Safari ≤15.3 mask shim — the utility emits the unprefixed property only. */
.tb-06 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
}
/* SVG path geometry: dasharray/dashoffset have no utility form. */
.tb-06__tick path {
stroke-dasharray: 26;
stroke-dashoffset: 26;
}
.tb-06 button[data-state="done"] .tb-06__tick path {
animation: tb-06-draw 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.tb-06__tick path {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: dark) {
.tb-06:not([data-theme="light"]) {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
}
.tb-06[data-theme="dark"] {
--color-tb06-bg: oklch(0.14 0.012 255);
--color-tb06-accent: oklch(0.6 0.19 265);
--color-tb06-ok: oklch(0.68 0.15 162);
}
@media (forced-colors: active) {
.tb-06 button {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
}
}
```
## JavaScript
```js
(() => {
const btn = document.querySelector('.tb-06 button');
const live = document.getElementById('tb-06-live');
if (!btn) return;
const set = (state, msg) => {
btn.dataset.state = state;
btn.disabled = state === 'busy';
btn.setAttribute('aria-busy', String(state === 'busy'));
if (msg !== undefined) live.textContent = msg;
};
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
set('busy', 'Saving changes');
// Replace both timeouts with your real await fetch(...) resolution.
setTimeout(() => set('done', 'Changes saved'), 1500);
setTimeout(() => set('idle', ''), 3200);
});
})();
```How this works
The state selector becomes a group-data variant. The CSS version wrote three rules like .btn[data-state='busy'] .slot--busy. Tailwind moves each condition onto the element it governs:
<button data-state="idle" class="group grid …">
<span class="col-start-1 row-start-1 opacity-0 scale-90
group-data-[state=idle]:opacity-100 group-data-[state=idle]:scale-100">…</span>Three slots, three self-describing class lists, and the button's own colour change rides the same variant: data-[state=done]:bg-tb06-ok.
The spinner stays gradient + mask, both as arbitrary values, because a conic gradient clipped into a 2.5px ring is one rendering recipe — splitting it across utilities would not make it more tunable.
The JS is unchanged in shape: set data-state, toggle disabled and aria-busy, write the outcome into an aria-live="polite" span. Motion is decoration; the announcement is the feedback.
Make it yours
- Wire to a real request: replace the
setTimeoutpair with yourawait fetch()resolution and setdata-statefrom the branch. - Add an
errorstate by copying a slot, changing the icon and addinggroup-data-[state=error]:utilities plus onedata-[state=error]:bg-tb06-danger. - Hold success longer by raising the second timeout; 1200–1600ms is the readable range.
- Thicker spinner: change
2.5pxin both mask stops — they must match. - Destructive flows: stay disabled after success and let the parent view navigate instead of resetting.
Gotchas — read before shipping
- Disabled buttons leave the tab order, so focus is lost mid-flow. Either use
aria-disabledand block in JS, or move focus deliberately after the response. - A spinner with no text change is silent to assistive tech.
aria-busy="true"plus a polite live region is the minimum. - Never animate
widthbetween states — stack the slots. Width animation reflows every frame. group-data-[state=busy]:requires thegroupclass on the button. Forgetting it is a silent no-op, not an error.- Under
motion-reduce:the spin must be replaced, not removed — a frozen ring reads as a broken button. This port swaps to an opacity pulse.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 120+ | 16.4+ | 128+ | 120+ |
The mask shorthand needs Chrome 120 / Safari 15.4 / Firefox 53; the -webkit- shim is declared in the CSS block for Safari ≤ 15.3. conic-gradient, grid stacking and data-attribute variants are otherwise universal.