Recipe Card with Serving Scaler — Free HTML CSS JS Snippet
Recipe Card with Serving Scaler · Cards · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="wrap">
<div class="recipe-card">
<div class="recipe-photo">
<span class="recipe-tag">30 min</span>
</div>
<div class="recipe-body">
<h2>Creamy Garlic Parmesan Pasta</h2>
<p class="recipe-sub">A weeknight pasta with a silky garlic-parmesan sauce.</p>
<div class="serving-row">
<span class="serving-label">Servings</span>
<div class="stepper">
<button class="step-btn" id="dec" aria-label="Decrease servings">−</button>
<span class="serving-count" id="count">4</span>
<button class="step-btn" id="inc" aria-label="Increase servings">+</button>
</div>
</div>
<ul class="ingredients" id="ingredients"></ul>
<div class="nutrition">
<div class="nut-item"><span class="nut-val" id="nutCal">560</span><span class="nut-label">kcal</span></div>
<div class="nut-item"><span class="nut-val" id="nutProtein">22g</span><span class="nut-label">protein</span></div>
<div class="nut-item"><span class="nut-val" id="nutCarbs">61g</span><span class="nut-label">carbs</span></div>
</div>
</div>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 20px; }
.wrap { width: 100%; max-width: 380px; }
.recipe-card { background: #fff; border-radius: 20px; overflow: hidden; box-shadow: 0 18px 44px rgba(15,23,42,0.12); border: 1px solid #f1f5f9; }
.recipe-photo { height: 150px; background: linear-gradient(135deg, #fbbf24, #f97316 55%, #ea580c); position: relative; display: flex; align-items: flex-end; padding: 12px; }
.recipe-tag { background: rgba(15,23,42,0.55); color: #fff; font-size: 11px; font-weight: 700; padding: 5px 10px; border-radius: 999px; backdrop-filter: blur(4px); }
.recipe-body { padding: 20px 22px 22px; }
.recipe-body h2 { font-size: 18px; font-weight: 800; color: #0f172a; line-height: 1.3; }
.recipe-sub { font-size: 13px; color: #64748b; margin-top: 4px; margin-bottom: 16px; line-height: 1.5; }
.serving-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: #f8fafc; border-radius: 12px; margin-bottom: 14px; }
.serving-label { font-size: 12.5px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.04em; }
.stepper { display: flex; align-items: center; gap: 12px; }
.step-btn { width: 28px; height: 28px; border-radius: 8px; border: none; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.12); color: #f97316; font-size: 16px; font-weight: 800; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.1s, background 0.12s; }
.step-btn:hover { background: #fff7ed; }
.step-btn:active { transform: scale(0.9); }
.serving-count { font-size: 16px; font-weight: 800; color: #0f172a; min-width: 18px; text-align: center; font-variant-numeric: tabular-nums; }
.ingredients { list-style: none; display: flex; flex-direction: column; gap: 9px; margin-bottom: 16px; }
.ingredients li { display: flex; align-items: baseline; gap: 8px; font-size: 13.5px; color: #334155; padding-bottom: 9px; border-bottom: 1px dashed #f1f5f9; }
.ingredients li:last-child { border-bottom: none; padding-bottom: 0; }
.ing-qty { font-weight: 800; color: #ea580c; min-width: 58px; flex-shrink: 0; font-variant-numeric: tabular-nums; transition: color 0.15s; }
.ing-qty.pulse { color: #16a34a; }
.ing-name { color: #475569; }
.nutrition { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; }
.nut-item { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.nut-val { font-size: 14px; font-weight: 800; color: #0f172a; font-variant-numeric: tabular-nums; }
.nut-label { font-size: 10.5px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }// Base quantities are all defined for BASE_SERVINGS — every other serving count
// is derived by scaling this array, never by editing text directly.
var BASE_SERVINGS = 4;
var INGREDIENTS = [
{ qty: 12, unit: 'oz', name: 'fettuccine pasta' },
{ qty: 3, unit: 'tbsp', name: 'butter' },
{ qty: 4, unit: 'cloves', name: 'garlic, minced' },
{ qty: 1, unit: 'cup', name: 'heavy cream' },
{ qty: 0.75, unit: 'cup', name: 'grated parmesan' },
{ qty: 0.5, unit: 'tsp', name: 'black pepper' },
{ qty: 2, unit: 'tbsp', name: 'chopped parsley' },
];
var BASE_NUTRITION = { cal: 560, protein: 22, carbs: 61 };
var servings = BASE_SERVINGS;
var MIN_SERVINGS = 1;
var MAX_SERVINGS = 12;
function formatQty(n) {
// Round to a friendly fraction-like decimal: whole numbers stay whole,
// otherwise show at most 2 decimal places with trailing zeros trimmed.
var rounded = Math.round(n * 100) / 100;
return rounded % 1 === 0 ? String(rounded) : rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function render(animate) {
var scale = servings / BASE_SERVINGS;
document.getElementById('count').textContent = servings;
var list = document.getElementById('ingredients');
list.innerHTML = INGREDIENTS.map(function (ing) {
var scaledQty = formatQty(ing.qty * scale);
return '<li><span class="ing-qty">' + scaledQty + ' ' + ing.unit + '</span><span class="ing-name">' + ing.name + '</span></li>';
}).join('');
document.getElementById('nutCal').textContent = Math.round(BASE_NUTRITION.cal * scale);
document.getElementById('nutProtein').textContent = Math.round(BASE_NUTRITION.protein * scale) + 'g';
document.getElementById('nutCarbs').textContent = Math.round(BASE_NUTRITION.carbs * scale) + 'g';
if (animate) {
document.querySelectorAll('.ing-qty').forEach(function (el) {
el.classList.add('pulse');
setTimeout(function () { el.classList.remove('pulse'); }, 260);
});
}
}
document.getElementById('inc').addEventListener('click', function () {
if (servings >= MAX_SERVINGS) return;
servings++;
render(true);
});
document.getElementById('dec').addEventListener('click', function () {
if (servings <= MIN_SERVINGS) return;
servings--;
render(true);
});
render(false);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Recipe Card with Serving Scaler — Proportional Ingredient Math & Live Nutrition Recalculation

Printed recipes are locked to whatever serving count the author happened to cook for, which means doubling a recipe for guests or halving it for a solo dinner requires doing the fraction math in your head over the sink. This card removes that friction: a plus/minus stepper next to "Servings" recalculates every single ingredient quantity — and the calorie, protein, and carb totals — the moment the count changes, all derived from one base data array instead of being retyped by hand.
Base quantities, not base text
The trick that makes this work is that INGREDIENTS never stores display strings like "12 oz" — it stores a plain qty number plus a unit and name, all defined for a fixed BASE_SERVINGS of 4. A scale factor of servings / BASE_SERVINGS is computed once per render, and every ingredient's displayed quantity is ing.qty * scale, formatted only at the last step. Because the source numbers are never touched, scaling up to 12 servings and back down to 1 produces exactly the same numbers you started with — no accumulated rounding drift from repeatedly editing displayed text.
Friendly quantity formatting
Multiplying 0.75 cups by 1.5 servings gives 1.125, which is not a number anyone wants to read at a glance. formatQty() rounds to two decimal places and trims a needless trailing zero or dangling decimal point, so the card shows "1.13" rather than "1.125" or "1.1250000000000002" (the kind of floating-point artifact that shows up if you multiply and display without any rounding step at all).
Nutrition scales too, not just ingredients
BASE_NUTRITION holds calorie, protein, and carb totals for the same base serving count, and they're scaled by the identical scale factor used for ingredients — so the nutrition panel at the bottom of the card always describes the batch currently shown above it, not the original 4-serving version. This matters for a card that might get screenshotted or printed at a scaled-up serving count; stale nutrition numbers next to correctly-scaled ingredients would be actively misleading.
A pulse instead of a jump
Every .ing-qty briefly gets a .pulse class (a quick color shift, cleared via setTimeout) whenever the servings change, so a viewer's eye is drawn to the fact that the numbers just updated rather than having them silently swap while attention is on the stepper buttons.
Clamped stepper bounds
MIN_SERVINGS and MAX_SERVINGS (1 and 12 in the demo) prevent the stepper from producing a zero, negative, or absurdly large serving count. In a real recipe app you'd likely tune the upper bound to whatever batch size your kitchen equipment or pot size can realistically handle — a stand mixer bowl or a stockpot has a hard physical ceiling that a UI limit should reflect.
Step by step
How to Use
- 1Click + or − on the servings stepperEvery ingredient quantity and the calorie/protein/carb totals recalculate instantly and pulse briefly to draw the eye.
- 2Edit the base ingredientsUpdate the INGREDIENTS array — each item has a qty number (for BASE_SERVINGS), a unit string, and a name string.
- 3Change the base serving countSet BASE_SERVINGS to whatever count your original recipe quantities were written for; all scaling math is relative to it.
- 4Adjust the min/max stepper rangeEdit MIN_SERVINGS and MAX_SERVINGS to fit realistic batch sizes for your recipe.
- 5Update the nutrition base valuesSet BASE_NUTRITION.cal/protein/carbs to the totals for BASE_SERVINGS — they scale with the same factor as ingredients.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a Tailwind CSS version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Storing a plain number lets scale multiplication (qty * scale) work correctly for any serving count. If quantities were stored as pre-formatted strings like "3/4 cup", scaling them would require parsing fractions out of text, which is far more error-prone than multiplying a number.
formatQty() rounds to two decimal places with Math.round(n * 100) / 100, then strips a trailing zero or trailing decimal point with a regex, so 1.125 displays as 1.13 rather than a long floating-point decimal.
Yes — BASE_NUTRITION values are multiplied by the same scale factor (servings / BASE_SERVINGS) used for ingredients, inside the same render() call, so nutrition and ingredients always describe the same batch size.
The current formatting shows decimals rather than fractions (e.g. 0.5 rather than 1/2). To show fractions, add a small decimal-to-fraction lookup (0.25 → "1/4", 0.5 → "1/2", 0.75 → "3/4") in formatQty() before falling back to a decimal for values that do not match a common fraction.
Yes — MAX_SERVINGS already clamps the increment button; the click handler returns early once servings reaches it. Lower or raise the constant to match a realistic limit for your recipe or equipment.
Keep servings in useState(BASE_SERVINGS), compute scale with useMemo, and map INGREDIENTS to <li> elements using the scaled quantity inline — the same formatQty logic ports directly into the render function.