Stoneware mug
$24Cart
import { useCallback, useEffect, useRef, useState } from 'react';
const css = `
.card-cart { position: relative; box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-cart__bag { position: absolute; right: 12px; top: 10px; z-index: 2; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
.card-cart__bag svg { width: 15px; height: 15px; }
.card-cart__bag--bump { animation: card-cart-bump 0.4s cubic-bezier(0.3, 1.8, 0.5, 1); }
@keyframes card-cart-bump {
30% { transform: scale(1.25) rotate(-8deg); }
}
.card-cart__count { position: absolute; right: -3px; top: -3px; min-width: 15px; height: 15px; padding: 0 3px; box-sizing: border-box; border-radius: 8px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-size: 9px; font-weight: 700; line-height: 15px; text-align: center; animation: card-cart-pop 0.4s cubic-bezier(0.3, 1.8, 0.5, 1); }
@keyframes card-cart-pop {
from { transform: scale(0); }
}
.card-cart__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.card-cart__art { display: block; width: 212px; height: 92px; border-radius: 10px; background: light-dark(#ebedf0, #232529); }
.card-cart__mug { fill: light-dark(#50535b, #c6c8cd); }
.card-cart__rim { fill: light-dark(#42454c, #e5e6e9); }
.card-cart__handle { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 6; }
.card-cart__steam { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 2; stroke-linecap: round; }
.card-cart__fly { position: absolute; left: 106px; top: 46px; z-index: 3; width: 28px; height: 28px; border-radius: 8px; background: light-dark(#50535b, #c6c8cd); pointer-events: none; }
.card-cart__row { display: flex; align-items: baseline; justify-content: space-between; margin: 10px 0; }
.card-cart__name { margin: 0; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-cart__price { font-size: 13px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); }
.card-cart__add { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; height: 34px; border: 0; border-radius: 10px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: transform 0.15s; }
.card-cart__add:active { transform: scale(0.97); }
.card-cart__add:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-cart__add svg { width: 14px; height: 14px; }
@media (prefers-reduced-motion: reduce) {
.card-cart__bag--bump, .card-cart__count { animation: none; }
}
`;
// A copy of the product flies up and over into the bag (WAAPI). Each press gets its own copy, so quick presses all land.
function Flyer({ id, onLand }: { id: number; onLand: (id: number) => void }) {
const el = useRef<HTMLSpanElement>(null);
useEffect(() => {
const a = el.current!.animate(
[
{ transform: 'translate(0, 0) scale(1)', opacity: 1 },
{ transform: 'translate(52px, -64px) scale(0.75)', opacity: 1, offset: 0.55 },
{ transform: 'translate(92px, -51px) scale(0.3)', opacity: 0.4 },
],
{ duration: 650, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' },
);
a.onfinish = () => onLand(id);
return () => a.cancel();
}, [id, onLand]);
return <span ref={el} className="card-cart__fly" aria-hidden="true" />;
}
export function CartCard() {
const [count, setCount] = useState(0);
const [flights, setFlights] = useState<number[]>([]);
const [added, setAdded] = useState(false);
const [bump, setBump] = useState(0);
const seq = useRef(0);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
// When a copy lands the bag bumps and the count goes up.
const land = useCallback((id: number) => {
setFlights((f) => f.filter((x) => x !== id));
setCount((c) => c + 1);
setBump((b) => b + 1);
}, []);
const add = () => {
setAdded(true);
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setAdded(false), 1400);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setCount((c) => c + 1);
const id = ++seq.current;
setFlights((f) => [...f, id]);
};
return (
<>
<style>
{css}
</style>
<article className="card-cart">
<span key={bump} className={`card-cart__bag${bump ? ' card-cart__bag--bump' : ''}`} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 7h12l-1 13H7L6 7zM9 7a3 3 0 0 1 6 0" />
</svg>
{count > 0 && (
<span key={count} className="card-cart__count" aria-hidden="true">
{count}
</span>
)}
</span>
<span className="card-cart__sr" role="status">
{count ? `${count} in cart` : ''}
</span>
<svg className="card-cart__art" viewBox="0 0 212 92" aria-hidden="true">
<path className="card-cart__steam" d="M98 22 q-4 -6 0 -12 M108 20 q-4 -6 0 -12" />
<path className="card-cart__handle" d="M126 44 q16 0 16 12 q0 12 -16 12" />
<rect className="card-cart__mug" x={84} y={32} width={44} height={46} rx={7} />
<rect className="card-cart__rim" x={84} y={32} width={44} height={6} rx={3} />
</svg>
{flights.map((id) => (
<Flyer key={id} id={id} onLand={land} />
))}
<div className="card-cart__row">
<p className="card-cart__name">Stoneware mug</p>
<span className="card-cart__price">$24</span>
</div>
<button type="button" className="card-cart__add" onClick={add}>
{added && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
)}
{added ? 'Added' : 'Add to cart'}
</button>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardCart(el) on the mounted element instead.
<article class="card-cart">
<span class="card-cart__bag" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 7h12l-1 13H7L6 7zM9 7a3 3 0 0 1 6 0"></path>
</svg>
</span>
<span class="card-cart__sr" role="status"></span>
<svg class="card-cart__art" viewBox="0 0 212 92" aria-hidden="true">
<path class="card-cart__steam" d="M98 22 q-4 -6 0 -12 M108 20 q-4 -6 0 -12"></path>
<path class="card-cart__handle" d="M126 44 q16 0 16 12 q0 12 -16 12"></path>
<rect class="card-cart__mug" x="84" y="32" width="44" height="46" rx="7"></rect>
<rect class="card-cart__rim" x="84" y="32" width="44" height="6" rx="3"></rect>
</svg>
<div class="card-cart__row">
<p class="card-cart__name">Stoneware mug</p>
<span class="card-cart__price">$24</span>
</div>
<button type="button" class="card-cart__add">Add to cart</button>
</article>.card-cart { position: relative; box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-cart__bag { position: absolute; right: 12px; top: 10px; z-index: 2; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
.card-cart__bag svg { width: 15px; height: 15px; }
.card-cart__bag--bump { animation: card-cart-bump 0.4s cubic-bezier(0.3, 1.8, 0.5, 1); }
@keyframes card-cart-bump {
30% { transform: scale(1.25) rotate(-8deg); }
}
.card-cart__count { position: absolute; right: -3px; top: -3px; min-width: 15px; height: 15px; padding: 0 3px; box-sizing: border-box; border-radius: 8px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-size: 9px; font-weight: 700; line-height: 15px; text-align: center; animation: card-cart-pop 0.4s cubic-bezier(0.3, 1.8, 0.5, 1); }
@keyframes card-cart-pop {
from { transform: scale(0); }
}
.card-cart__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.card-cart__art { display: block; width: 212px; height: 92px; border-radius: 10px; background: light-dark(#ebedf0, #232529); }
.card-cart__mug { fill: light-dark(#50535b, #c6c8cd); }
.card-cart__rim { fill: light-dark(#42454c, #e5e6e9); }
.card-cart__handle { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 6; }
.card-cart__steam { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 2; stroke-linecap: round; }
.card-cart__fly { position: absolute; left: 106px; top: 46px; z-index: 3; width: 28px; height: 28px; border-radius: 8px; background: light-dark(#50535b, #c6c8cd); pointer-events: none; }
.card-cart__row { display: flex; align-items: baseline; justify-content: space-between; margin: 10px 0; }
.card-cart__name { margin: 0; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-cart__price { font-size: 13px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); }
.card-cart__add { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; height: 34px; border: 0; border-radius: 10px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: transform 0.15s; }
.card-cart__add:active { transform: scale(0.97); }
.card-cart__add:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-cart__add svg { width: 14px; height: 14px; }
@media (prefers-reduced-motion: reduce) {
.card-cart__bag--bump, .card-cart__count { animation: none; }
}function initCardCart(root) {
var bag = root.querySelector('.card-cart__bag');
var sr = root.querySelector('.card-cart__sr');
var row = root.querySelector('.card-cart__row');
var add = root.querySelector('.card-cart__add');
var TICK =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M5 12.5l4.5 4.5L19 7.5"></path></svg>';
var count = 0;
var timer = 0;
var flights = [];
// When a copy lands the bag bumps (a fresh class replays it) and a new badge pops in with the count.
function land() {
count++;
bag.classList.remove('card-cart__bag--bump');
void bag.offsetWidth;
bag.classList.add('card-cart__bag--bump');
var old = bag.querySelector('.card-cart__count');
var badge = document.createElement('span');
badge.className = 'card-cart__count';
badge.setAttribute('aria-hidden', 'true');
badge.textContent = count;
if (old) old.replaceWith(badge);
else bag.appendChild(badge);
sr.textContent = count + ' in cart';
}
// A copy of the product flies up and over into the bag (WAAPI). Each press gets its own copy, so quick presses all land.
function onAdd() {
add.innerHTML = TICK + 'Added';
clearTimeout(timer);
timer = setTimeout(function () {
add.textContent = 'Add to cart';
}, 1400);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return land();
var fly = document.createElement('span');
fly.className = 'card-cart__fly';
fly.setAttribute('aria-hidden', 'true');
root.insertBefore(fly, row);
var a = fly.animate(
[
{ transform: 'translate(0, 0) scale(1)', opacity: 1 },
{ transform: 'translate(52px, -64px) scale(0.75)', opacity: 1, offset: 0.55 },
{ transform: 'translate(92px, -51px) scale(0.3)', opacity: 0.4 },
],
{ duration: 650, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' },
);
flights.push(a);
a.onfinish = function () {
flights.splice(flights.indexOf(a), 1);
fly.remove();
land();
};
}
add.addEventListener('click', onAdd);
return function () {
clearTimeout(timer);
flights.forEach(function (a) {
a.cancel();
});
add.removeEventListener('click', onAdd);
};
}
document.querySelectorAll('.card-cart').forEach(initCardCart);Goal: Create a "Cart" card.
Visual behavior: A product card: add it to the cart and a copy of the product flies in an arc into the cart icon, which bumps as its badge pops up with the new count.
Technique:
- Each press makes its own small copy of the product and flies it with the Web Animations API, three keyframes up, over and down into the bag, shrinking as it goes, so quick presses all land.
- When a copy lands the bag replays a bump keyframe and a fresh badge pops in with the count.
- The button shows a tick and Added for a moment after each press.
Accessibility: A real button; the count is announced through a visually hidden role="status" element, and the flying copy and the bag are aria-hidden. Under prefers-reduced-motion nothing flies and the count simply goes up.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Cart" card.
Visual behavior: A product card: add it to the cart and a copy of the product flies in an arc into the cart icon, which bumps as its badge pops up with the new count.
Technique:
- Each press makes its own small copy of the product and flies it with the Web Animations API, three keyframes up, over and down into the bag, shrinking as it goes, so quick presses all land.
- When a copy lands the bag replays a bump keyframe and a fresh badge pops in with the count.
- The button shows a tick and Added for a moment after each press.
Accessibility: A real button; the count is announced through a visually hidden role="status" element, and the flying copy and the bag are aria-hidden. Under prefers-reduced-motion nothing flies and the count simply goes up.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A product card: add it to the cart and a copy of the product flies in an arc into the cart icon, which bumps as its badge pops up with the new count.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, spring, state change, card
- Techniques
- keyframes, transform only, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real button; the count is announced through a visually hidden role="status" element, and the flying copy and the bag are aria-hidden. Under prefers-reduced-motion nothing flies and the count simply goes up.