Patterns
PatternsTier 5 · Blocks
Cart item & summary
A bag line with quantity and remove, plus the totals block.
bunx --bun shadcn@latest add @woohl/cart-itemReference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/cart-itemregistry/tier-5-blocks/cart-item.tsx
"use client";
import type { ElementType } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { WoohlImage } from "@/registry/tier-1-tokens/imagery/woohl-image";
import { Button } from "@/registry/tier-3-components/button";
import { Price } from "@/registry/tier-5-blocks/price";
import { QuantitySelector } from "@/registry/tier-5-blocks/quantity-selector";
/**
* CartItem.
*
* Image, title, maker, variant, quantity, subtotal, remove.
*
* THE SUBTOTAL IS PER LINE, NOT PER UNIT
* Two candles at ₹1,200 shows ₹2,400 with "₹1,200 each" underneath. Showing
* the unit price as the headline number and making the buyer multiply is how
* a bag total becomes a surprise at the payment step.
*
* REMOVE IS A BUTTON, AND IT NAMES WHAT IT REMOVES
* "Remove Forest Ritual Candle", not "Remove". In a bag of six items, six
* buttons called "Remove" are unusable with a screen reader.
*/
export type CartItemProps = {
name: string;
maker: string;
href?: string;
image?: string;
imageAlt?: string;
/** Unit price in rupees. */
price: number;
quantity: number;
onQuantityChange: (quantity: number) => void;
onRemove: () => void;
/** "Size: M", "Colour: Terracotta". */
variant?: string;
/** Available stock. Caps the quantity selector. */
maxQuantity?: number;
/** "Only 3 left", "Made to order — ships in 10 days". */
note?: string;
as?: ElementType;
className?: string;
};
export function CartItem({
name,
maker,
href,
image,
imageAlt,
price,
quantity,
onQuantityChange,
onRemove,
variant,
maxQuantity,
note,
as: LinkComponent = "a",
className,
}: CartItemProps) {
const subtotal = price * quantity;
return (
<article
data-slot="cart-item"
className={cn("font-ui flex gap-4 py-5", className)}
>
<div className="w-20 flex-none sm:w-24">
<WoohlImage
src={image ?? ""}
alt={imageAlt ?? `${name} by ${maker}`}
ratio="square"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-caption text-text-muted">{maker}</p>
<h3 className="text-body text-text-primary mt-0.5 leading-[1.35] font-bold">
{href ? (
<LinkComponent href={href} className="hover:text-text-accent">
{name}
</LinkComponent>
) : (
name
)}
</h3>
{variant && (
<p className="text-caption text-text-secondary mt-1">{variant}</p>
)}
</div>
<div className="text-right">
<Price amount={subtotal} tone="primary" size="md" />
{quantity > 1 && (
<p className="text-caption text-text-muted mt-0.5">
{new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
}).format(price)}{" "}
each
</p>
)}
</div>
</div>
{note && <p className="text-caption text-warning">{note}</p>}
<div className="mt-auto flex items-center justify-between gap-4 pt-1">
<QuantitySelector
value={quantity}
onChange={onQuantityChange}
max={maxQuantity}
maxReason={maxQuantity ? `Only ${maxQuantity} left` : undefined}
label={`Quantity, ${name}`}
size="sm"
/>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
aria-label={`Remove ${name}`}
>
Remove
</Button>
</div>
</div>
</article>
);
}
/**
* The bag's totals block.
*
* Every line is named and every line is a real number. Delivery says "Free"
* rather than "₹0" when it's free, and a COD handling fee is listed rather than
* folded into the total — the checkout pattern is explicit that a fee a buyer
* discovers at the payment step costs more than the fee is worth.
*/
export function CartSummary({
subtotal,
delivery,
discount,
fees,
className,
}: {
subtotal: number;
/** `0` renders as "Free". Omit when it isn't known yet. */
delivery?: number;
discount?: { label: string; amount: number };
fees?: { label: string; amount: number }[];
className?: string;
}) {
const format = (value: number) =>
new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
}).format(value);
const total =
subtotal +
(delivery ?? 0) -
(discount?.amount ?? 0) +
(fees ?? []).reduce((sum, fee) => sum + fee.amount, 0);
return (
<dl
data-slot="cart-summary"
className={cn("font-ui text-body flex flex-col gap-2.5", className)}
>
<Row label="Subtotal" value={format(subtotal)} />
{delivery !== undefined && (
<Row
label="Delivery"
value={delivery === 0 ? "Free" : format(delivery)}
valueClassName={
delivery === 0 ? "text-success font-semibold" : undefined
}
/>
)}
{discount && (
<Row
label={discount.label}
value={`−${format(discount.amount)}`}
valueClassName="text-success font-semibold"
/>
)}
{fees?.map((fee) => (
<Row key={fee.label} label={fee.label} value={format(fee.amount)} />
))}
<div className="border-border-subtle mt-1 flex items-baseline justify-between border-t pt-3">
<dt className="text-body text-text-primary font-bold">Total</dt>
<dd className="m-0">
<Price amount={total} size="lg" tone="primary" />
</dd>
</div>
</dl>
);
}
function Row({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-text-secondary">{label}</dt>
<dd className={cn("text-text-primary m-0", valueClassName)}>{value}</dd>
</div>
);
}
CartItemProps
| Prop | Type | Default |
|---|---|---|
maker* | string | — |
name* | string | — |
onQuantityChange* | (quantity: number) => void | — |
onRemove* | () => void | — |
price*Unit price in rupees. | number | — |
quantity* | number | — |
as | ElementType | — |
className | string | — |
href | string | — |
image | string | — |
imageAlt | string | — |
maxQuantityAvailable stock. Caps the quantity selector. | number | — |
note"Only 3 left", "Made to order — ships in 10 days". | string | — |
variant"Size: M", "Colour: Terracotta". | string | — |
Registry items
npm packages
None beyond React itself.