16 / 18
All 18 in 18 Tailwind CSS Product Cards
- 01Tailwind Product Card with Hover-Reveal Add to Cart
- 02Pure-Tailwind 3D Flip Product Card with Spec Back
- 03Tailwind Contained Image Zoom Product Card
- 04Tailwind Mouse-Tracking 3D Tilt Product Card
- 05Tailwind Slide-Up Details Panel Product Card
- 06Tailwind Minimalist eCommerce Product Card
- 07Tailwind Glassmorphism Product Card
- 08Tailwind Dark-Mode Gadget Product Card with Accent Glow
- 09Tailwind Neon Glowing Conic-Border Product Card
- 10Tailwind Material-Design Elevation Product Card
- 11Tailwind Horizontal Product Card with Container Queries
- 12Tailwind Auto-Fit Product Catalog Grid
- 13Tailwind Sale Ribbon & Coupon Badge Product Card
- 14Tailwind Sneaker Card with Colourway & Size Picker
- 15Tailwind Digital Download Product Card
- 16Tailwind Food Delivery Card with Quantity Stepper
- 17Tailwind Real Estate Listing Card with Favourite Toggle
- 18Tailwind Subscription Pricing Card with Billing Switch
Tailwind Food Delivery Card with Quantity Stepper
The delivery-app dish card: dietary pills, a rating and prep-time line, and a +/− stepper driving a live running total. CSS owns all of it except two numbers — the script writes the count and the total, sets [disabled] so disabled: can style it, and announces the change in an aria-live region.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tpc-16 w-full min-h-svh grid place-items-center bg-tpc16-bg font-tpc16-sans px-5 py-12" data-price="12.5" aria-label="Food delivery dish card with quantity stepper demo">
<article class="w-[min(360px,100%)] rounded-[18px] overflow-hidden bg-tpc16-card shadow-[0_16px_40px_-22px_oklch(0.3_0.04_60/0.4)]">
<div role="img" aria-label="Dish photo: Smoky bean chilli bowl" class="aspect-[16/10] bg-linear-[135deg,var(--color-tpc16-ph),var(--color-tpc16-ph2)] bg-[url('https://images.unsplash.com/photo-1547592166-23ac45744acd?w=800&h=500&fit=crop&q=70')] bg-cover bg-center"></div>
<div class="px-5 pt-4.5 pb-5">
<div class="flex items-start justify-between gap-3">
<h3 class="text-[18px] font-bold text-tpc16-ink text-pretty">Smoky Bean Chilli Bowl</h3>
<div class="flex gap-1.5 shrink-0">
<span class="px-2.25 py-1 rounded-full bg-tpc16-veg text-tpc16-vegink text-[10px] font-bold tracking-[0.04em] uppercase">Vegan</span>
<span class="px-2.25 py-1 rounded-full bg-tpc16-gf text-tpc16-gfink text-[10px] font-bold tracking-[0.04em] uppercase">GF</span>
</div>
</div>
<p class="mt-2.5 text-[13.5px] leading-[1.5] text-tpc16-mut">Slow-cooked black beans, fire-roasted peppers, lime crema.</p>
<p class="mt-2.5 text-[13px] text-tpc16-mut2"><span aria-hidden="true" class="text-tpc16-star">★</span> 4.7 · 20–30 min · $2.99 delivery</p>
<div class="mt-4.5 flex items-center justify-between gap-3">
<div role="group" aria-label="Quantity" class="inline-flex items-center border border-tpc16-edge rounded-xl overflow-hidden">
<button type="button" data-dec aria-label="Decrease quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">−</button>
<span data-qty class="min-w-9 text-center text-[15px] font-bold text-tpc16-ink tabular-nums">1</span>
<button type="button" data-inc aria-label="Increase quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">+</button>
</div>
<button type="button" data-add aria-live="polite" class="flex-1 min-h-11 h-11.5 rounded-xl border-0 cursor-pointer bg-tpc16-acc text-tpc16-onacc text-[14px] font-bold tabular-nums transition-[filter] duration-150 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc16-ring focus-visible:outline-offset-2 motion-reduce:transition-none">Add · $12.50</button>
</div>
</div>
</article>
</section><section class="tpc-16 w-full min-h-svh grid place-items-center bg-tpc16-bg font-tpc16-sans px-5 py-12" data-price="12.5" aria-label="Food delivery dish card with quantity stepper demo">
<article class="w-[min(360px,100%)] rounded-[18px] overflow-hidden bg-tpc16-card shadow-[0_16px_40px_-22px_oklch(0.3_0.04_60/0.4)]">
<div role="img" aria-label="Dish photo: Smoky bean chilli bowl" class="aspect-[16/10] bg-linear-[135deg,var(--color-tpc16-ph),var(--color-tpc16-ph2)] bg-[url('https://images.unsplash.com/photo-1547592166-23ac45744acd?w=800&h=500&fit=crop&q=70')] bg-cover bg-center"></div>
<div class="px-5 pt-4.5 pb-5">
<div class="flex items-start justify-between gap-3">
<h3 class="text-[18px] font-bold text-tpc16-ink text-pretty">Smoky Bean Chilli Bowl</h3>
<div class="flex gap-1.5 shrink-0">
<span class="px-2.25 py-1 rounded-full bg-tpc16-veg text-tpc16-vegink text-[10px] font-bold tracking-[0.04em] uppercase">Vegan</span>
<span class="px-2.25 py-1 rounded-full bg-tpc16-gf text-tpc16-gfink text-[10px] font-bold tracking-[0.04em] uppercase">GF</span>
</div>
</div>
<p class="mt-2.5 text-[13.5px] leading-[1.5] text-tpc16-mut">Slow-cooked black beans, fire-roasted peppers, lime crema.</p>
<p class="mt-2.5 text-[13px] text-tpc16-mut2"><span aria-hidden="true" class="text-tpc16-star">★</span> 4.7 · 20–30 min · $2.99 delivery</p>
<div class="mt-4.5 flex items-center justify-between gap-3">
<div role="group" aria-label="Quantity" class="inline-flex items-center border border-tpc16-edge rounded-xl overflow-hidden">
<button type="button" data-dec aria-label="Decrease quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">−</button>
<span data-qty class="min-w-9 text-center text-[15px] font-bold text-tpc16-ink tabular-nums">1</span>
<button type="button" data-inc aria-label="Increase quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">+</button>
</div>
<button type="button" data-add aria-live="polite" class="flex-1 min-h-11 h-11.5 rounded-xl border-0 cursor-pointer bg-tpc16-acc text-tpc16-onacc text-[14px] font-bold tabular-nums transition-[filter] duration-150 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc16-ring focus-visible:outline-offset-2 motion-reduce:transition-none">Add · $12.50</button>
</div>
</div>
</article>
</section>@import "tailwindcss";
@theme {
--color-tpc16-bg: #f6f3ee;
--color-tpc16-bg: oklch(0.96 0.008 75);
--color-tpc16-card: #ffffff;
--color-tpc16-card: oklch(1 0 0);
--color-tpc16-ink: #2a1e12;
--color-tpc16-ink: oklch(0.26 0.025 60);
--color-tpc16-mut: #5f5344;
--color-tpc16-mut: oklch(0.46 0.02 65);
--color-tpc16-mut2: #6d6152;
--color-tpc16-mut2: oklch(0.51 0.02 68);
--color-tpc16-edge: #e4dccf;
--color-tpc16-edge: oklch(0.9 0.015 80);
--color-tpc16-step: #faf7f2;
--color-tpc16-step: oklch(0.98 0.006 80);
--color-tpc16-step2: #f0e8db;
--color-tpc16-step2: oklch(0.94 0.014 80);
--color-tpc16-ph: #f0e4d4;
--color-tpc16-ph: oklch(0.93 0.02 75);
--color-tpc16-ph2: #e6d2b8;
--color-tpc16-ph2: oklch(0.88 0.035 78);
--color-tpc16-veg: #dcf2e0;
--color-tpc16-veg: oklch(0.93 0.06 150);
--color-tpc16-vegink: #1f6b3b;
--color-tpc16-vegink: oklch(0.42 0.12 150);
--color-tpc16-gf: #dde9f8;
--color-tpc16-gf: oklch(0.93 0.05 250);
--color-tpc16-gfink: #1f548f;
--color-tpc16-gfink: oklch(0.44 0.12 255);
--color-tpc16-star: #c78a12;
--color-tpc16-star: oklch(0.68 0.14 75);
--color-tpc16-acc: #d4601f;
--color-tpc16-acc: oklch(0.6 0.17 45);
--color-tpc16-ring: #f0b48c;
--color-tpc16-ring: oklch(0.82 0.09 45);
--color-tpc16-onacc: #ffffff;
--color-tpc16-onacc: oklch(1 0 0);
--font-tpc16-sans: 'Outfit', system-ui, sans-serif;
}
.tpc-16 {
width: 100%;
min-height: 100svh;
}
.tpc-16 ::selection {
background: var(--color-tpc16-acc);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tpc16-bg: #f6f3ee;
--color-tpc16-bg: oklch(0.96 0.008 75);
--color-tpc16-card: #ffffff;
--color-tpc16-card: oklch(1 0 0);
--color-tpc16-ink: #2a1e12;
--color-tpc16-ink: oklch(0.26 0.025 60);
--color-tpc16-mut: #5f5344;
--color-tpc16-mut: oklch(0.46 0.02 65);
--color-tpc16-mut2: #6d6152;
--color-tpc16-mut2: oklch(0.51 0.02 68);
--color-tpc16-edge: #e4dccf;
--color-tpc16-edge: oklch(0.9 0.015 80);
--color-tpc16-step: #faf7f2;
--color-tpc16-step: oklch(0.98 0.006 80);
--color-tpc16-step2: #f0e8db;
--color-tpc16-step2: oklch(0.94 0.014 80);
--color-tpc16-ph: #f0e4d4;
--color-tpc16-ph: oklch(0.93 0.02 75);
--color-tpc16-ph2: #e6d2b8;
--color-tpc16-ph2: oklch(0.88 0.035 78);
--color-tpc16-veg: #dcf2e0;
--color-tpc16-veg: oklch(0.93 0.06 150);
--color-tpc16-vegink: #1f6b3b;
--color-tpc16-vegink: oklch(0.42 0.12 150);
--color-tpc16-gf: #dde9f8;
--color-tpc16-gf: oklch(0.93 0.05 250);
--color-tpc16-gfink: #1f548f;
--color-tpc16-gfink: oklch(0.44 0.12 255);
--color-tpc16-star: #c78a12;
--color-tpc16-star: oklch(0.68 0.14 75);
--color-tpc16-acc: #d4601f;
--color-tpc16-acc: oklch(0.6 0.17 45);
--color-tpc16-ring: #f0b48c;
--color-tpc16-ring: oklch(0.82 0.09 45);
--color-tpc16-onacc: #ffffff;
--color-tpc16-onacc: oklch(1 0 0);
--font-tpc16-sans: 'Outfit', system-ui, sans-serif;
}
.tpc-16 {
width: 100%;
min-height: 100svh;
}
.tpc-16 ::selection {
background: var(--color-tpc16-acc);
color: #fff;
}document.querySelectorAll('.tpc-16').forEach(function (root) {
if (root.dataset.bound) return;
root.dataset.bound = '1';
var unit = parseFloat(root.dataset.price) || 0;
var qtyEl = root.querySelector('[data-qty]');
var addEl = root.querySelector('[data-add]');
var inc = root.querySelector('[data-inc]');
var dec = root.querySelector('[data-dec]');
if (!qtyEl || !addEl || !inc || !dec) return;
var qty = 1;
var MAX = 20;
function render() {
qtyEl.textContent = qty;
addEl.textContent = 'Add \u00b7 $' + (unit * qty).toFixed(2);
dec.disabled = qty <= 1; // JS sets the attribute; disabled: styles it
inc.disabled = qty >= MAX;
}
inc.addEventListener('click', function () { if (qty < MAX) { qty++; render(); } });
dec.addEventListener('click', function () { if (qty > 1) { qty--; render(); } });
render();
});document.querySelectorAll('.tpc-16').forEach(function (root) {
if (root.dataset.bound) return;
root.dataset.bound = '1';
var unit = parseFloat(root.dataset.price) || 0;
var qtyEl = root.querySelector('[data-qty]');
var addEl = root.querySelector('[data-add]');
var inc = root.querySelector('[data-inc]');
var dec = root.querySelector('[data-dec]');
if (!qtyEl || !addEl || !inc || !dec) return;
var qty = 1;
var MAX = 20;
function render() {
qtyEl.textContent = qty;
addEl.textContent = 'Add \u00b7 $' + (unit * qty).toFixed(2);
dec.disabled = qty <= 1; // JS sets the attribute; disabled: styles it
inc.disabled = qty >= MAX;
}
inc.addEventListener('click', function () { if (qty < MAX) { qty++; render(); } });
dec.addEventListener('click', function () { if (qty > 1) { qty--; render(); } });
render();
});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-tpc16-bg: #f6f3ee;
--color-tpc16-bg: oklch(0.96 0.008 75);
--color-tpc16-card: #ffffff;
--color-tpc16-card: oklch(1 0 0);
--color-tpc16-ink: #2a1e12;
--color-tpc16-ink: oklch(0.26 0.025 60);
--color-tpc16-mut: #5f5344;
--color-tpc16-mut: oklch(0.46 0.02 65);
--color-tpc16-mut2: #6d6152;
--color-tpc16-mut2: oklch(0.51 0.02 68);
--color-tpc16-edge: #e4dccf;
--color-tpc16-edge: oklch(0.9 0.015 80);
--color-tpc16-step: #faf7f2;
--color-tpc16-step: oklch(0.98 0.006 80);
--color-tpc16-step2: #f0e8db;
--color-tpc16-step2: oklch(0.94 0.014 80);
--color-tpc16-ph: #f0e4d4;
--color-tpc16-ph: oklch(0.93 0.02 75);
--color-tpc16-ph2: #e6d2b8;
--color-tpc16-ph2: oklch(0.88 0.035 78);
--color-tpc16-veg: #dcf2e0;
--color-tpc16-veg: oklch(0.93 0.06 150);
--color-tpc16-vegink: #1f6b3b;
--color-tpc16-vegink: oklch(0.42 0.12 150);
--color-tpc16-gf: #dde9f8;
--color-tpc16-gf: oklch(0.93 0.05 250);
--color-tpc16-gfink: #1f548f;
--color-tpc16-gfink: oklch(0.44 0.12 255);
--color-tpc16-star: #c78a12;
--color-tpc16-star: oklch(0.68 0.14 75);
--color-tpc16-acc: #d4601f;
--color-tpc16-acc: oklch(0.6 0.17 45);
--color-tpc16-ring: #f0b48c;
--color-tpc16-ring: oklch(0.82 0.09 45);
--color-tpc16-onacc: #ffffff;
--color-tpc16-onacc: oklch(1 0 0);
--font-tpc16-sans: 'Outfit', system-ui, sans-serif;
}
.tpc-16 {
width: 100%;
min-height: 100svh;
}
.tpc-16 ::selection {
background: var(--color-tpc16-acc);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tpc16-bg: #f6f3ee;
--color-tpc16-bg: oklch(0.96 0.008 75);
--color-tpc16-card: #ffffff;
--color-tpc16-card: oklch(1 0 0);
--color-tpc16-ink: #2a1e12;
--color-tpc16-ink: oklch(0.26 0.025 60);
--color-tpc16-mut: #5f5344;
--color-tpc16-mut: oklch(0.46 0.02 65);
--color-tpc16-mut2: #6d6152;
--color-tpc16-mut2: oklch(0.51 0.02 68);
--color-tpc16-edge: #e4dccf;
--color-tpc16-edge: oklch(0.9 0.015 80);
--color-tpc16-step: #faf7f2;
--color-tpc16-step: oklch(0.98 0.006 80);
--color-tpc16-step2: #f0e8db;
--color-tpc16-step2: oklch(0.94 0.014 80);
--color-tpc16-ph: #f0e4d4;
--color-tpc16-ph: oklch(0.93 0.02 75);
--color-tpc16-ph2: #e6d2b8;
--color-tpc16-ph2: oklch(0.88 0.035 78);
--color-tpc16-veg: #dcf2e0;
--color-tpc16-veg: oklch(0.93 0.06 150);
--color-tpc16-vegink: #1f6b3b;
--color-tpc16-vegink: oklch(0.42 0.12 150);
--color-tpc16-gf: #dde9f8;
--color-tpc16-gf: oklch(0.93 0.05 250);
--color-tpc16-gfink: #1f548f;
--color-tpc16-gfink: oklch(0.44 0.12 255);
--color-tpc16-star: #c78a12;
--color-tpc16-star: oklch(0.68 0.14 75);
--color-tpc16-acc: #d4601f;
--color-tpc16-acc: oklch(0.6 0.17 45);
--color-tpc16-ring: #f0b48c;
--color-tpc16-ring: oklch(0.82 0.09 45);
--color-tpc16-onacc: #ffffff;
--color-tpc16-onacc: oklch(1 0 0);
--font-tpc16-sans: 'Outfit', system-ui, sans-serif;
}
.tpc-16 {
width: 100%;
min-height: 100svh;
}
.tpc-16 ::selection {
background: var(--color-tpc16-acc);
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=Outfit:wght@400;500;600;700;800&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=Outfit:wght@400;500;600;700;800&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The cleanest demonstration in the collection of where the line falls: the script writes two text nodes and one boolean attribute, and
disabled:,hover:andfocus-visible:handle every appearance those states imply. NoclassList.addanywhere. getElementByIdand five hard-coded ids becomequerySelectorAll('.tpc-16')plus[data-qty]-style hooks, so the snippet works pasted anywhere and drives every card on a menu page, not just the first.focus-visible:-outline-offset-2— the negative offset is load-bearing. The stepper shell isoverflow-hiddento get its seamless seam, which would clip a normal outset ring.- Enhancement over the CSS original: the increment button now disables at the maximum instead of silently ignoring presses, the stepper controls grew to 44px, muted text was darkened to clear 4.5:1, and both the count and the total are
tabular-numsso the row stops jogging as the price crosses a digit.
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 Food Delivery Card with Quantity Stepper
Source: https://codefronts.com/components/tailwind-css-product-cards/tailwind-food-delivery-card-quantity-stepper/
The delivery-app dish card: dietary pills, a rating and prep-time line, and a +/− stepper driving a live running total. CSS owns all of it except two numbers — the script writes the count and the total, sets [disabled] so disabled: can style it, and announces the change in an aria-live region.
## HTML
```html
<section class="tpc-16 w-full min-h-svh grid place-items-center bg-tpc16-bg font-tpc16-sans px-5 py-12" data-price="12.5" aria-label="Food delivery dish card with quantity stepper demo">
<article class="w-[min(360px,100%)] rounded-[18px] overflow-hidden bg-tpc16-card shadow-[0_16px_40px_-22px_oklch(0.3_0.04_60/0.4)]">
<div role="img" aria-label="Dish photo: Smoky bean chilli bowl" class="aspect-[16/10] bg-linear-[135deg,var(--color-tpc16-ph),var(--color-tpc16-ph2)] bg-[url('https://images.unsplash.com/photo-1547592166-23ac45744acd?w=800&h=500&fit=crop&q=70')] bg-cover bg-center"></div>
<div class="px-5 pt-4.5 pb-5">
<div class="flex items-start justify-between gap-3">
<h3 class="text-[18px] font-bold text-tpc16-ink text-pretty">Smoky Bean Chilli Bowl</h3>
<div class="flex gap-1.5 shrink-0">
<span class="px-2.25 py-1 rounded-full bg-tpc16-veg text-tpc16-vegink text-[10px] font-bold tracking-[0.04em] uppercase">Vegan</span>
<span class="px-2.25 py-1 rounded-full bg-tpc16-gf text-tpc16-gfink text-[10px] font-bold tracking-[0.04em] uppercase">GF</span>
</div>
</div>
<p class="mt-2.5 text-[13.5px] leading-[1.5] text-tpc16-mut">Slow-cooked black beans, fire-roasted peppers, lime crema.</p>
<p class="mt-2.5 text-[13px] text-tpc16-mut2"><span aria-hidden="true" class="text-tpc16-star">★</span> 4.7 · 20–30 min · $2.99 delivery</p>
<div class="mt-4.5 flex items-center justify-between gap-3">
<div role="group" aria-label="Quantity" class="inline-flex items-center border border-tpc16-edge rounded-xl overflow-hidden">
<button type="button" data-dec aria-label="Decrease quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">−</button>
<span data-qty class="min-w-9 text-center text-[15px] font-bold text-tpc16-ink tabular-nums">1</span>
<button type="button" data-inc aria-label="Increase quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">+</button>
</div>
<button type="button" data-add aria-live="polite" class="flex-1 min-h-11 h-11.5 rounded-xl border-0 cursor-pointer bg-tpc16-acc text-tpc16-onacc text-[14px] font-bold tabular-nums transition-[filter] duration-150 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc16-ring focus-visible:outline-offset-2 motion-reduce:transition-none">Add · $12.50</button>
</div>
</div>
</article>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpc16-bg: #f6f3ee;
--color-tpc16-bg: oklch(0.96 0.008 75);
--color-tpc16-card: #ffffff;
--color-tpc16-card: oklch(1 0 0);
--color-tpc16-ink: #2a1e12;
--color-tpc16-ink: oklch(0.26 0.025 60);
--color-tpc16-mut: #5f5344;
--color-tpc16-mut: oklch(0.46 0.02 65);
--color-tpc16-mut2: #6d6152;
--color-tpc16-mut2: oklch(0.51 0.02 68);
--color-tpc16-edge: #e4dccf;
--color-tpc16-edge: oklch(0.9 0.015 80);
--color-tpc16-step: #faf7f2;
--color-tpc16-step: oklch(0.98 0.006 80);
--color-tpc16-step2: #f0e8db;
--color-tpc16-step2: oklch(0.94 0.014 80);
--color-tpc16-ph: #f0e4d4;
--color-tpc16-ph: oklch(0.93 0.02 75);
--color-tpc16-ph2: #e6d2b8;
--color-tpc16-ph2: oklch(0.88 0.035 78);
--color-tpc16-veg: #dcf2e0;
--color-tpc16-veg: oklch(0.93 0.06 150);
--color-tpc16-vegink: #1f6b3b;
--color-tpc16-vegink: oklch(0.42 0.12 150);
--color-tpc16-gf: #dde9f8;
--color-tpc16-gf: oklch(0.93 0.05 250);
--color-tpc16-gfink: #1f548f;
--color-tpc16-gfink: oklch(0.44 0.12 255);
--color-tpc16-star: #c78a12;
--color-tpc16-star: oklch(0.68 0.14 75);
--color-tpc16-acc: #d4601f;
--color-tpc16-acc: oklch(0.6 0.17 45);
--color-tpc16-ring: #f0b48c;
--color-tpc16-ring: oklch(0.82 0.09 45);
--color-tpc16-onacc: #ffffff;
--color-tpc16-onacc: oklch(1 0 0);
--font-tpc16-sans: 'Outfit', system-ui, sans-serif;
}
.tpc-16 {
width: 100%;
min-height: 100svh;
}
.tpc-16 ::selection {
background: var(--color-tpc16-acc);
color: #fff;
}
```
## JavaScript
```js
document.querySelectorAll('.tpc-16').forEach(function (root) {
if (root.dataset.bound) return;
root.dataset.bound = '1';
var unit = parseFloat(root.dataset.price) || 0;
var qtyEl = root.querySelector('[data-qty]');
var addEl = root.querySelector('[data-add]');
var inc = root.querySelector('[data-inc]');
var dec = root.querySelector('[data-dec]');
if (!qtyEl || !addEl || !inc || !dec) return;
var qty = 1;
var MAX = 20;
function render() {
qtyEl.textContent = qty;
addEl.textContent = 'Add \u00b7 $' + (unit * qty).toFixed(2);
dec.disabled = qty <= 1; // JS sets the attribute; disabled: styles it
inc.disabled = qty >= MAX;
}
inc.addEventListener('click', function () { if (qty < MAX) { qty++; render(); } });
dec.addEventListener('click', function () { if (qty > 1) { qty--; render(); } });
render();
});
```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 Food Delivery Card with Quantity Stepper
Source: https://codefronts.com/components/tailwind-css-product-cards/tailwind-food-delivery-card-quantity-stepper/
The delivery-app dish card: dietary pills, a rating and prep-time line, and a +/− stepper driving a live running total. CSS owns all of it except two numbers — the script writes the count and the total, sets [disabled] so disabled: can style it, and announces the change in an aria-live region.
## HTML
```html
<section class="tpc-16 w-full min-h-svh grid place-items-center bg-tpc16-bg font-tpc16-sans px-5 py-12" data-price="12.5" aria-label="Food delivery dish card with quantity stepper demo">
<article class="w-[min(360px,100%)] rounded-[18px] overflow-hidden bg-tpc16-card shadow-[0_16px_40px_-22px_oklch(0.3_0.04_60/0.4)]">
<div role="img" aria-label="Dish photo: Smoky bean chilli bowl" class="aspect-[16/10] bg-linear-[135deg,var(--color-tpc16-ph),var(--color-tpc16-ph2)] bg-[url('https://images.unsplash.com/photo-1547592166-23ac45744acd?w=800&h=500&fit=crop&q=70')] bg-cover bg-center"></div>
<div class="px-5 pt-4.5 pb-5">
<div class="flex items-start justify-between gap-3">
<h3 class="text-[18px] font-bold text-tpc16-ink text-pretty">Smoky Bean Chilli Bowl</h3>
<div class="flex gap-1.5 shrink-0">
<span class="px-2.25 py-1 rounded-full bg-tpc16-veg text-tpc16-vegink text-[10px] font-bold tracking-[0.04em] uppercase">Vegan</span>
<span class="px-2.25 py-1 rounded-full bg-tpc16-gf text-tpc16-gfink text-[10px] font-bold tracking-[0.04em] uppercase">GF</span>
</div>
</div>
<p class="mt-2.5 text-[13.5px] leading-[1.5] text-tpc16-mut">Slow-cooked black beans, fire-roasted peppers, lime crema.</p>
<p class="mt-2.5 text-[13px] text-tpc16-mut2"><span aria-hidden="true" class="text-tpc16-star">★</span> 4.7 · 20–30 min · $2.99 delivery</p>
<div class="mt-4.5 flex items-center justify-between gap-3">
<div role="group" aria-label="Quantity" class="inline-flex items-center border border-tpc16-edge rounded-xl overflow-hidden">
<button type="button" data-dec aria-label="Decrease quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">−</button>
<span data-qty class="min-w-9 text-center text-[15px] font-bold text-tpc16-ink tabular-nums">1</span>
<button type="button" data-inc aria-label="Increase quantity" class="grid place-items-center min-w-11 min-h-11 border-0 cursor-pointer bg-tpc16-step text-tpc16-ink text-[20px] font-semibold leading-none transition-colors duration-150 hover:bg-tpc16-step2 disabled:opacity-35 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-tpc16-acc focus-visible:-outline-offset-2 motion-reduce:transition-none">+</button>
</div>
<button type="button" data-add aria-live="polite" class="flex-1 min-h-11 h-11.5 rounded-xl border-0 cursor-pointer bg-tpc16-acc text-tpc16-onacc text-[14px] font-bold tabular-nums transition-[filter] duration-150 hover:brightness-108 focus-visible:outline-3 focus-visible:outline-tpc16-ring focus-visible:outline-offset-2 motion-reduce:transition-none">Add · $12.50</button>
</div>
</div>
</article>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpc16-bg: #f6f3ee;
--color-tpc16-bg: oklch(0.96 0.008 75);
--color-tpc16-card: #ffffff;
--color-tpc16-card: oklch(1 0 0);
--color-tpc16-ink: #2a1e12;
--color-tpc16-ink: oklch(0.26 0.025 60);
--color-tpc16-mut: #5f5344;
--color-tpc16-mut: oklch(0.46 0.02 65);
--color-tpc16-mut2: #6d6152;
--color-tpc16-mut2: oklch(0.51 0.02 68);
--color-tpc16-edge: #e4dccf;
--color-tpc16-edge: oklch(0.9 0.015 80);
--color-tpc16-step: #faf7f2;
--color-tpc16-step: oklch(0.98 0.006 80);
--color-tpc16-step2: #f0e8db;
--color-tpc16-step2: oklch(0.94 0.014 80);
--color-tpc16-ph: #f0e4d4;
--color-tpc16-ph: oklch(0.93 0.02 75);
--color-tpc16-ph2: #e6d2b8;
--color-tpc16-ph2: oklch(0.88 0.035 78);
--color-tpc16-veg: #dcf2e0;
--color-tpc16-veg: oklch(0.93 0.06 150);
--color-tpc16-vegink: #1f6b3b;
--color-tpc16-vegink: oklch(0.42 0.12 150);
--color-tpc16-gf: #dde9f8;
--color-tpc16-gf: oklch(0.93 0.05 250);
--color-tpc16-gfink: #1f548f;
--color-tpc16-gfink: oklch(0.44 0.12 255);
--color-tpc16-star: #c78a12;
--color-tpc16-star: oklch(0.68 0.14 75);
--color-tpc16-acc: #d4601f;
--color-tpc16-acc: oklch(0.6 0.17 45);
--color-tpc16-ring: #f0b48c;
--color-tpc16-ring: oklch(0.82 0.09 45);
--color-tpc16-onacc: #ffffff;
--color-tpc16-onacc: oklch(1 0 0);
--font-tpc16-sans: 'Outfit', system-ui, sans-serif;
}
.tpc-16 {
width: 100%;
min-height: 100svh;
}
.tpc-16 ::selection {
background: var(--color-tpc16-acc);
color: #fff;
}
```
## JavaScript
```js
document.querySelectorAll('.tpc-16').forEach(function (root) {
if (root.dataset.bound) return;
root.dataset.bound = '1';
var unit = parseFloat(root.dataset.price) || 0;
var qtyEl = root.querySelector('[data-qty]');
var addEl = root.querySelector('[data-add]');
var inc = root.querySelector('[data-inc]');
var dec = root.querySelector('[data-dec]');
if (!qtyEl || !addEl || !inc || !dec) return;
var qty = 1;
var MAX = 20;
function render() {
qtyEl.textContent = qty;
addEl.textContent = 'Add \u00b7 $' + (unit * qty).toFixed(2);
dec.disabled = qty <= 1; // JS sets the attribute; disabled: styles it
inc.disabled = qty >= MAX;
}
inc.addEventListener('click', function () { if (qty < MAX) { qty++; render(); } });
dec.addEventListener('click', function () { if (qty > 1) { qty--; render(); } });
render();
});
```How this works
The split is the point. Every visual state is a utility — hover, focus, disabled, the pills, the stepper's seam. The script owns two strings:
qtyEl.textContent = qty;
addEl.textContent = 'Add · $' + (unit * qty).toFixed(2);
dec.disabled = qty <= 1;That last line is the interesting one: JS sets the attribute, and disabled:opacity-35 decides what it looks like. Styling stays in the markup even for a state only JavaScript can reach.
Price lives in the DOM, not the script. data-price="12.5" on the root means the same eight lines drive any dish on the menu, and the controller starts from querySelectorAll('.tpc-16') so a page of fifty cards needs one copy of it.
Announcing a changing number. The button label is the total, and it carries aria-live="polite" — so 'Add · $37.50' is read after the third press without moving focus away from the stepper.
Make it yours
- Unit price: the root's
data-price. The script never hard-codes it. - Max quantity: the
qty < 20guard. Set it from adata-maxfor stock limits. - Dietary pills: add Halal, Spicy or Nut-free by copying one pill and giving it a colour pair.
- Swap the total for an 'Add N to order' label if the count matters more than the money.
- Retheme through
--color-tpc16-acc; the add button, both focus rings and the selection colour read it.
Gotchas — read before shipping
- The total must live in an
aria-live="polite"region. Without it, a screen-reader user presses + four times and hears nothing change. - Stepper controls must be real
<button type="button">witharia-label('Increase quantity'), not clickable spans — and inside arole="group"with a label naming what is being stepped. - Guard the decrement at 1 in the script and reflect it with
[disabled]; a visually dimmed button that still fires is worse than no guard. - The inset focus ring is deliberate.
overflow-hiddenon the stepper shell would clip a normal outset outline to nothing. - Without JS the card shows a static quantity of 1 and a correct single-unit price — check that your fallback is the honest number, not a zero.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
The layout is universally supported; a small vanilla script drives the stepper and the live total. With JS disabled the card degrades to a static quantity of one at the correct price.