← CartOpen in v0
Cart 01 — Cart Summary
Shopping cart with editable line items (quantity steppers, remove), an empty state, and a sticky order summary with promo code, live totals, a secure-checkout CTA and payment badges.
$ npx shadcn@latest add @ludusvibe/cart-01Code
Installed to components/sections/cart-01.tsx — it's yours to edit.
"use client"
import * as React from "react"
import { motion } from "motion/react"
import { ArrowLeft, Lock, Minus, Plus, ShieldCheck, X } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"
export type CartItem = {
id: string
name: string
/** Secondary line, e.g. "Size M · Slate". */
variant?: string
/** Unit price in the section's currency. */
price: number
qty: number
/** Real product image. Falls back to a token gradient mock when absent. */
image?: string
}
export type Cart01Props = {
items?: CartItem[]
/** Currency symbol prefixed to every figure. */
currency?: string
/** Subtotal at or above this value ships free. */
freeShippingThreshold?: number
/** Flat fee applied below the free-shipping threshold. */
shippingFee?: number
/** Applied to the subtotal for the estimated-tax row. */
taxRate?: number
/** Checkout button destination. */
checkoutHref?: string
/** Continue-shopping link destination. */
continueHref?: string
/** Heading element — use "h1" when this is the page's main heading. */
as?: "h1" | "h2"
className?: string
}
const defaultItems: CartItem[] = [
{
id: "aria-runner",
name: "Aria Runner",
variant: "Size 42 · Bone",
price: 148,
qty: 1,
},
{
id: "field-jacket",
name: "All-Weather Field Jacket",
variant: "Size M · Moss",
price: 236,
qty: 1,
},
{
id: "merino-crew",
name: "Merino Crew Sock — 3 pack",
variant: "One size · Charcoal",
price: 34,
qty: 2,
},
]
const paymentMethods = ["Visa", "Mastercard", "Amex", "PayPal"]
function ThumbMock({ seed }: { seed: number }) {
// Deterministic per-item gradient direction so each row reads distinctly.
const dir = ["bg-gradient-to-br", "bg-gradient-to-tr", "bg-gradient-to-bl"][
seed % 3
]
return (
<div
aria-hidden
className={cn(
"relative size-20 shrink-0 overflow-hidden rounded-lg border md:size-24",
dir,
"from-primary/25 via-muted to-background"
)}
>
<div className="absolute inset-0 [background-image:radial-gradient(circle_at_30%_25%,var(--color-primary,transparent)_0,transparent_45%)] opacity-10" />
</div>
)
}
export function Cart01({
items = defaultItems,
currency = "$",
freeShippingThreshold = 250,
shippingFee = 12,
taxRate = 0.08,
checkoutHref = "#",
continueHref = "#",
as: Heading = "h2",
className,
}: Cart01Props) {
const [lines, setLines] = React.useState<CartItem[]>(items)
const [promo, setPromo] = React.useState("")
const format = React.useCallback(
(value: number) =>
`${currency}${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`,
[currency]
)
const setQty = React.useCallback((id: string, next: number) => {
setLines((prev) =>
prev.map((line) =>
line.id === id ? { ...line, qty: Math.max(1, next) } : line
)
)
}, [])
const removeLine = React.useCallback((id: string) => {
setLines((prev) => prev.filter((line) => line.id !== id))
}, [])
const count = React.useMemo(
() => lines.reduce((sum, line) => sum + line.qty, 0),
[lines]
)
const { subtotal, shipping, tax, total } = React.useMemo(() => {
const sub = lines.reduce((sum, line) => sum + line.price * line.qty, 0)
const ship = sub === 0 || sub >= freeShippingThreshold ? 0 : shippingFee
const estTax = sub * taxRate
return {
subtotal: sub,
shipping: ship,
tax: estTax,
total: sub + ship + estTax,
}
}, [lines, freeShippingThreshold, shippingFee, taxRate])
const isEmpty = lines.length === 0
return (
<section className={cn("bg-background px-6 py-20 md:py-28", className)}>
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewport}
variants={stagger(0.08)}
className="mx-auto max-w-6xl"
>
{/* Header row */}
<motion.div
variants={fadeUp}
className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
>
<Heading className="font-heading text-3xl font-semibold tracking-tight md:text-4xl">
Your cart{" "}
<span className="text-muted-foreground tabular-nums">
({count} {count === 1 ? "item" : "items"})
</span>
</Heading>
<a
href={continueHref}
className="group inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none"
>
<ArrowLeft className="size-4 transition-transform group-hover:-translate-x-0.5" />
Continue shopping
</a>
</motion.div>
<div className="mt-10 grid items-start gap-10 lg:grid-cols-[1.6fr_1fr] lg:gap-14">
{/* LEFT — line items */}
<motion.div variants={fadeUp}>
{isEmpty ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed px-6 py-20 text-center">
<p className="text-muted-foreground">
Your cart is empty.
</p>
<a
href={continueHref}
className={cn(buttonVariants({ size: "lg" }), "mt-6")}
>
Start shopping
</a>
</div>
) : (
<ul className="divide-y overflow-hidden rounded-2xl border">
{lines.map((line, i) => (
<li
key={line.id}
className="flex gap-4 p-4 sm:gap-6 sm:p-6"
>
{line.image ? (
<img
src={line.image}
alt={line.name}
className="size-20 shrink-0 rounded-lg border object-cover md:size-24"
/>
) : (
<ThumbMock seed={i} />
)}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">{line.name}</p>
{line.variant && (
<p className="mt-0.5 text-xs text-muted-foreground">
{line.variant}
</p>
)}
<p className="mt-1 text-sm text-muted-foreground tabular-nums">
{format(line.price)} each
</p>
</div>
<button
type="button"
onClick={() => removeLine(line.id)}
aria-label={`Remove ${line.name} from cart`}
className="-mt-1 -mr-1 flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<X className="size-4" />
</button>
</div>
<div className="mt-4 flex items-center justify-between gap-4">
{/* Qty stepper */}
<div className="inline-flex items-center rounded-lg border">
<button
type="button"
onClick={() => setQty(line.id, line.qty - 1)}
disabled={line.qty <= 1}
aria-label={`Decrease quantity of ${line.name}`}
className="flex size-9 items-center justify-center rounded-l-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40"
>
<Minus className="size-4" />
</button>
<span className="w-9 text-center text-sm font-medium tabular-nums">
{line.qty}
</span>
<button
type="button"
onClick={() => setQty(line.id, line.qty + 1)}
aria-label={`Increase quantity of ${line.name}`}
className="flex size-9 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<Plus className="size-4" />
</button>
</div>
<span className="font-medium tabular-nums">
{format(line.price * line.qty)}
</span>
</div>
</div>
</li>
))}
</ul>
)}
</motion.div>
{/* RIGHT — order summary */}
<motion.div variants={fadeUp} className="lg:sticky lg:top-24">
<div className="rounded-2xl border bg-card p-6">
<h3 className="font-heading text-lg font-semibold tracking-tight">
Order summary
</h3>
<dl className="mt-6 space-y-3 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{format(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">
{shipping === 0 ? (
<span className="text-primary">Free</span>
) : (
format(shipping)
)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">
Estimated tax{" "}
<span className="tabular-nums">
({Math.round(taxRate * 100)}%)
</span>
</dt>
<dd className="tabular-nums">{format(tax)}</dd>
</div>
</dl>
<Separator className="my-5" />
<div className="flex items-baseline justify-between">
<span className="text-lg font-semibold">Total</span>
<span className="text-lg font-semibold tabular-nums">
{format(total)}
</span>
</div>
{/* Promo code */}
<form
className="mt-6"
onSubmit={(e) => e.preventDefault()}
>
<Label htmlFor="cart-promo" className="text-xs text-muted-foreground">
Promo code
</Label>
<div className="mt-2 flex gap-2">
<Input
id="cart-promo"
value={promo}
onChange={(e) => setPromo(e.target.value)}
placeholder="Enter code"
autoComplete="off"
className="flex-1"
/>
<button
type="submit"
className={cn(buttonVariants({ variant: "outline" }))}
>
Apply
</button>
</div>
</form>
{/* Checkout CTA */}
<a
href={checkoutHref}
aria-disabled={isEmpty}
tabIndex={isEmpty ? -1 : undefined}
className={cn(
buttonVariants({ size: "lg" }),
"mt-6 w-full gap-2",
isEmpty && "pointer-events-none opacity-50"
)}
>
<Lock className="size-4" />
Checkout · {format(total)}
</a>
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5 text-primary" />
Secure, encrypted checkout
</p>
<div className="mt-4 flex flex-wrap justify-center gap-2">
{paymentMethods.map((method) => (
<span
key={method}
className="rounded-md border px-2 py-1 text-xs text-muted-foreground"
>
{method}
</span>
))}
</div>
</div>
</motion.div>
</div>
</motion.div>
</section>
)
}