10 / 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 Download Progress Button
A download button that becomes its own progress bar: the fill sweeps behind the label, a conic ring mirrors it on the icon, and the percentage is a registered custom property so the digits interpolate instead of stepping. One variable drives three visuals — which is exactly the case where Tailwind's job is to expose the variable, not to own it.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<div class="tb-10 w-full min-h-svh grid place-items-center font-tb10-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Download the 2026 report" class="tb-10__btn group relative overflow-hidden isolate cursor-pointer w-[min(100%,20rem)] min-h-14 px-6 border border-tb10-line rounded-[0.9rem] text-[0.9375rem] font-semibold text-left text-tb10-ink bg-tb10-surface transition-[border-color,translate] duration-300 ease-tb10-out hover:-translate-y-0.5 hover:border-tb10-accent-line focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb10-accent data-[state=busy]:cursor-progress data-[state=done]:border-tb10-ok motion-reduce:transition-none">
<span aria-hidden="true" class="absolute inset-0 -z-10 origin-left scale-x-[calc(var(--tb-10-p)/100%)] bg-[linear-gradient(90deg,var(--color-tb10-fill-a),var(--color-tb10-fill-b))] transition-[scale,background] duration-[400ms] ease-tb10-out group-data-[state=done]:bg-[linear-gradient(90deg,var(--color-tb10-ok-a),var(--color-tb10-ok-b))] motion-reduce:transition-none"></span>
<span class="grid grid-flow-col items-center gap-3 [grid-template-columns:auto_1fr_auto]">
<span aria-hidden="true" class="relative grid place-items-center size-6 text-tb10-accent group-data-[state=done]:text-tb10-ok">
<span class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 bg-[conic-gradient(currentColor_var(--tb-10-p),var(--color-tb10-ring-track)_0)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-3px),#000_calc(100%-3px))] group-data-[state=busy]:opacity-100"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="tb-10__svg size-[1.15rem]">
<path class="tb-10__arrow" d="M12 4v10m0 0 4-4m-4 4-4-4"/>
<path class="tb-10__tray" d="M5 18.5h14"/>
<path class="tb-10__tick" d="M6.5 12.5 10.5 16.5 17.5 8"/>
</svg>
</span>
<span class="tb-10__label">Download report</span>
<span aria-hidden="true" class="tb-10__pct text-[0.8125rem] font-bold tabular-nums text-tb10-accent opacity-0 transition-opacity duration-300 group-data-[state=busy]:opacity-100">0%</span>
</span>
</button>
</div><div class="tb-10 w-full min-h-svh grid place-items-center font-tb10-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Download the 2026 report" class="tb-10__btn group relative overflow-hidden isolate cursor-pointer w-[min(100%,20rem)] min-h-14 px-6 border border-tb10-line rounded-[0.9rem] text-[0.9375rem] font-semibold text-left text-tb10-ink bg-tb10-surface transition-[border-color,translate] duration-300 ease-tb10-out hover:-translate-y-0.5 hover:border-tb10-accent-line focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb10-accent data-[state=busy]:cursor-progress data-[state=done]:border-tb10-ok motion-reduce:transition-none">
<span aria-hidden="true" class="absolute inset-0 -z-10 origin-left scale-x-[calc(var(--tb-10-p)/100%)] bg-[linear-gradient(90deg,var(--color-tb10-fill-a),var(--color-tb10-fill-b))] transition-[scale,background] duration-[400ms] ease-tb10-out group-data-[state=done]:bg-[linear-gradient(90deg,var(--color-tb10-ok-a),var(--color-tb10-ok-b))] motion-reduce:transition-none"></span>
<span class="grid grid-flow-col items-center gap-3 [grid-template-columns:auto_1fr_auto]">
<span aria-hidden="true" class="relative grid place-items-center size-6 text-tb10-accent group-data-[state=done]:text-tb10-ok">
<span class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 bg-[conic-gradient(currentColor_var(--tb-10-p),var(--color-tb10-ring-track)_0)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-3px),#000_calc(100%-3px))] group-data-[state=busy]:opacity-100"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="tb-10__svg size-[1.15rem]">
<path class="tb-10__arrow" d="M12 4v10m0 0 4-4m-4 4-4-4"/>
<path class="tb-10__tray" d="M5 18.5h14"/>
<path class="tb-10__tick" d="M6.5 12.5 10.5 16.5 17.5 8"/>
</svg>
</span>
<span class="tb-10__label">Download report</span>
<span aria-hidden="true" class="tb-10__pct text-[0.8125rem] font-bold tabular-nums text-tb10-accent opacity-0 transition-opacity duration-300 group-data-[state=busy]:opacity-100">0%</span>
</span>
</button>
</div>@import "tailwindcss";
@property --tb-10-p {
syntax: '<percentage>';
inherits: true;
initial-value: 0%;
}
@theme {
--color-tb10-bg: #0b0f14;
--color-tb10-bg: oklch(0.16 0.02 250);
--color-tb10-surface: #151b23;
--color-tb10-surface: oklch(0.24 0.02 250);
--color-tb10-line: #26303b;
--color-tb10-line: oklch(0.33 0.02 250);
--color-tb10-ink: #e9eef5;
--color-tb10-ink: oklch(0.94 0.008 250);
--color-tb10-accent: #38bdf8;
--color-tb10-accent: oklch(0.78 0.13 222);
--color-tb10-accent-line: #2a7fa8;
--color-tb10-accent-line: oklch(0.58 0.09 222);
--color-tb10-ok: #34d399;
--color-tb10-ok: oklch(0.78 0.14 165);
--color-tb10-fill-a: #38bdf842;
--color-tb10-fill-a: oklch(0.78 0.13 222 / 0.26);
--color-tb10-fill-b: #38bdf86b;
--color-tb10-fill-b: oklch(0.78 0.13 222 / 0.42);
--color-tb10-ok-a: #34d39938;
--color-tb10-ok-a: oklch(0.78 0.14 165 / 0.22);
--color-tb10-ok-b: #34d39957;
--color-tb10-ok-b: oklch(0.78 0.14 165 / 0.34);
--color-tb10-ring-track: #38bdf82e;
--color-tb10-ring-track: oklch(0.78 0.13 222 / 0.18);
--font-tb10-sans: "Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb10-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb10-bob: tb-10-bob 1.1s ease-in-out infinite;
}
@keyframes tb-10-bob {
50% {
translate: 0 2px;
opacity: 0.5;
}
}
@keyframes tb-10-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: radial-gradient(50rem 30rem at 50% 0%, var(--color-tb10-ring-track), transparent 70%), var(--color-tb10-bg);
}
.tb-10__btn {
--tb-10-p: 0%;
}
/* Safari ≤15.3 mask shim. */
.tb-10 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}
/* SVG path state: geometry and per-path visibility. No utility form. */
.tb-10__tick {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: var(--animate-tb10-bob);
}
.tb-10__btn[data-state="done"] :is(.tb-10__arrow, .tb-10__tray) {
opacity: 0;
}
.tb-10__btn[data-state="done"] .tb-10__tick {
animation: tb-10-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* The animated counter: Chrome/Safari only, with the JS-written text as fallback. */
@supports (counter-reset: x calc(1 / 1)) {
.tb-10__pct {
counter-reset: tb-10-n calc(var(--tb-10-p) / 1%);
font-size: 0;
}
.tb-10__pct::after {
content: counter(tb-10-n) "%";
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: none;
}
.tb-10__tick {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: light) {
.tb-10:not([data-theme="dark"]) {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
--color-tb10-ok: oklch(0.56 0.13 162);
}
}
.tb-10[data-theme="light"] {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
}
@media (forced-colors: active) {
.tb-10__btn {
border: 1px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.tb-10__btn > span:first-child {
background: Highlight;
}
}@import "tailwindcss";
@property --tb-10-p {
syntax: '<percentage>';
inherits: true;
initial-value: 0%;
}
@theme {
--color-tb10-bg: #0b0f14;
--color-tb10-bg: oklch(0.16 0.02 250);
--color-tb10-surface: #151b23;
--color-tb10-surface: oklch(0.24 0.02 250);
--color-tb10-line: #26303b;
--color-tb10-line: oklch(0.33 0.02 250);
--color-tb10-ink: #e9eef5;
--color-tb10-ink: oklch(0.94 0.008 250);
--color-tb10-accent: #38bdf8;
--color-tb10-accent: oklch(0.78 0.13 222);
--color-tb10-accent-line: #2a7fa8;
--color-tb10-accent-line: oklch(0.58 0.09 222);
--color-tb10-ok: #34d399;
--color-tb10-ok: oklch(0.78 0.14 165);
--color-tb10-fill-a: #38bdf842;
--color-tb10-fill-a: oklch(0.78 0.13 222 / 0.26);
--color-tb10-fill-b: #38bdf86b;
--color-tb10-fill-b: oklch(0.78 0.13 222 / 0.42);
--color-tb10-ok-a: #34d39938;
--color-tb10-ok-a: oklch(0.78 0.14 165 / 0.22);
--color-tb10-ok-b: #34d39957;
--color-tb10-ok-b: oklch(0.78 0.14 165 / 0.34);
--color-tb10-ring-track: #38bdf82e;
--color-tb10-ring-track: oklch(0.78 0.13 222 / 0.18);
--font-tb10-sans: "Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb10-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb10-bob: tb-10-bob 1.1s ease-in-out infinite;
}
@keyframes tb-10-bob {
50% {
translate: 0 2px;
opacity: 0.5;
}
}
@keyframes tb-10-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: radial-gradient(50rem 30rem at 50% 0%, var(--color-tb10-ring-track), transparent 70%), var(--color-tb10-bg);
}
.tb-10__btn {
--tb-10-p: 0%;
}
/* Safari ≤15.3 mask shim. */
.tb-10 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}
/* SVG path state: geometry and per-path visibility. No utility form. */
.tb-10__tick {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: var(--animate-tb10-bob);
}
.tb-10__btn[data-state="done"] :is(.tb-10__arrow, .tb-10__tray) {
opacity: 0;
}
.tb-10__btn[data-state="done"] .tb-10__tick {
animation: tb-10-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* The animated counter: Chrome/Safari only, with the JS-written text as fallback. */
@supports (counter-reset: x calc(1 / 1)) {
.tb-10__pct {
counter-reset: tb-10-n calc(var(--tb-10-p) / 1%);
font-size: 0;
}
.tb-10__pct::after {
content: counter(tb-10-n) "%";
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: none;
}
.tb-10__tick {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: light) {
.tb-10:not([data-theme="dark"]) {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
--color-tb10-ok: oklch(0.56 0.13 162);
}
}
.tb-10[data-theme="light"] {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
}
@media (forced-colors: active) {
.tb-10__btn {
border: 1px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.tb-10__btn > span:first-child {
background: Highlight;
}
}(() => {
const btn = document.querySelector('.tb-10__btn');
const pct = document.querySelector('.tb-10__pct');
const label = document.querySelector('.tb-10__label');
if (!btn) return;
let timer, p = 0;
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'busy';
p = 0;
// Swap this fake ticker for your XHR/fetch progress events.
const step = () => {
p = Math.min(100, p + Math.random() * 7 + 2);
btn.style.setProperty('--tb-10-p', p + '%');
btn.setAttribute('aria-valuenow', String(Math.round(p)));
pct.textContent = Math.round(p) + '%';
if (p < 100) { timer = setTimeout(step, 140); }
else { btn.dataset.state = 'done'; label.textContent = 'Report downloaded'; }
};
step();
});
window.addEventListener('pagehide', () => clearTimeout(timer));
})();(() => {
const btn = document.querySelector('.tb-10__btn');
const pct = document.querySelector('.tb-10__pct');
const label = document.querySelector('.tb-10__label');
if (!btn) return;
let timer, p = 0;
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'busy';
p = 0;
// Swap this fake ticker for your XHR/fetch progress events.
const step = () => {
p = Math.min(100, p + Math.random() * 7 + 2);
btn.style.setProperty('--tb-10-p', p + '%');
btn.setAttribute('aria-valuenow', String(Math.round(p)));
pct.textContent = Math.round(p) + '%';
if (p < 100) { timer = setTimeout(step, 140); }
else { btn.dataset.state = 'done'; label.textContent = 'Report downloaded'; }
};
step();
});
window.addEventListener('pagehide', () => clearTimeout(timer));
})();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";
@property --tb-10-p {
syntax: '<percentage>';
inherits: true;
initial-value: 0%;
}
@theme {
--color-tb10-bg: #0b0f14;
--color-tb10-bg: oklch(0.16 0.02 250);
--color-tb10-surface: #151b23;
--color-tb10-surface: oklch(0.24 0.02 250);
--color-tb10-line: #26303b;
--color-tb10-line: oklch(0.33 0.02 250);
--color-tb10-ink: #e9eef5;
--color-tb10-ink: oklch(0.94 0.008 250);
--color-tb10-accent: #38bdf8;
--color-tb10-accent: oklch(0.78 0.13 222);
--color-tb10-accent-line: #2a7fa8;
--color-tb10-accent-line: oklch(0.58 0.09 222);
--color-tb10-ok: #34d399;
--color-tb10-ok: oklch(0.78 0.14 165);
--color-tb10-fill-a: #38bdf842;
--color-tb10-fill-a: oklch(0.78 0.13 222 / 0.26);
--color-tb10-fill-b: #38bdf86b;
--color-tb10-fill-b: oklch(0.78 0.13 222 / 0.42);
--color-tb10-ok-a: #34d39938;
--color-tb10-ok-a: oklch(0.78 0.14 165 / 0.22);
--color-tb10-ok-b: #34d39957;
--color-tb10-ok-b: oklch(0.78 0.14 165 / 0.34);
--color-tb10-ring-track: #38bdf82e;
--color-tb10-ring-track: oklch(0.78 0.13 222 / 0.18);
--font-tb10-sans: "Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb10-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb10-bob: tb-10-bob 1.1s ease-in-out infinite;
}
@keyframes tb-10-bob {
50% {
translate: 0 2px;
opacity: 0.5;
}
}
@keyframes tb-10-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: radial-gradient(50rem 30rem at 50% 0%, var(--color-tb10-ring-track), transparent 70%), var(--color-tb10-bg);
}
.tb-10__btn {
--tb-10-p: 0%;
}
/* Safari ≤15.3 mask shim. */
.tb-10 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}
/* SVG path state: geometry and per-path visibility. No utility form. */
.tb-10__tick {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: var(--animate-tb10-bob);
}
.tb-10__btn[data-state="done"] :is(.tb-10__arrow, .tb-10__tray) {
opacity: 0;
}
.tb-10__btn[data-state="done"] .tb-10__tick {
animation: tb-10-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* The animated counter: Chrome/Safari only, with the JS-written text as fallback. */
@supports (counter-reset: x calc(1 / 1)) {
.tb-10__pct {
counter-reset: tb-10-n calc(var(--tb-10-p) / 1%);
font-size: 0;
}
.tb-10__pct::after {
content: counter(tb-10-n) "%";
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: none;
}
.tb-10__tick {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: light) {
.tb-10:not([data-theme="dark"]) {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
--color-tb10-ok: oklch(0.56 0.13 162);
}
}
.tb-10[data-theme="light"] {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
}
@media (forced-colors: active) {
.tb-10__btn {
border: 1px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.tb-10__btn > span:first-child {
background: Highlight;
}
}@import "tailwindcss";
@property --tb-10-p {
syntax: '<percentage>';
inherits: true;
initial-value: 0%;
}
@theme {
--color-tb10-bg: #0b0f14;
--color-tb10-bg: oklch(0.16 0.02 250);
--color-tb10-surface: #151b23;
--color-tb10-surface: oklch(0.24 0.02 250);
--color-tb10-line: #26303b;
--color-tb10-line: oklch(0.33 0.02 250);
--color-tb10-ink: #e9eef5;
--color-tb10-ink: oklch(0.94 0.008 250);
--color-tb10-accent: #38bdf8;
--color-tb10-accent: oklch(0.78 0.13 222);
--color-tb10-accent-line: #2a7fa8;
--color-tb10-accent-line: oklch(0.58 0.09 222);
--color-tb10-ok: #34d399;
--color-tb10-ok: oklch(0.78 0.14 165);
--color-tb10-fill-a: #38bdf842;
--color-tb10-fill-a: oklch(0.78 0.13 222 / 0.26);
--color-tb10-fill-b: #38bdf86b;
--color-tb10-fill-b: oklch(0.78 0.13 222 / 0.42);
--color-tb10-ok-a: #34d39938;
--color-tb10-ok-a: oklch(0.78 0.14 165 / 0.22);
--color-tb10-ok-b: #34d39957;
--color-tb10-ok-b: oklch(0.78 0.14 165 / 0.34);
--color-tb10-ring-track: #38bdf82e;
--color-tb10-ring-track: oklch(0.78 0.13 222 / 0.18);
--font-tb10-sans: "Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb10-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb10-bob: tb-10-bob 1.1s ease-in-out infinite;
}
@keyframes tb-10-bob {
50% {
translate: 0 2px;
opacity: 0.5;
}
}
@keyframes tb-10-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: radial-gradient(50rem 30rem at 50% 0%, var(--color-tb10-ring-track), transparent 70%), var(--color-tb10-bg);
}
.tb-10__btn {
--tb-10-p: 0%;
}
/* Safari ≤15.3 mask shim. */
.tb-10 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}
/* SVG path state: geometry and per-path visibility. No utility form. */
.tb-10__tick {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: var(--animate-tb10-bob);
}
.tb-10__btn[data-state="done"] :is(.tb-10__arrow, .tb-10__tray) {
opacity: 0;
}
.tb-10__btn[data-state="done"] .tb-10__tick {
animation: tb-10-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* The animated counter: Chrome/Safari only, with the JS-written text as fallback. */
@supports (counter-reset: x calc(1 / 1)) {
.tb-10__pct {
counter-reset: tb-10-n calc(var(--tb-10-p) / 1%);
font-size: 0;
}
.tb-10__pct::after {
content: counter(tb-10-n) "%";
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: none;
}
.tb-10__tick {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: light) {
.tb-10:not([data-theme="dark"]) {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
--color-tb10-ok: oklch(0.56 0.13 162);
}
}
.tb-10[data-theme="light"] {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
}
@media (forced-colors: active) {
.tb-10__btn {
border: 1px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.tb-10__btn > span:first-child {
background: Highlight;
}
}<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=Sora: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=Sora:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- Utilities: the fill's scale (reading the registered property inside
calc()), the conic ring, the mask, everydata-statecondition and the layout. CSS: the@propertyregistration, both keyframe blocks, the per-path SVG state (.tb-10__arrow,.tb-10__tray,.tb-10__tick) and the@supports (counter-reset)block. - Arbitrary utilities read custom properties happily —
scale-x-[calc(var(--tb-10-p)/100%)]compiles to exactly the CSS original. That is the pattern for every@property-driven demo in the collection: register in CSS, consume in utilities. - Per-path SVG styling is the recurring case against total utility coverage. The paths are inside an inline
<svg>, they need classes anyway for the JS-free state switch, and three CSS lines beat three class lists on three<path>elements. - The button's stage background (a radial bloom plus base colour) stays on
.tb-10in CSS — it is scene-setting, not component styling, and the same decision is made on every demo with a lit backdrop.
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 Download Progress Button
Source: https://codefronts.com/components/tailwind-css-buttons/tailwind-download-progress-button/
A download button that becomes its own progress bar: the fill sweeps behind the label, a conic ring mirrors it on the icon, and the percentage is a registered custom property so the digits interpolate instead of stepping. One variable drives three visuals — which is exactly the case where Tailwind's job is to expose the variable, not to own it.
## HTML
```html
<div class="tb-10 w-full min-h-svh grid place-items-center font-tb10-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Download the 2026 report" class="tb-10__btn group relative overflow-hidden isolate cursor-pointer w-[min(100%,20rem)] min-h-14 px-6 border border-tb10-line rounded-[0.9rem] text-[0.9375rem] font-semibold text-left text-tb10-ink bg-tb10-surface transition-[border-color,translate] duration-300 ease-tb10-out hover:-translate-y-0.5 hover:border-tb10-accent-line focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb10-accent data-[state=busy]:cursor-progress data-[state=done]:border-tb10-ok motion-reduce:transition-none">
<span aria-hidden="true" class="absolute inset-0 -z-10 origin-left scale-x-[calc(var(--tb-10-p)/100%)] bg-[linear-gradient(90deg,var(--color-tb10-fill-a),var(--color-tb10-fill-b))] transition-[scale,background] duration-[400ms] ease-tb10-out group-data-[state=done]:bg-[linear-gradient(90deg,var(--color-tb10-ok-a),var(--color-tb10-ok-b))] motion-reduce:transition-none"></span>
<span class="grid grid-flow-col items-center gap-3 [grid-template-columns:auto_1fr_auto]">
<span aria-hidden="true" class="relative grid place-items-center size-6 text-tb10-accent group-data-[state=done]:text-tb10-ok">
<span class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 bg-[conic-gradient(currentColor_var(--tb-10-p),var(--color-tb10-ring-track)_0)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-3px),#000_calc(100%-3px))] group-data-[state=busy]:opacity-100"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="tb-10__svg size-[1.15rem]">
<path class="tb-10__arrow" d="M12 4v10m0 0 4-4m-4 4-4-4"/>
<path class="tb-10__tray" d="M5 18.5h14"/>
<path class="tb-10__tick" d="M6.5 12.5 10.5 16.5 17.5 8"/>
</svg>
</span>
<span class="tb-10__label">Download report</span>
<span aria-hidden="true" class="tb-10__pct text-[0.8125rem] font-bold tabular-nums text-tb10-accent opacity-0 transition-opacity duration-300 group-data-[state=busy]:opacity-100">0%</span>
</span>
</button>
</div>
```
## CSS
```css
@import "tailwindcss";
@property --tb-10-p {
syntax: '<percentage>';
inherits: true;
initial-value: 0%;
}
@theme {
--color-tb10-bg: #0b0f14;
--color-tb10-bg: oklch(0.16 0.02 250);
--color-tb10-surface: #151b23;
--color-tb10-surface: oklch(0.24 0.02 250);
--color-tb10-line: #26303b;
--color-tb10-line: oklch(0.33 0.02 250);
--color-tb10-ink: #e9eef5;
--color-tb10-ink: oklch(0.94 0.008 250);
--color-tb10-accent: #38bdf8;
--color-tb10-accent: oklch(0.78 0.13 222);
--color-tb10-accent-line: #2a7fa8;
--color-tb10-accent-line: oklch(0.58 0.09 222);
--color-tb10-ok: #34d399;
--color-tb10-ok: oklch(0.78 0.14 165);
--color-tb10-fill-a: #38bdf842;
--color-tb10-fill-a: oklch(0.78 0.13 222 / 0.26);
--color-tb10-fill-b: #38bdf86b;
--color-tb10-fill-b: oklch(0.78 0.13 222 / 0.42);
--color-tb10-ok-a: #34d39938;
--color-tb10-ok-a: oklch(0.78 0.14 165 / 0.22);
--color-tb10-ok-b: #34d39957;
--color-tb10-ok-b: oklch(0.78 0.14 165 / 0.34);
--color-tb10-ring-track: #38bdf82e;
--color-tb10-ring-track: oklch(0.78 0.13 222 / 0.18);
--font-tb10-sans: "Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb10-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb10-bob: tb-10-bob 1.1s ease-in-out infinite;
}
@keyframes tb-10-bob {
50% {
translate: 0 2px;
opacity: 0.5;
}
}
@keyframes tb-10-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: radial-gradient(50rem 30rem at 50% 0%, var(--color-tb10-ring-track), transparent 70%), var(--color-tb10-bg);
}
.tb-10__btn {
--tb-10-p: 0%;
}
/* Safari ≤15.3 mask shim. */
.tb-10 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}
/* SVG path state: geometry and per-path visibility. No utility form. */
.tb-10__tick {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: var(--animate-tb10-bob);
}
.tb-10__btn[data-state="done"] :is(.tb-10__arrow, .tb-10__tray) {
opacity: 0;
}
.tb-10__btn[data-state="done"] .tb-10__tick {
animation: tb-10-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* The animated counter: Chrome/Safari only, with the JS-written text as fallback. */
@supports (counter-reset: x calc(1 / 1)) {
.tb-10__pct {
counter-reset: tb-10-n calc(var(--tb-10-p) / 1%);
font-size: 0;
}
.tb-10__pct::after {
content: counter(tb-10-n) "%";
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: none;
}
.tb-10__tick {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: light) {
.tb-10:not([data-theme="dark"]) {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
--color-tb10-ok: oklch(0.56 0.13 162);
}
}
.tb-10[data-theme="light"] {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
}
@media (forced-colors: active) {
.tb-10__btn {
border: 1px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.tb-10__btn > span:first-child {
background: Highlight;
}
}
```
## JavaScript
```js
(() => {
const btn = document.querySelector('.tb-10__btn');
const pct = document.querySelector('.tb-10__pct');
const label = document.querySelector('.tb-10__label');
if (!btn) return;
let timer, p = 0;
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'busy';
p = 0;
// Swap this fake ticker for your XHR/fetch progress events.
const step = () => {
p = Math.min(100, p + Math.random() * 7 + 2);
btn.style.setProperty('--tb-10-p', p + '%');
btn.setAttribute('aria-valuenow', String(Math.round(p)));
pct.textContent = Math.round(p) + '%';
if (p < 100) { timer = setTimeout(step, 140); }
else { btn.dataset.state = 'done'; label.textContent = 'Report downloaded'; }
};
step();
});
window.addEventListener('pagehide', () => clearTimeout(timer));
})();
```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 Download Progress Button
Source: https://codefronts.com/components/tailwind-css-buttons/tailwind-download-progress-button/
A download button that becomes its own progress bar: the fill sweeps behind the label, a conic ring mirrors it on the icon, and the percentage is a registered custom property so the digits interpolate instead of stepping. One variable drives three visuals — which is exactly the case where Tailwind's job is to expose the variable, not to own it.
## HTML
```html
<div class="tb-10 w-full min-h-svh grid place-items-center font-tb10-sans antialiased p-[clamp(1.5rem,6vw,4rem)]">
<button type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Download the 2026 report" class="tb-10__btn group relative overflow-hidden isolate cursor-pointer w-[min(100%,20rem)] min-h-14 px-6 border border-tb10-line rounded-[0.9rem] text-[0.9375rem] font-semibold text-left text-tb10-ink bg-tb10-surface transition-[border-color,translate] duration-300 ease-tb10-out hover:-translate-y-0.5 hover:border-tb10-accent-line focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-tb10-accent data-[state=busy]:cursor-progress data-[state=done]:border-tb10-ok motion-reduce:transition-none">
<span aria-hidden="true" class="absolute inset-0 -z-10 origin-left scale-x-[calc(var(--tb-10-p)/100%)] bg-[linear-gradient(90deg,var(--color-tb10-fill-a),var(--color-tb10-fill-b))] transition-[scale,background] duration-[400ms] ease-tb10-out group-data-[state=done]:bg-[linear-gradient(90deg,var(--color-tb10-ok-a),var(--color-tb10-ok-b))] motion-reduce:transition-none"></span>
<span class="grid grid-flow-col items-center gap-3 [grid-template-columns:auto_1fr_auto]">
<span aria-hidden="true" class="relative grid place-items-center size-6 text-tb10-accent group-data-[state=done]:text-tb10-ok">
<span class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 bg-[conic-gradient(currentColor_var(--tb-10-p),var(--color-tb10-ring-track)_0)] [mask-image:radial-gradient(farthest-side,transparent_calc(100%-3px),#000_calc(100%-3px))] group-data-[state=busy]:opacity-100"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" class="tb-10__svg size-[1.15rem]">
<path class="tb-10__arrow" d="M12 4v10m0 0 4-4m-4 4-4-4"/>
<path class="tb-10__tray" d="M5 18.5h14"/>
<path class="tb-10__tick" d="M6.5 12.5 10.5 16.5 17.5 8"/>
</svg>
</span>
<span class="tb-10__label">Download report</span>
<span aria-hidden="true" class="tb-10__pct text-[0.8125rem] font-bold tabular-nums text-tb10-accent opacity-0 transition-opacity duration-300 group-data-[state=busy]:opacity-100">0%</span>
</span>
</button>
</div>
```
## CSS
```css
@import "tailwindcss";
@property --tb-10-p {
syntax: '<percentage>';
inherits: true;
initial-value: 0%;
}
@theme {
--color-tb10-bg: #0b0f14;
--color-tb10-bg: oklch(0.16 0.02 250);
--color-tb10-surface: #151b23;
--color-tb10-surface: oklch(0.24 0.02 250);
--color-tb10-line: #26303b;
--color-tb10-line: oklch(0.33 0.02 250);
--color-tb10-ink: #e9eef5;
--color-tb10-ink: oklch(0.94 0.008 250);
--color-tb10-accent: #38bdf8;
--color-tb10-accent: oklch(0.78 0.13 222);
--color-tb10-accent-line: #2a7fa8;
--color-tb10-accent-line: oklch(0.58 0.09 222);
--color-tb10-ok: #34d399;
--color-tb10-ok: oklch(0.78 0.14 165);
--color-tb10-fill-a: #38bdf842;
--color-tb10-fill-a: oklch(0.78 0.13 222 / 0.26);
--color-tb10-fill-b: #38bdf86b;
--color-tb10-fill-b: oklch(0.78 0.13 222 / 0.42);
--color-tb10-ok-a: #34d39938;
--color-tb10-ok-a: oklch(0.78 0.14 165 / 0.22);
--color-tb10-ok-b: #34d39957;
--color-tb10-ok-b: oklch(0.78 0.14 165 / 0.34);
--color-tb10-ring-track: #38bdf82e;
--color-tb10-ring-track: oklch(0.78 0.13 222 / 0.18);
--font-tb10-sans: "Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ease-tb10-out: cubic-bezier(0.16, 1, 0.3, 1);
--animate-tb10-bob: tb-10-bob 1.1s ease-in-out infinite;
}
@keyframes tb-10-bob {
50% {
translate: 0 2px;
opacity: 0.5;
}
}
@keyframes tb-10-draw {
to {
stroke-dashoffset: 0;
}
}
.tb-10 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: radial-gradient(50rem 30rem at 50% 0%, var(--color-tb10-ring-track), transparent 70%), var(--color-tb10-bg);
}
.tb-10__btn {
--tb-10-p: 0%;
}
/* Safari ≤15.3 mask shim. */
.tb-10 [class*="mask-image"] {
-webkit-mask-image: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}
/* SVG path state: geometry and per-path visibility. No utility form. */
.tb-10__tick {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: var(--animate-tb10-bob);
}
.tb-10__btn[data-state="done"] :is(.tb-10__arrow, .tb-10__tray) {
opacity: 0;
}
.tb-10__btn[data-state="done"] .tb-10__tick {
animation: tb-10-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* The animated counter: Chrome/Safari only, with the JS-written text as fallback. */
@supports (counter-reset: x calc(1 / 1)) {
.tb-10__pct {
counter-reset: tb-10-n calc(var(--tb-10-p) / 1%);
font-size: 0;
}
.tb-10__pct::after {
content: counter(tb-10-n) "%";
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-10__btn[data-state="busy"] .tb-10__arrow {
animation: none;
}
.tb-10__tick {
stroke-dashoffset: 0;
animation: none;
}
}
@media (prefers-color-scheme: light) {
.tb-10:not([data-theme="dark"]) {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
--color-tb10-ok: oklch(0.56 0.13 162);
}
}
.tb-10[data-theme="light"] {
--color-tb10-bg: oklch(0.95 0.008 250);
--color-tb10-surface: #ffffff;
--color-tb10-line: oklch(0.88 0.012 250);
--color-tb10-ink: oklch(0.2 0.02 250);
--color-tb10-accent: oklch(0.55 0.13 235);
}
@media (forced-colors: active) {
.tb-10__btn {
border: 1px solid ButtonText;
color: ButtonText;
background: ButtonFace;
}
.tb-10__btn > span:first-child {
background: Highlight;
}
}
```
## JavaScript
```js
(() => {
const btn = document.querySelector('.tb-10__btn');
const pct = document.querySelector('.tb-10__pct');
const label = document.querySelector('.tb-10__label');
if (!btn) return;
let timer, p = 0;
btn.addEventListener('click', () => {
if (btn.dataset.state !== 'idle') return;
btn.dataset.state = 'busy';
p = 0;
// Swap this fake ticker for your XHR/fetch progress events.
const step = () => {
p = Math.min(100, p + Math.random() * 7 + 2);
btn.style.setProperty('--tb-10-p', p + '%');
btn.setAttribute('aria-valuenow', String(Math.round(p)));
pct.textContent = Math.round(p) + '%';
if (p < 100) { timer = setTimeout(step, 140); }
else { btn.dataset.state = 'done'; label.textContent = 'Report downloaded'; }
};
step();
});
window.addEventListener('pagehide', () => clearTimeout(timer));
})();
```How this works
The registered property is the API. @property --tb-10-p with inherits:true stays in the CSS block — three descendants read it, and inherits:false would animate only the element it is set on:
@property --tb-10-p{ syntax:'<percentage>'; inherits:true; initial-value:0%; }Tailwind then consumes it in ordinary arbitrary utilities: scale-x-[calc(var(--tb-10-p)/100%)] on the fill, a conic gradient on the ring, and [counter-reset:tb-10-n_calc(var(--tb-10-p)/1%)] on the digits. The variable is written once from JS and everything follows.
The state chrome is a group-data variant. data-state on the button governs the ring's opacity, the arrow's bob, the border colour and the completion tick — four descendants, four group-data-[state=…]: class lists, each where it applies.
ARIA is not decoration. role="progressbar" with live aria-valuenow is what makes this a progress indicator to assistive tech. No utility touches it and none should.
Make it yours
- Point at real bytes:
btn.style.setProperty('--tb-10-p', (loaded/total*100)+'%')from an XHRprogressevent. - Indeterminate: drop
aria-valuenowand add a looping stripedbg-[position:...]animation. - Ring thickness is the
3pxin both mask stops — they must match. - Completion colour lives in
--color-tb10-ok; the fill, border and tick all read it. - Prefer a bar under the label: give the fill
h-1 top-auto bottom-0and dropinset-0.
Gotchas — read before shipping
- A visual bar is not a progressbar. Without
role="progressbar"and a livearia-valuenow, assistive tech reports nothing. @propertymust declareinherits:truehere, or only the element carrying the variable animates.- The counter trick needs a unitless number —
calc(var(--tb-10-p)/1%)— and is Chrome/Safari-only. Always leave the JS-written text in the DOM as the fallback. - Don't transition the percentage faster than the data arrives, or the bar finishes before the file and stalls visibly at 100%.
- A download button with no abort path is a dead end. Wire a second click to your
AbortController.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
@property (Chrome 85 / Safari 16.4 / Firefox 128) matches Tailwind v4's own floor, so the smooth interpolation is available wherever the sheet is. Animated counters are Chrome/Safari only; the JS-written percentage text is the documented fallback.