Empty Cart
Original · freeempty shopping cart state
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/empty-cart.json"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
} from "motion/react";
type ProductId = "grinder" | "tumbler" | "guji" | "dripper";
type Product = {
id: ProductId;
name: string;
detail: string;
price: number;
tone: string;
};
type Line = {
id: ProductId;
qty: number;
};
const FREE_SHIPPING_AT = 7500;
const PRODUCTS: readonly Product[] = [
{
id: "grinder",
name: "Ceramic Burr Hand Grinder",
detail: "38 clicks, espresso to French press",
price: 6800,
tone: "from-amber-200 to-amber-400 dark:from-amber-500/30 dark:to-amber-400/10",
},
{
id: "guji",
name: "Ethiopia Guji — Whole Bean",
detail: "340 g · peach, jasmine, cane sugar",
price: 1950,
tone: "from-rose-200 to-rose-400 dark:from-rose-500/30 dark:to-rose-400/10",
},
{
id: "dripper",
name: "Stainless Pour-Over Dripper",
detail: "Paperless, fits a 500 ml carafe",
price: 4200,
tone: "from-sky-200 to-sky-400 dark:from-sky-500/30 dark:to-sky-400/10",
},
{
id: "tumbler",
name: "Double-Wall Glass Tumbler",
detail: "350 ml · keeps a flat white hot for 40 min",
price: 2400,
tone: "from-emerald-200 to-emerald-400 dark:from-emerald-500/30 dark:to-emerald-400/10",
},
];
const LAST_CART: readonly Line[] = [
{ id: "guji", qty: 2 },
{ id: "dripper", qty: 1 },
];
const money = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
const findProduct = (id: ProductId): Product => {
const hit = PRODUCTS.find((p) => p.id === id);
if (!hit) throw new Error(`Unknown product: ${id}`);
return hit;
};
function Glyph({ id }: { id: ProductId }) {
const common = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6">
{id === "grinder" && (
<g {...common}>
<path d="M8 4h8l-1 5H9L8 4Z" />
<path d="M9 9h6l-.7 8.5a2 2 0 0 1-2 1.8h-.6a2 2 0 0 1-2-1.8L9 9Z" />
<path d="M12 2.4V4" />
<path d="M10.5 12.2h3" />
</g>
)}
{id === "guji" && (
<g {...common}>
<path d="M15.6 4.6c2.6 2.6 2.6 6.8 0 9.4-2.6 2.6-6.8 2.6-9.4 0" />
<path d="M6.2 14c-2.6-2.6-2.6-6.8 0-9.4 2.6-2.6 6.8-2.6 9.4 0" />
<path d="M6.2 14c1.4-3.6 5-6.2 9.4-9.4" />
<path d="M4.4 19.6c3.2-1.4 6.6-1.6 9.8-.6" />
</g>
)}
{id === "dripper" && (
<g {...common}>
<path d="M4.5 6h15l-5.4 7.6v4.2a1 1 0 0 1-1 1h-2.2a1 1 0 0 1-1-1v-4.2L4.5 6Z" />
<path d="M7.2 9.6h9.6" />
<path d="M12 2.6V5" />
</g>
)}
{id === "tumbler" && (
<g {...common}>
<path d="M7 4h10l-1.2 15.2a1.8 1.8 0 0 1-1.8 1.6h-4a1.8 1.8 0 0 1-1.8-1.6L7 4Z" />
<path d="M7.4 9h9.2" />
<path d="M14.4 12.4v4.4" />
</g>
)}
</svg>
);
}
function CartArt({ animated }: { animated: boolean }) {
return (
<div className="relative mx-auto h-44 w-52 sm:h-52 sm:w-60">
<div
aria-hidden="true"
className="absolute inset-x-6 bottom-3 h-4 rounded-[100%] bg-slate-900/10 blur-md dark:bg-black/50"
/>
<svg
viewBox="0 0 220 180"
aria-hidden="true"
className="relative h-full w-full text-slate-400 dark:text-zinc-600"
>
<circle
cx="110"
cy="86"
r="62"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray="5 9"
strokeLinecap="round"
className={animated ? "ec-dash origin-center" : "origin-center"}
opacity="0.5"
/>
<g
className={animated ? "ec-bob" : undefined}
style={{ transformOrigin: "110px 90px" }}
>
<path
d="M46 50h13.5a5 5 0 0 1 4.9 4l3 15"
fill="none"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
/>
<path
d="M67.4 69h96.8a4 4 0 0 1 3.9 4.9l-7.4 32a8 8 0 0 1-7.8 6.2H84.6a8 8 0 0 1-7.9-6.5L67.4 69Z"
className="fill-white stroke-current dark:fill-zinc-900"
strokeWidth="5"
strokeLinejoin="round"
/>
<path
d="M78 82h78"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
opacity="0.45"
/>
<path
d="M99 69v43M127 69v43M155 69v40"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
opacity="0.35"
/>
<circle
cx="96"
cy="130"
r="9"
className="fill-white stroke-current dark:fill-zinc-900"
strokeWidth="5"
/>
<circle
cx="148"
cy="130"
r="9"
className="fill-white stroke-current dark:fill-zinc-900"
strokeWidth="5"
/>
</g>
<g className="text-indigo-500 dark:text-indigo-400">
<circle
cx="86"
cy="46"
r="3.5"
fill="currentColor"
className={animated ? "ec-drift" : undefined}
style={{ animationDelay: "0s" }}
opacity={animated ? undefined : 0.6}
/>
<circle
cx="122"
cy="38"
r="2.5"
fill="currentColor"
className={animated ? "ec-drift" : undefined}
style={{ animationDelay: "1.1s" }}
opacity={animated ? undefined : 0.6}
/>
<circle
cx="156"
cy="48"
r="3"
fill="currentColor"
className={animated ? "ec-drift" : undefined}
style={{ animationDelay: "2.2s" }}
opacity={animated ? undefined : 0.6}
/>
</g>
</svg>
</div>
);
}
export default function EmptyCart() {
const reduce = useReducedMotion();
const animated = !reduce;
const [lines, setLines] = useState<Line[]>([]);
const [restored, setRestored] = useState(false);
const [status, setStatus] = useState("");
const browseRef = useRef<HTMLButtonElement | null>(null);
const wasFilled = useRef(false);
const spring: Transition = reduce
? { duration: 0 }
: { type: "spring", stiffness: 460, damping: 36, mass: 0.7 };
const fade: Transition = reduce ? { duration: 0 } : { duration: 0.26, ease: "easeOut" };
const count = useMemo(
() => lines.reduce((sum, line) => sum + line.qty, 0),
[lines],
);
const subtotal = useMemo(
() => lines.reduce((sum, line) => sum + findProduct(line.id).price * line.qty, 0),
[lines],
);
const toGo = Math.max(0, FREE_SHIPPING_AT - subtotal);
const shipPct = Math.min(100, Math.round((subtotal / FREE_SHIPPING_AT) * 100));
useEffect(() => {
if (lines.length > 0) {
wasFilled.current = true;
return;
}
if (wasFilled.current) {
wasFilled.current = false;
browseRef.current?.focus();
}
}, [lines.length]);
const add = useCallback((id: ProductId) => {
const product = findProduct(id);
setLines((prev) => {
const existing = prev.find((line) => line.id === id);
if (existing) {
return prev.map((line) =>
line.id === id ? { ...line, qty: line.qty + 1 } : line,
);
}
return [...prev, { id, qty: 1 }];
});
setStatus(`${product.name} added to your cart.`);
}, []);
const setQty = useCallback((id: ProductId, next: number) => {
const product = findProduct(id);
if (next <= 0) {
setLines((prev) => prev.filter((line) => line.id !== id));
setStatus(`${product.name} removed from your cart.`);
return;
}
setLines((prev) =>
prev.map((line) => (line.id === id ? { ...line, qty: next } : line)),
);
setStatus(`${product.name} quantity ${next}.`);
}, []);
const restore = useCallback(() => {
setLines(LAST_CART.map((line) => ({ ...line })));
setRestored(true);
setStatus("Tuesday's cart restored. 3 items are back in your cart.");
}, []);
const empty = useCallback(() => {
setLines([]);
setRestored(false);
setStatus("Cart emptied.");
}, []);
const qtyOf = (id: ProductId): number =>
lines.find((line) => line.id === id)?.qty ?? 0;
const isEmpty = lines.length === 0;
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-zinc-950">
<style>{`
@keyframes ec-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
@keyframes ec-drift {
0% { transform: translateY(6px) scale(0.6); opacity: 0; }
25% { opacity: 0.9; }
100% { transform: translateY(-30px) scale(0.4); opacity: 0; }
}
@keyframes ec-dash {
to { stroke-dashoffset: -196; }
}
@keyframes ec-sheen {
0% { transform: translateX(-120%); }
60%, 100% { transform: translateX(320%); }
}
.ec-bob { animation: ec-bob 4.6s ease-in-out infinite; }
.ec-drift { animation: ec-drift 3.4s ease-out infinite; }
.ec-dash { animation: ec-dash 24s linear infinite; }
.ec-sheen { animation: ec-sheen 3.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.ec-bob, .ec-drift, .ec-dash, .ec-sheen {
animation: none !important;
}
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4 sm:px-8 dark:border-zinc-800">
<div className="flex items-center gap-3">
<h2 className="text-base font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
Your cart
</h2>
<motion.span
key={count}
initial={reduce ? false : { scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={spring}
className={[
"inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums",
count > 0
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-500 dark:bg-zinc-800 dark:text-zinc-400",
].join(" ")}
>
{count}
</motion.span>
</div>
<p className="text-xs text-slate-500 dark:text-zinc-400">
Free shipping over {money(FREE_SHIPPING_AT)} · 30-day returns
</p>
</header>
<div className="grid gap-px bg-slate-200 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] dark:bg-zinc-800">
<div className="bg-white p-5 sm:p-8 dark:bg-zinc-900">
<AnimatePresence mode="wait" initial={false}>
{isEmpty ? (
<motion.div
key="empty"
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={fade}
className="flex flex-col items-center text-center"
>
<CartArt animated={animated} />
<h3 className="mt-6 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
Nothing in here yet
</h3>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
Your cart is empty — no items, no holds, nothing charged.
Pick a bag of beans or start with the grinder everyone
buys twice.
</p>
<div className="mt-7 flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-center">
<button
ref={browseRef}
type="button"
onClick={() => add("guji")}
className="relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-900"
>
<span
aria-hidden="true"
className={[
"pointer-events-none absolute inset-y-0 -left-4 w-10 -skew-x-12 bg-white/25 dark:bg-zinc-900/20",
animated ? "ec-sheen" : "hidden",
].join(" ")}
/>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.9a2 2 0 0 0 2-1.5L21 8H6" />
<circle cx="10" cy="20" r="1.2" />
<circle cx="18" cy="20" r="1.2" />
</svg>
Start with the Guji
</button>
<button
type="button"
onClick={restore}
disabled={restored}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 2.6-6.4" />
<path d="M3 4v5h5" />
</svg>
Restore Tuesday’s cart
</button>
</div>
<p className="mt-5 text-xs text-slate-500 dark:text-zinc-500">
Saved carts stay put for 30 days. We never charge until
you hit checkout.
</p>
</motion.div>
) : (
<motion.div
key="filled"
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={fade}
>
<ul className="divide-y divide-slate-200 dark:divide-zinc-800">
<AnimatePresence initial={false}>
{lines.map((line) => {
const product = findProduct(line.id);
return (
<motion.li
key={line.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, height: 0, marginTop: 0 }
}
transition={spring}
className="overflow-hidden"
>
<div className="flex items-center gap-4 py-4">
<span
aria-hidden="true"
className={`grid h-14 w-14 shrink-0 place-items-center rounded-xl bg-gradient-to-br text-slate-700 ring-1 ring-inset ring-slate-900/5 dark:text-zinc-200 dark:ring-white/10 ${product.tone}`}
>
<Glyph id={product.id} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-zinc-100">
{product.name}
</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-zinc-400">
{product.detail}
</p>
</div>
<div className="flex items-center gap-1 rounded-lg border border-slate-200 p-0.5 dark:border-zinc-700">
<button
type="button"
onClick={() => setQty(line.id, line.qty - 1)}
aria-label={`Decrease quantity of ${product.name}`}
className="grid h-7 w-7 place-items-center rounded-md text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3.5 8h9" />
</svg>
</button>
<span className="w-6 text-center text-sm font-semibold tabular-nums text-slate-900 dark:text-zinc-100">
{line.qty}
</span>
<button
type="button"
onClick={() => setQty(line.id, line.qty + 1)}
aria-label={`Increase quantity of ${product.name}`}
className="grid h-7 w-7 place-items-center rounded-md text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M8 3.5v9M3.5 8h9" />
</svg>
</button>
</div>
<p className="w-16 shrink-0 text-right text-sm font-semibold tabular-nums text-slate-900 dark:text-zinc-100">
{money(product.price * line.qty)}
</p>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
<div className="mt-6 rounded-xl bg-slate-50 p-4 dark:bg-zinc-800/50">
<div className="flex items-baseline justify-between">
<span className="text-sm text-slate-600 dark:text-zinc-400">
Subtotal
</span>
<span className="text-lg font-semibold tabular-nums text-slate-900 dark:text-zinc-50">
{money(subtotal)}
</span>
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={shipPct}
aria-label="Progress toward free shipping"
className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-700"
>
<motion.div
className="h-full rounded-full bg-emerald-500 dark:bg-emerald-400"
initial={false}
animate={{ width: `${shipPct}%` }}
transition={spring}
/>
</div>
<p className="mt-2 text-xs text-slate-600 dark:text-zinc-400">
{toGo === 0
? "Shipping is on us — you cleared the $75 line."
: `${money(toGo)} away from free shipping.`}
</p>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
className="inline-flex flex-1 items-center justify-center rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
>
Checkout · {money(subtotal)}
</button>
<button
type="button"
onClick={empty}
className="inline-flex items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-600 transition hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:text-zinc-400 dark:hover:text-rose-400"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16M9 7V5h6v2M6 7l1 12.2A1.8 1.8 0 0 0 8.8 21h6.4a1.8 1.8 0 0 0 1.8-1.8L18 7" />
</svg>
Empty cart
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<aside className="bg-white p-5 sm:p-8 dark:bg-zinc-900">
<h3 className="text-sm font-semibold text-slate-900 dark:text-zinc-100">
Start with a staple
</h3>
<p className="mt-1 text-xs text-slate-500 dark:text-zinc-400">
Four things our regulars reorder most.
</p>
<ul className="mt-4 space-y-2">
{PRODUCTS.map((product) => {
const qty = qtyOf(product.id);
return (
<li key={product.id}>
<div className="flex items-center gap-3 rounded-xl border border-slate-200 p-2.5 transition hover:border-slate-300 dark:border-zinc-800 dark:hover:border-zinc-700">
<span
aria-hidden="true"
className={`grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-gradient-to-br text-slate-700 ring-1 ring-inset ring-slate-900/5 dark:text-zinc-200 dark:ring-white/10 ${product.tone}`}
>
<Glyph id={product.id} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-slate-900 dark:text-zinc-100">
{product.name}
</p>
<p className="text-xs tabular-nums text-slate-500 dark:text-zinc-400">
{money(product.price)}
</p>
</div>
<button
type="button"
onClick={() => add(product.id)}
aria-label={`Add ${product.name} to cart, ${money(product.price)}`}
className="shrink-0 rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:text-zinc-200 dark:hover:border-indigo-400 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
{qty > 0 ? (
<span className="tabular-nums">In cart · {qty}</span>
) : (
"Add"
)}
</button>
</div>
</li>
);
})}
</ul>
<div className="mt-5 rounded-xl border border-dashed border-slate-300 p-3 dark:border-zinc-700">
<p className="text-xs leading-relaxed text-slate-600 dark:text-zinc-400">
<span className="font-semibold text-slate-900 dark:text-zinc-100">
Roasted to order.
</span>{" "}
Beans ship within 48 hours of the roast date, so what lands on
your counter is never more than four days old.
</p>
</div>
</aside>
</div>
</div>
</div>
<p aria-live="polite" role="status" className="sr-only">
{status}
</p>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Empty No Data
Originalempty data state with illustration

Empty No Results
Originalno results with clear filters

Empty Inbox
Originalempty inbox / all caught up

Empty First Run
Originalfirst-run get started empty state

Empty Notifications
Originalno notifications state

Empty Error
Originalsomething went wrong empty state

Empty No Connection
Originalno connection empty state

Empty Folder
Originalempty folder with upload CTA

Empty Success
Originalsuccess / done empty state

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

