Tailwind + JSMIT licensed

Tailwind Animated Download Button with Progress Fill

Click and the arrow bounces, a progress bar sweeps the button's bottom edge, and the label resolves to "Downloaded!". Three states, one data-state attribute, and a progress bar that is a single after: layer scaling on the compositor.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tab-13 w-full min-h-svh grid place-items-center bg-tab13-bg font-tab13-sans px-6 py-10">
  <div class="flex flex-col items-center gap-5">
    <p class="text-[0.78rem] tracking-[0.1em] uppercase text-tab13-mut m-0">Click the button to download</p>
    <button type="button" id="tab13-btn" data-state="idle" class="relative overflow-hidden inline-flex items-center gap-2.5 min-h-11 px-9 text-base font-bold text-white bg-tab13-blue rounded-xl cursor-pointer shadow-[0_4px_16px_oklch(0.55_0.22_264/0.4)] transition-[background-color,box-shadow,translate] duration-300 hover:brightness-108 hover:-translate-y-0.5 active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab13-blue data-[state=downloading]:bg-tab13-deep data-[state=downloading]:cursor-not-allowed data-[state=downloading]:hover:translate-y-0 data-[state=done]:bg-tab13-done data-[state=done]:shadow-[0_4px_16px_oklch(0.58_0.14_163/0.4)] after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-[3px] after:bg-white/55 after:origin-left after:scale-x-0 after:pointer-events-none after:transition-transform after:duration-[2200ms] after:ease-linear data-[state=downloading]:after:scale-x-100 data-[state=downloading]:[&_svg]:animate-tab13-bounce motion-reduce:transition-none motion-reduce:after:transition-none motion-reduce:data-[state=downloading]:[&_svg]:animate-none">
      <svg id="tab13-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-5 shrink-0">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
        <polyline points="7 10 12 15 17 10"/>
        <line x1="12" y1="15" x2="12" y2="3"/>
      </svg>
      <span id="tab13-text" role="status" aria-live="polite">Download v2.4.1</span>
    </button>
    <p class="text-[0.78rem] tracking-[0.1em] uppercase text-tab13-mut m-0">macOS &middot; 48.2 MB</p>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tab13-bg: #f0f4ff;
  --color-tab13-bg: oklch(0.97 0.015 265);
  --color-tab13-mut: #64748b;
  --color-tab13-mut: oklch(0.55 0.03 255);
  --color-tab13-blue: #2563eb;
  --color-tab13-blue: oklch(0.55 0.22 264);
  --color-tab13-deep: #1d4ed8;
  --color-tab13-deep: oklch(0.5 0.22 264);
  --color-tab13-done: #059669;
  --color-tab13-done: oklch(0.58 0.14 163);
  --font-tab13-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab13-bounce: tab13-bounce 0.6s ease-in-out infinite;
}

@keyframes tab13-bounce {
  0%,
    100% {
    translate: 0 0;
  }

  50% {
    translate: 0 4px;
  }
}

.tab-13 {
  width: 100%;
  min-height: 100svh;
}

.tab-13 ::selection {
  background: var(--color-tab13-blue);
  color: #fff;
}
(function () {
  var btn = document.getElementById('tab13-btn');
  var txt = document.getElementById('tab13-text');
  var icon = document.getElementById('tab13-icon');
  if (!btn || !txt || !icon) return;
  var DONE_ICON = '<polyline points="20 6 9 17 4 12"/>';
  var IDLE_ICON = icon.innerHTML;
  btn.addEventListener('click', function () {
    if (btn.dataset.state !== 'idle') return;
    btn.dataset.state = 'downloading';
    btn.setAttribute('aria-busy', 'true');
    txt.textContent = 'Downloading\u2026';
    setTimeout(function () {
      btn.dataset.state = 'done';
      btn.setAttribute('aria-busy', 'false');
      icon.innerHTML = DONE_ICON;
      txt.textContent = 'Downloaded!';
      setTimeout(function () {
        btn.dataset.state = 'idle';
        icon.innerHTML = IDLE_ICON;
        txt.textContent = 'Download v2.4.1';
      }, 2000);
    }, 2200);
  });
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tab13-bg: #f0f4ff;
  --color-tab13-bg: oklch(0.97 0.015 265);
  --color-tab13-mut: #64748b;
  --color-tab13-mut: oklch(0.55 0.03 255);
  --color-tab13-blue: #2563eb;
  --color-tab13-blue: oklch(0.55 0.22 264);
  --color-tab13-deep: #1d4ed8;
  --color-tab13-deep: oklch(0.5 0.22 264);
  --color-tab13-done: #059669;
  --color-tab13-done: oklch(0.58 0.14 163);
  --font-tab13-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab13-bounce: tab13-bounce 0.6s ease-in-out infinite;
}

@keyframes tab13-bounce {
  0%,
    100% {
    translate: 0 0;
  }

  50% {
    translate: 0 4px;
  }
}

.tab-13 {
  width: 100%;
  min-height: 100svh;
}

.tab-13 ::selection {
  background: var(--color-tab13-blue);
  color: #fff;
}
3Fonts used by this demooptional — skip if you have your own type stack
<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.

  • [&_svg]: is how a state on the button reaches the icon without giving the icon a class of its own — the arbitrary-variant escape hatch used for exactly what it is for.
  • Two independent classes become one state value, which removes the double-click race the CSS version could reach.
  • UI change from the CSS original: the label is an aria-live region and the button reports aria-busy while fetching, so the progress bar is not the only channel.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 Animated Download Button with Progress Fill
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-animated-download-button/

Click and the arrow bounces, a progress bar sweeps the button's bottom edge, and the label resolves to "Downloaded!". Three states, one data-state attribute, and a progress bar that is a single after: layer scaling on the compositor.
## HTML
```html
<section class="tab-13 w-full min-h-svh grid place-items-center bg-tab13-bg font-tab13-sans px-6 py-10">
  <div class="flex flex-col items-center gap-5">
    <p class="text-[0.78rem] tracking-[0.1em] uppercase text-tab13-mut m-0">Click the button to download</p>
    <button type="button" id="tab13-btn" data-state="idle" class="relative overflow-hidden inline-flex items-center gap-2.5 min-h-11 px-9 text-base font-bold text-white bg-tab13-blue rounded-xl cursor-pointer shadow-[0_4px_16px_oklch(0.55_0.22_264/0.4)] transition-[background-color,box-shadow,translate] duration-300 hover:brightness-108 hover:-translate-y-0.5 active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab13-blue data-[state=downloading]:bg-tab13-deep data-[state=downloading]:cursor-not-allowed data-[state=downloading]:hover:translate-y-0 data-[state=done]:bg-tab13-done data-[state=done]:shadow-[0_4px_16px_oklch(0.58_0.14_163/0.4)] after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-[3px] after:bg-white/55 after:origin-left after:scale-x-0 after:pointer-events-none after:transition-transform after:duration-[2200ms] after:ease-linear data-[state=downloading]:after:scale-x-100 data-[state=downloading]:[&_svg]:animate-tab13-bounce motion-reduce:transition-none motion-reduce:after:transition-none motion-reduce:data-[state=downloading]:[&_svg]:animate-none">
      <svg id="tab13-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-5 shrink-0">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
        <polyline points="7 10 12 15 17 10"/>
        <line x1="12" y1="15" x2="12" y2="3"/>
      </svg>
      <span id="tab13-text" role="status" aria-live="polite">Download v2.4.1</span>
    </button>
    <p class="text-[0.78rem] tracking-[0.1em] uppercase text-tab13-mut m-0">macOS &middot; 48.2 MB</p>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tab13-bg: #f0f4ff;
  --color-tab13-bg: oklch(0.97 0.015 265);
  --color-tab13-mut: #64748b;
  --color-tab13-mut: oklch(0.55 0.03 255);
  --color-tab13-blue: #2563eb;
  --color-tab13-blue: oklch(0.55 0.22 264);
  --color-tab13-deep: #1d4ed8;
  --color-tab13-deep: oklch(0.5 0.22 264);
  --color-tab13-done: #059669;
  --color-tab13-done: oklch(0.58 0.14 163);
  --font-tab13-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab13-bounce: tab13-bounce 0.6s ease-in-out infinite;
}

@keyframes tab13-bounce {
  0%,
    100% {
    translate: 0 0;
  }

  50% {
    translate: 0 4px;
  }
}

.tab-13 {
  width: 100%;
  min-height: 100svh;
}

.tab-13 ::selection {
  background: var(--color-tab13-blue);
  color: #fff;
}
```

## JavaScript
```js
(function () {
  var btn = document.getElementById('tab13-btn');
  var txt = document.getElementById('tab13-text');
  var icon = document.getElementById('tab13-icon');
  if (!btn || !txt || !icon) return;
  var DONE_ICON = '<polyline points="20 6 9 17 4 12"/>';
  var IDLE_ICON = icon.innerHTML;
  btn.addEventListener('click', function () {
    if (btn.dataset.state !== 'idle') return;
    btn.dataset.state = 'downloading';
    btn.setAttribute('aria-busy', 'true');
    txt.textContent = 'Downloading\u2026';
    setTimeout(function () {
      btn.dataset.state = 'done';
      btn.setAttribute('aria-busy', 'false');
      icon.innerHTML = DONE_ICON;
      txt.textContent = 'Downloaded!';
      setTimeout(function () {
        btn.dataset.state = 'idle';
        icon.innerHTML = IDLE_ICON;
        txt.textContent = 'Download v2.4.1';
      }, 2000);
    }, 2200);
  });
})();
```

How this works

The progress bar is a transition, not an animation. after:scale-x-0 at rest, scale-x-100 while downloading, with a 2200ms linear transition — the exact duration of the simulated request. One property, compositor-only, and it lands precisely when the controller flips to done.

Three states cannot overlap. The CSS original toggled .is-downloading and .is-done independently, which made "both classes present" reachable through a fast double click. A single data-state value makes that state unrepresentable — the port fixes a bug, not just a syntax.

The icon swap stays in JS. Replacing a download glyph with a checkmark is content, not style; the controller swaps the SVG's inner markup and restores it on reset.

Make it yours

  • Duration: change the controller's 2200 and the matching after:duration-[2200ms] together — they are one number in two places by necessity.
  • Progress colour: after:bg-white/55, or a token if you need it themeable.
  • Swap the linear bar for a ring by animating stroke-dashoffset on an SVG circle instead of the after: layer.

Gotchas — read before shipping

  • The transition only fires if the state flips on a later frame than the initial paint. Setting data-state inside a click handler satisfies that; setting it during initial render does not.
  • after:pointer-events-none keeps the fill from intercepting the click that would cancel a download.
  • Do not animate the width of the bar. scale-x with origin-left looks identical and costs nothing per frame.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Scale transitions and SVG swaps are universally supported; the floor is Tailwind v4's generated sheet.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…