15 / 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 Add to Cart Button with Flying Product Dot
A dot leaps from the cursor into the cart badge, the count ticks, and the button flashes confirmation. JS measures the flight vector once per click and hands it to CSS as two custom properties — the arc itself is a keyframe.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tab-15 w-full min-h-svh grid place-items-center bg-tab15-bg font-tab15-sans px-6 py-10">
<div class="w-[min(360px,100%)] p-6 bg-white rounded-2xl shadow-[0_4px_24px_oklch(0_0_0/0.08)]">
<div class="flex items-center gap-4 mb-5">
<div class="size-18 shrink-0 rounded-[10px] bg-[linear-gradient(135deg,#fde68a,#f59e0b)]"></div>
<div>
<p class="text-[0.95rem] font-bold text-tab15-ink m-0">Mechanical Keyboard Pro</p>
<p class="mt-1.5 text-[1.15rem] font-bold text-tab15-amber m-0">$149.00</p>
</div>
</div>
<div class="relative">
<button type="button" id="tab15-btn" data-state="idle" class="relative flex items-center gap-2.5 w-full min-h-11 px-6 text-[0.95rem] font-bold text-white bg-tab15-ink rounded-[10px] cursor-pointer transition-[background-color,translate] duration-300 hover:bg-tab15-ink-soft hover:-translate-y-px active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab15-ink data-[state=added]:bg-tab15-done motion-reduce:transition-none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-5 shrink-0">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
<span>Add to Cart</span>
<span id="tab15-badge" class="absolute -top-2 -right-2 grid place-items-center min-w-5 h-5 px-1.5 text-[0.72rem] font-bold text-white bg-tab15-amber border-2 border-white rounded-full data-pop:animate-tab15-pop motion-reduce:data-pop:animate-none">0</span>
</button>
<p id="tab15-status" role="status" aria-live="polite" class="sr-only"></p>
</div>
</div>
</section><section class="tab-15 w-full min-h-svh grid place-items-center bg-tab15-bg font-tab15-sans px-6 py-10">
<div class="w-[min(360px,100%)] p-6 bg-white rounded-2xl shadow-[0_4px_24px_oklch(0_0_0/0.08)]">
<div class="flex items-center gap-4 mb-5">
<div class="size-18 shrink-0 rounded-[10px] bg-[linear-gradient(135deg,#fde68a,#f59e0b)]"></div>
<div>
<p class="text-[0.95rem] font-bold text-tab15-ink m-0">Mechanical Keyboard Pro</p>
<p class="mt-1.5 text-[1.15rem] font-bold text-tab15-amber m-0">$149.00</p>
</div>
</div>
<div class="relative">
<button type="button" id="tab15-btn" data-state="idle" class="relative flex items-center gap-2.5 w-full min-h-11 px-6 text-[0.95rem] font-bold text-white bg-tab15-ink rounded-[10px] cursor-pointer transition-[background-color,translate] duration-300 hover:bg-tab15-ink-soft hover:-translate-y-px active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab15-ink data-[state=added]:bg-tab15-done motion-reduce:transition-none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-5 shrink-0">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
<span>Add to Cart</span>
<span id="tab15-badge" class="absolute -top-2 -right-2 grid place-items-center min-w-5 h-5 px-1.5 text-[0.72rem] font-bold text-white bg-tab15-amber border-2 border-white rounded-full data-pop:animate-tab15-pop motion-reduce:data-pop:animate-none">0</span>
</button>
<p id="tab15-status" role="status" aria-live="polite" class="sr-only"></p>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-tab15-bg: #fff7ed;
--color-tab15-bg: oklch(0.97 0.02 75);
--color-tab15-ink: #1e293b;
--color-tab15-ink: oklch(0.28 0.03 258);
--color-tab15-ink-soft: #334155;
--color-tab15-ink-soft: oklch(0.37 0.03 258);
--color-tab15-amber: #b45309;
--color-tab15-amber: oklch(0.53 0.15 62);
--color-tab15-done: #059669;
--color-tab15-done: oklch(0.58 0.14 163);
--font-tab15-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab15-fly: tab15-fly 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards;
--animate-tab15-pop: tab15-pop 0.3s ease both;
}
@keyframes tab15-fly {
0% {
translate: 0 0;
scale: 1;
opacity: 1;
}
50% {
translate: calc(var(--tx) * 0.5) calc(var(--ty) * 0.5 - 40px);
scale: 0.8;
opacity: 0.9;
}
100% {
translate: var(--tx) var(--ty);
scale: 0;
opacity: 0;
}
}
@keyframes tab15-pop {
0% {
scale: 1;
}
50% {
scale: 1.5;
}
100% {
scale: 1;
}
}
.tab-15 {
width: 100%;
min-height: 100svh;
}
.tab-15 ::selection {
background: var(--color-tab15-amber);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tab15-bg: #fff7ed;
--color-tab15-bg: oklch(0.97 0.02 75);
--color-tab15-ink: #1e293b;
--color-tab15-ink: oklch(0.28 0.03 258);
--color-tab15-ink-soft: #334155;
--color-tab15-ink-soft: oklch(0.37 0.03 258);
--color-tab15-amber: #b45309;
--color-tab15-amber: oklch(0.53 0.15 62);
--color-tab15-done: #059669;
--color-tab15-done: oklch(0.58 0.14 163);
--font-tab15-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab15-fly: tab15-fly 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards;
--animate-tab15-pop: tab15-pop 0.3s ease both;
}
@keyframes tab15-fly {
0% {
translate: 0 0;
scale: 1;
opacity: 1;
}
50% {
translate: calc(var(--tx) * 0.5) calc(var(--ty) * 0.5 - 40px);
scale: 0.8;
opacity: 0.9;
}
100% {
translate: var(--tx) var(--ty);
scale: 0;
opacity: 0;
}
}
@keyframes tab15-pop {
0% {
scale: 1;
}
50% {
scale: 1.5;
}
100% {
scale: 1;
}
}
.tab-15 {
width: 100%;
min-height: 100svh;
}
.tab-15 ::selection {
background: var(--color-tab15-amber);
color: #fff;
}(function () {
var btn = document.getElementById('tab15-btn');
var badge = document.getElementById('tab15-badge');
var status = document.getElementById('tab15-status');
if (!btn || !badge) return;
var count = 0;
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
function bump() {
count++;
badge.textContent = String(count);
badge.removeAttribute('data-pop');
void badge.offsetWidth;
badge.setAttribute('data-pop', '');
if (status) status.textContent = count + (count === 1 ? ' item in cart' : ' items in cart');
}
btn.addEventListener('click', function (e) {
btn.dataset.state = 'added';
setTimeout(function () { btn.dataset.state = 'idle'; }, 800);
if (calm.matches) { bump(); return; }
var target = badge.getBoundingClientRect();
var startX = e.clientX - 5;
var startY = e.clientY - 5;
var dot = document.createElement('div');
dot.className = 'fixed size-2.5 rounded-full bg-tab15-amber pointer-events-none z-[9999] animate-tab15-fly will-change-transform';
dot.style.left = startX + 'px';
dot.style.top = startY + 'px';
dot.style.setProperty('--tx', (target.left + target.width / 2 - 5 - startX) + 'px');
dot.style.setProperty('--ty', (target.top + target.height / 2 - 5 - startY) + 'px');
document.body.appendChild(dot);
dot.addEventListener('animationend', function () { dot.remove(); bump(); });
});
})();(function () {
var btn = document.getElementById('tab15-btn');
var badge = document.getElementById('tab15-badge');
var status = document.getElementById('tab15-status');
if (!btn || !badge) return;
var count = 0;
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
function bump() {
count++;
badge.textContent = String(count);
badge.removeAttribute('data-pop');
void badge.offsetWidth;
badge.setAttribute('data-pop', '');
if (status) status.textContent = count + (count === 1 ? ' item in cart' : ' items in cart');
}
btn.addEventListener('click', function (e) {
btn.dataset.state = 'added';
setTimeout(function () { btn.dataset.state = 'idle'; }, 800);
if (calm.matches) { bump(); return; }
var target = badge.getBoundingClientRect();
var startX = e.clientX - 5;
var startY = e.clientY - 5;
var dot = document.createElement('div');
dot.className = 'fixed size-2.5 rounded-full bg-tab15-amber pointer-events-none z-[9999] animate-tab15-fly will-change-transform';
dot.style.left = startX + 'px';
dot.style.top = startY + 'px';
dot.style.setProperty('--tx', (target.left + target.width / 2 - 5 - startX) + 'px');
dot.style.setProperty('--ty', (target.top + target.height / 2 - 5 - startY) + 'px');
document.body.appendChild(dot);
dot.addEventListener('animationend', function () { dot.remove(); bump(); });
});
})();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-tab15-bg: #fff7ed;
--color-tab15-bg: oklch(0.97 0.02 75);
--color-tab15-ink: #1e293b;
--color-tab15-ink: oklch(0.28 0.03 258);
--color-tab15-ink-soft: #334155;
--color-tab15-ink-soft: oklch(0.37 0.03 258);
--color-tab15-amber: #b45309;
--color-tab15-amber: oklch(0.53 0.15 62);
--color-tab15-done: #059669;
--color-tab15-done: oklch(0.58 0.14 163);
--font-tab15-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab15-fly: tab15-fly 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards;
--animate-tab15-pop: tab15-pop 0.3s ease both;
}
@keyframes tab15-fly {
0% {
translate: 0 0;
scale: 1;
opacity: 1;
}
50% {
translate: calc(var(--tx) * 0.5) calc(var(--ty) * 0.5 - 40px);
scale: 0.8;
opacity: 0.9;
}
100% {
translate: var(--tx) var(--ty);
scale: 0;
opacity: 0;
}
}
@keyframes tab15-pop {
0% {
scale: 1;
}
50% {
scale: 1.5;
}
100% {
scale: 1;
}
}
.tab-15 {
width: 100%;
min-height: 100svh;
}
.tab-15 ::selection {
background: var(--color-tab15-amber);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tab15-bg: #fff7ed;
--color-tab15-bg: oklch(0.97 0.02 75);
--color-tab15-ink: #1e293b;
--color-tab15-ink: oklch(0.28 0.03 258);
--color-tab15-ink-soft: #334155;
--color-tab15-ink-soft: oklch(0.37 0.03 258);
--color-tab15-amber: #b45309;
--color-tab15-amber: oklch(0.53 0.15 62);
--color-tab15-done: #059669;
--color-tab15-done: oklch(0.58 0.14 163);
--font-tab15-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab15-fly: tab15-fly 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards;
--animate-tab15-pop: tab15-pop 0.3s ease both;
}
@keyframes tab15-fly {
0% {
translate: 0 0;
scale: 1;
opacity: 1;
}
50% {
translate: calc(var(--tx) * 0.5) calc(var(--ty) * 0.5 - 40px);
scale: 0.8;
opacity: 0.9;
}
100% {
translate: var(--tx) var(--ty);
scale: 0;
opacity: 0;
}
}
@keyframes tab15-pop {
0% {
scale: 1;
}
50% {
scale: 1.5;
}
100% {
scale: 1;
}
}
.tab-15 {
width: 100%;
min-height: 100svh;
}
.tab-15 ::selection {
background: var(--color-tab15-amber);
color: #fff;
}<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.
data-pop:is the bare-data-attribute variant — no value needed, which is exactly right for a one-shot flag the controller sets and the keyframe consumes.- The dot's utilities are written from JS and appended to
document.body, outside the demo's scope. Under a CLI build they need safelisting; under the browser build they compile on insertion. - UI change from the CSS original: a screen-reader-only live region announces the cart count, and the reduced-motion path increments immediately instead of animating an invisible dot.
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 Add to Cart Button with Flying Product Dot
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-add-to-cart-button-animation/
A dot leaps from the cursor into the cart badge, the count ticks, and the button flashes confirmation. JS measures the flight vector once per click and hands it to CSS as two custom properties — the arc itself is a keyframe.
## HTML
```html
<section class="tab-15 w-full min-h-svh grid place-items-center bg-tab15-bg font-tab15-sans px-6 py-10">
<div class="w-[min(360px,100%)] p-6 bg-white rounded-2xl shadow-[0_4px_24px_oklch(0_0_0/0.08)]">
<div class="flex items-center gap-4 mb-5">
<div class="size-18 shrink-0 rounded-[10px] bg-[linear-gradient(135deg,#fde68a,#f59e0b)]"></div>
<div>
<p class="text-[0.95rem] font-bold text-tab15-ink m-0">Mechanical Keyboard Pro</p>
<p class="mt-1.5 text-[1.15rem] font-bold text-tab15-amber m-0">$149.00</p>
</div>
</div>
<div class="relative">
<button type="button" id="tab15-btn" data-state="idle" class="relative flex items-center gap-2.5 w-full min-h-11 px-6 text-[0.95rem] font-bold text-white bg-tab15-ink rounded-[10px] cursor-pointer transition-[background-color,translate] duration-300 hover:bg-tab15-ink-soft hover:-translate-y-px active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab15-ink data-[state=added]:bg-tab15-done motion-reduce:transition-none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-5 shrink-0">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
<span>Add to Cart</span>
<span id="tab15-badge" class="absolute -top-2 -right-2 grid place-items-center min-w-5 h-5 px-1.5 text-[0.72rem] font-bold text-white bg-tab15-amber border-2 border-white rounded-full data-pop:animate-tab15-pop motion-reduce:data-pop:animate-none">0</span>
</button>
<p id="tab15-status" role="status" aria-live="polite" class="sr-only"></p>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tab15-bg: #fff7ed;
--color-tab15-bg: oklch(0.97 0.02 75);
--color-tab15-ink: #1e293b;
--color-tab15-ink: oklch(0.28 0.03 258);
--color-tab15-ink-soft: #334155;
--color-tab15-ink-soft: oklch(0.37 0.03 258);
--color-tab15-amber: #b45309;
--color-tab15-amber: oklch(0.53 0.15 62);
--color-tab15-done: #059669;
--color-tab15-done: oklch(0.58 0.14 163);
--font-tab15-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab15-fly: tab15-fly 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards;
--animate-tab15-pop: tab15-pop 0.3s ease both;
}
@keyframes tab15-fly {
0% {
translate: 0 0;
scale: 1;
opacity: 1;
}
50% {
translate: calc(var(--tx) * 0.5) calc(var(--ty) * 0.5 - 40px);
scale: 0.8;
opacity: 0.9;
}
100% {
translate: var(--tx) var(--ty);
scale: 0;
opacity: 0;
}
}
@keyframes tab15-pop {
0% {
scale: 1;
}
50% {
scale: 1.5;
}
100% {
scale: 1;
}
}
.tab-15 {
width: 100%;
min-height: 100svh;
}
.tab-15 ::selection {
background: var(--color-tab15-amber);
color: #fff;
}
```
## JavaScript
```js
(function () {
var btn = document.getElementById('tab15-btn');
var badge = document.getElementById('tab15-badge');
var status = document.getElementById('tab15-status');
if (!btn || !badge) return;
var count = 0;
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
function bump() {
count++;
badge.textContent = String(count);
badge.removeAttribute('data-pop');
void badge.offsetWidth;
badge.setAttribute('data-pop', '');
if (status) status.textContent = count + (count === 1 ? ' item in cart' : ' items in cart');
}
btn.addEventListener('click', function (e) {
btn.dataset.state = 'added';
setTimeout(function () { btn.dataset.state = 'idle'; }, 800);
if (calm.matches) { bump(); return; }
var target = badge.getBoundingClientRect();
var startX = e.clientX - 5;
var startY = e.clientY - 5;
var dot = document.createElement('div');
dot.className = 'fixed size-2.5 rounded-full bg-tab15-amber pointer-events-none z-[9999] animate-tab15-fly will-change-transform';
dot.style.left = startX + 'px';
dot.style.top = startY + 'px';
dot.style.setProperty('--tx', (target.left + target.width / 2 - 5 - startX) + 'px');
dot.style.setProperty('--ty', (target.top + target.height / 2 - 5 - startY) + 'px');
document.body.appendChild(dot);
dot.addEventListener('animationend', function () { dot.remove(); bump(); });
});
})();
```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 Add to Cart Button with Flying Product Dot
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-add-to-cart-button-animation/
A dot leaps from the cursor into the cart badge, the count ticks, and the button flashes confirmation. JS measures the flight vector once per click and hands it to CSS as two custom properties — the arc itself is a keyframe.
## HTML
```html
<section class="tab-15 w-full min-h-svh grid place-items-center bg-tab15-bg font-tab15-sans px-6 py-10">
<div class="w-[min(360px,100%)] p-6 bg-white rounded-2xl shadow-[0_4px_24px_oklch(0_0_0/0.08)]">
<div class="flex items-center gap-4 mb-5">
<div class="size-18 shrink-0 rounded-[10px] bg-[linear-gradient(135deg,#fde68a,#f59e0b)]"></div>
<div>
<p class="text-[0.95rem] font-bold text-tab15-ink m-0">Mechanical Keyboard Pro</p>
<p class="mt-1.5 text-[1.15rem] font-bold text-tab15-amber m-0">$149.00</p>
</div>
</div>
<div class="relative">
<button type="button" id="tab15-btn" data-state="idle" class="relative flex items-center gap-2.5 w-full min-h-11 px-6 text-[0.95rem] font-bold text-white bg-tab15-ink rounded-[10px] cursor-pointer transition-[background-color,translate] duration-300 hover:bg-tab15-ink-soft hover:-translate-y-px active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab15-ink data-[state=added]:bg-tab15-done motion-reduce:transition-none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-5 shrink-0">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
<span>Add to Cart</span>
<span id="tab15-badge" class="absolute -top-2 -right-2 grid place-items-center min-w-5 h-5 px-1.5 text-[0.72rem] font-bold text-white bg-tab15-amber border-2 border-white rounded-full data-pop:animate-tab15-pop motion-reduce:data-pop:animate-none">0</span>
</button>
<p id="tab15-status" role="status" aria-live="polite" class="sr-only"></p>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tab15-bg: #fff7ed;
--color-tab15-bg: oklch(0.97 0.02 75);
--color-tab15-ink: #1e293b;
--color-tab15-ink: oklch(0.28 0.03 258);
--color-tab15-ink-soft: #334155;
--color-tab15-ink-soft: oklch(0.37 0.03 258);
--color-tab15-amber: #b45309;
--color-tab15-amber: oklch(0.53 0.15 62);
--color-tab15-done: #059669;
--color-tab15-done: oklch(0.58 0.14 163);
--font-tab15-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-tab15-fly: tab15-fly 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards;
--animate-tab15-pop: tab15-pop 0.3s ease both;
}
@keyframes tab15-fly {
0% {
translate: 0 0;
scale: 1;
opacity: 1;
}
50% {
translate: calc(var(--tx) * 0.5) calc(var(--ty) * 0.5 - 40px);
scale: 0.8;
opacity: 0.9;
}
100% {
translate: var(--tx) var(--ty);
scale: 0;
opacity: 0;
}
}
@keyframes tab15-pop {
0% {
scale: 1;
}
50% {
scale: 1.5;
}
100% {
scale: 1;
}
}
.tab-15 {
width: 100%;
min-height: 100svh;
}
.tab-15 ::selection {
background: var(--color-tab15-amber);
color: #fff;
}
```
## JavaScript
```js
(function () {
var btn = document.getElementById('tab15-btn');
var badge = document.getElementById('tab15-badge');
var status = document.getElementById('tab15-status');
if (!btn || !badge) return;
var count = 0;
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
function bump() {
count++;
badge.textContent = String(count);
badge.removeAttribute('data-pop');
void badge.offsetWidth;
badge.setAttribute('data-pop', '');
if (status) status.textContent = count + (count === 1 ? ' item in cart' : ' items in cart');
}
btn.addEventListener('click', function (e) {
btn.dataset.state = 'added';
setTimeout(function () { btn.dataset.state = 'idle'; }, 800);
if (calm.matches) { bump(); return; }
var target = badge.getBoundingClientRect();
var startX = e.clientX - 5;
var startY = e.clientY - 5;
var dot = document.createElement('div');
dot.className = 'fixed size-2.5 rounded-full bg-tab15-amber pointer-events-none z-[9999] animate-tab15-fly will-change-transform';
dot.style.left = startX + 'px';
dot.style.top = startY + 'px';
dot.style.setProperty('--tx', (target.left + target.width / 2 - 5 - startX) + 'px');
dot.style.setProperty('--ty', (target.top + target.height / 2 - 5 - startY) + 'px');
document.body.appendChild(dot);
dot.addEventListener('animationend', function () { dot.remove(); bump(); });
});
})();
```How this works
One measurement, one animation. The controller reads the badge's rect and the click point, writes the delta into --tx/--ty, and appends the dot. The keyframe interpolates the whole flight including a 40px rise at the midpoint, which is what makes it read as thrown rather than dragged.
The badge pop needs a re-trigger. An animation does not replay when re-applied in the same frame, so the controller removes the attribute, forces a reflow, and re-adds it — the same trick the CSS version used with a class, now on a data attribute the badge styles itself from.
Count updates on landing, not on click. Incrementing in the animationend handler means the number and the dot agree, which is the whole illusion.
Make it yours
- Higher arc: raise the negative Y at the 50% keyframe step.
- Fly a thumbnail instead of a dot — clone the product image and give it the same two custom properties.
- Cap in-flight dots at three by counting live nodes before appending; a rapid-click stack of ten reads as noise.
Gotchas — read before shipping
- The dot is
fixedand positioned fromclientX/Y, so it is scroll-independent. UsingabsolutewithoffsetX/Ybreaks the moment the page scrolls mid-flight. - The badge must be positioned against a
relativeancestor, or its measured centre is not where it appears. - Under
prefers-reduced-motionthe controller skips the flight and updates the count immediately — a dot animating withanimation:nonewould simply park on screen forever.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
getBoundingClientRect and custom properties in keyframes are universally supported; the floor is Tailwind v4's generated sheet.