Lemon pasta
- Spaghetti200 g
- Lemons1
- Parmesan50 g
- Butter30 g
Lemon pasta
import { useState } from 'react';
const ITEMS = [
{ name: 'Spaghetti', per: 100, unit: ' g' },
{ name: 'Lemons', per: 0.5, unit: '' },
{ name: 'Parmesan', per: 25, unit: ' g' },
{ name: 'Butter', per: 15, unit: ' g' },
];
const fmt = (q: number, unit: string) => {
const whole = Math.floor(q);
return (q % 1 ? `${whole || ''}½` : String(whole)) + unit;
};
const css = `
.card-servings { box-sizing: border-box; width: 240px; padding: 14px 16px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-servings__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-servings__title { margin: 0; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-servings__step { display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 999px; background: light-dark(#ebedf0, #232529); }
.card-servings__btn { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 15px; line-height: 1; cursor: pointer; }
.card-servings__btn:hover:not(:disabled) { background: light-dark(#dbdde1, #2e3035); }
.card-servings__btn:disabled { color: light-dark(#cbced3, #393c41); cursor: default; }
.card-servings__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; }
.card-servings__n { min-width: 18px; font-size: 12px; font-weight: 700; text-align: center; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; }
.card-servings__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.card-servings__list { margin: 0; padding: 0; list-style: none; }
.card-servings__row { display: flex; justify-content: space-between; padding: 7px 0; border-top: 1px solid light-dark(#ebedf0, #232529); font-size: 12px; color: light-dark(#50535b, #c6c8cd); }
/* Each quantity is a short window: the old figure rolls out as the new one rolls in. */
.card-servings__qty { position: relative; display: inline-grid; overflow: hidden; height: 18px; color: light-dark(#373a40, #f1f2f3); font-weight: 600; font-variant-numeric: tabular-nums; }
.card-servings__qty > span { grid-area: 1 / 1; justify-self: end; line-height: 18px; animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.3, 1.3, 0.5, 1); animation-fill-mode: both; }
.card-servings__qty > .card-servings__in--up { animation-name: card-servings-in-up; }
.card-servings__qty > .card-servings__out--up { animation-name: card-servings-out-up; }
.card-servings__qty > .card-servings__in--down { animation-name: card-servings-in-down; }
.card-servings__qty > .card-servings__out--down { animation-name: card-servings-out-down; }
@keyframes card-servings-in-up { from { transform: translateY(100%); opacity: 0; } }
@keyframes card-servings-out-up { to { transform: translateY(-100%); opacity: 0; } }
@keyframes card-servings-in-down { from { transform: translateY(-100%); opacity: 0; } }
@keyframes card-servings-out-down { to { transform: translateY(100%); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.card-servings__qty > span { animation: none !important; }
.card-servings__qty > [class*='card-servings__out'] { display: none; }
}
`;
export function ServingsCard() {
const [s, setS] = useState({ n: 2, prev: 2, dir: 'up', gen: 0 });
const set = (n: number) => setS((o) => ({ n, prev: o.n, dir: n > o.n ? 'up' : 'down', gen: o.gen + 1 }));
return (
<>
<style>
{css}
</style>
<article className="card-servings">
<div className="card-servings__top">
<p className="card-servings__title">Lemon pasta</p>
<div className="card-servings__step">
<button type="button" className="card-servings__btn" aria-label="Fewer servings" disabled={s.n <= 1} onClick={() => set(s.n - 1)}>
−
</button>
<span className="card-servings__n" aria-live="polite">
{s.n}
<span className="card-servings__sr"> servings</span>
</span>
<button type="button" className="card-servings__btn" aria-label="More servings" disabled={s.n >= 8} onClick={() => set(s.n + 1)}>
+
</button>
</div>
</div>
<ul className="card-servings__list">
{ITEMS.map((it, i) => (
<li key={it.name} className="card-servings__row">
{it.name}
<span className="card-servings__qty">
{/* Each row starts a beat after the one above. */}
{s.gen > 0 && (
<span key={`o${s.gen}`} className={`card-servings__out--${s.dir}`} style={{ animationDelay: `${i * 60}ms` }} aria-hidden="true">
{fmt(it.per * s.prev, it.unit)}
</span>
)}
<span key={`i${s.gen}`} className={s.gen > 0 ? `card-servings__in--${s.dir}` : ''} style={{ animationDelay: `${i * 60}ms` }}>
{fmt(it.per * s.n, it.unit)}
</span>
</span>
</li>
))}
</ul>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardServings(el) on the mounted element instead.
<article class="card-servings">
<div class="card-servings__top">
<p class="card-servings__title">Lemon pasta</p>
<div class="card-servings__step">
<button type="button" class="card-servings__btn" aria-label="Fewer servings">−</button>
<span class="card-servings__n" aria-live="polite">
2
<span class="card-servings__sr"> servings</span>
</span>
<button type="button" class="card-servings__btn" aria-label="More servings">+</button>
</div>
</div>
<ul class="card-servings__list">
<li class="card-servings__row">
Spaghetti
<span class="card-servings__qty">
<span style="animation-delay:0ms">200 g</span>
</span>
</li>
<li class="card-servings__row">
Lemons
<span class="card-servings__qty">
<span style="animation-delay:60ms">1</span>
</span>
</li>
<li class="card-servings__row">
Parmesan
<span class="card-servings__qty">
<span style="animation-delay:120ms">50 g</span>
</span>
</li>
<li class="card-servings__row">
Butter
<span class="card-servings__qty">
<span style="animation-delay:180ms">30 g</span>
</span>
</li>
</ul>
</article>.card-servings { box-sizing: border-box; width: 240px; padding: 14px 16px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-servings__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-servings__title { margin: 0; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-servings__step { display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 999px; background: light-dark(#ebedf0, #232529); }
.card-servings__btn { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 15px; line-height: 1; cursor: pointer; }
.card-servings__btn:hover:not(:disabled) { background: light-dark(#dbdde1, #2e3035); }
.card-servings__btn:disabled { color: light-dark(#cbced3, #393c41); cursor: default; }
.card-servings__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; }
.card-servings__n { min-width: 18px; font-size: 12px; font-weight: 700; text-align: center; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; }
.card-servings__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.card-servings__list { margin: 0; padding: 0; list-style: none; }
.card-servings__row { display: flex; justify-content: space-between; padding: 7px 0; border-top: 1px solid light-dark(#ebedf0, #232529); font-size: 12px; color: light-dark(#50535b, #c6c8cd); }
/* Each quantity is a short window: the old figure rolls out as the new one rolls in. */
.card-servings__qty { position: relative; display: inline-grid; overflow: hidden; height: 18px; color: light-dark(#373a40, #f1f2f3); font-weight: 600; font-variant-numeric: tabular-nums; }
.card-servings__qty > span { grid-area: 1 / 1; justify-self: end; line-height: 18px; animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.3, 1.3, 0.5, 1); animation-fill-mode: both; }
.card-servings__qty > .card-servings__in--up { animation-name: card-servings-in-up; }
.card-servings__qty > .card-servings__out--up { animation-name: card-servings-out-up; }
.card-servings__qty > .card-servings__in--down { animation-name: card-servings-in-down; }
.card-servings__qty > .card-servings__out--down { animation-name: card-servings-out-down; }
@keyframes card-servings-in-up { from { transform: translateY(100%); opacity: 0; } }
@keyframes card-servings-out-up { to { transform: translateY(-100%); opacity: 0; } }
@keyframes card-servings-in-down { from { transform: translateY(-100%); opacity: 0; } }
@keyframes card-servings-out-down { to { transform: translateY(100%); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.card-servings__qty > span { animation: none !important; }
.card-servings__qty > [class*='card-servings__out'] { display: none; }
}function initCardServings(root) {
var ITEMS = [
{ per: 100, unit: ' g' },
{ per: 0.5, unit: '' },
{ per: 25, unit: ' g' },
{ per: 15, unit: ' g' },
];
var btns = root.querySelectorAll('.card-servings__btn');
var count = root.querySelector('.card-servings__n').firstChild;
var qtys = root.querySelectorAll('.card-servings__qty');
var n = 2;
function fmt(q, unit) {
var whole = Math.floor(q);
return (q % 1 ? (whole || '') + '½' : String(whole)) + unit;
}
// Each quantity is a short window: the old figure rolls out as the new one rolls in, each row a beat after the one above.
function set(next) {
var dir = next > n ? 'up' : 'down';
qtys.forEach(function (qty, i) {
var item = ITEMS[i];
var delay = i * 60 + 'ms';
var out = document.createElement('span');
out.className = 'card-servings__out--' + dir;
out.style.animationDelay = delay;
out.setAttribute('aria-hidden', 'true');
out.textContent = fmt(item.per * n, item.unit);
var into = document.createElement('span');
into.className = 'card-servings__in--' + dir;
into.style.animationDelay = delay;
into.textContent = fmt(item.per * next, item.unit);
qty.replaceChildren(out, into);
});
n = next;
count.textContent = n;
btns[0].disabled = n <= 1;
btns[1].disabled = n >= 8;
}
function onLess() {
set(n - 1);
}
function onMore() {
set(n + 1);
}
btns[0].addEventListener('click', onLess);
btns[1].addEventListener('click', onMore);
return function () {
btns[0].removeEventListener('click', onLess);
btns[1].removeEventListener('click', onMore);
};
}
document.querySelectorAll('.card-servings').forEach(initCardServings);Goal: Create a "Servings" card.
Visual behavior: A recipe card: change the servings and every quantity rescales, the old figure rolling out as the new one rolls in, one ingredient after another.
Technique:
- Each quantity is a one line window holding two copies in the same grid cell: the old amount animates out and the new one animates in, upward when the servings go up and downward when they go down.
- Each row's animation delay is 60ms more than the row above, so the change ripples down the list.
- Amounts are stored per serving and written with halves (½), so 1 lemon becomes 1½ rather than 1.5.
Accessibility: Minus and plus are labelled buttons, disabled at the limits, and the servings count is a polite live region that says "servings" to screen readers. The outgoing figures are aria-hidden. Under prefers-reduced-motion the amounts change without rolling.
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 "Servings" card.
Visual behavior: A recipe card: change the servings and every quantity rescales, the old figure rolling out as the new one rolls in, one ingredient after another.
Technique:
- Each quantity is a one line window holding two copies in the same grid cell: the old amount animates out and the new one animates in, upward when the servings go up and downward when they go down.
- Each row's animation delay is 60ms more than the row above, so the change ripples down the list.
- Amounts are stored per serving and written with halves (½), so 1 lemon becomes 1½ rather than 1.5.
Accessibility: Minus and plus are labelled buttons, disabled at the limits, and the servings count is a polite live region that says "servings" to screen readers. The outgoing figures are aria-hidden. Under prefers-reduced-motion the amounts change without rolling.
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
A recipe card: change the servings and every quantity rescales, the old figure rolling out as the new one rolls in, one ingredient after another.
Minus and plus are labelled buttons, disabled at the limits, and the servings count is a polite live region that says "servings" to screen readers. The outgoing figures are aria-hidden. Under prefers-reduced-motion the amounts change without rolling.