A cart row with a rolling line total, an inline quantity stepper, and a remove action that collapses the row.
npx shadcn@latest add @paragon/cart-line-itemAlso installs: digit-roll, quantity-stepper
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
import { QuantityStepper } from "@/registry/paragon/ui/quantity-stepper";
export interface CartLineItemProps
extends Omit<React.ComponentProps<"div">, "onChange" | "title"> {
title: string;
/** e.g. "Slate / M". */
variant?: string;
/** Unit price. */
price: number;
quantity?: number;
defaultQuantity?: number;
onQuantityChange?: (quantity: number) => void;
onRemove?: () => void;
/** Thumbnail URL; a gradient placeholder shows when omitted. */
image?: string;
currency?: string;
locale?: Intl.LocalesArgument;
max?: number;
/** Disables the collapse and roll motion. */
static?: boolean;
}
/**
* A cart row: thumbnail, title/variant, a quantity stepper, and a line total
* that rolls (odometer) as the quantity changes. Removing collapses the row's
* height with a fade — the one place height animates, via grid-template-rows
* 1fr→0fr so it stays cheap. Fire `onRemove` after the exit to drop the item.
*/
export function CartLineItem({
title,
variant,
price,
quantity: quantityProp,
defaultQuantity = 1,
onQuantityChange,
onRemove,
image,
currency = "USD",
locale = "en-US",
max = 10,
static: isStatic = false,
className,
...props
}: CartLineItemProps) {
const reduced = useReducedMotion() ?? false;
const [uncontrolled, setUncontrolled] = React.useState(defaultQuantity);
const quantity = quantityProp ?? uncontrolled;
const [removed, setRemoved] = React.useState(false);
const setQuantity = (next: number) => {
if (quantityProp === undefined) setUncontrolled(next);
onQuantityChange?.(next);
};
const collapse = isStatic || reduced;
return (
<div
data-slot="cart-line-item"
aria-hidden={removed || undefined}
className={cn(
"grid transition-[grid-template-rows,opacity] ease-in-out",
removed
? "grid-rows-[0fr] opacity-0 duration-[250ms]"
: "grid-rows-[1fr] opacity-100 duration-[250ms]",
className,
)}
onTransitionEnd={(e) => {
if (removed && e.propertyName === "grid-template-rows") onRemove?.();
}}
{...props}
>
<div className="overflow-hidden">
<div className="flex items-center gap-4 py-4">
<div
aria-hidden
className="size-16 shrink-0 overflow-hidden rounded-lg bg-gradient-to-br from-secondary to-muted"
>
{image && (
<img
src={image}
alt=""
className="size-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{title}</p>
{variant && (
<p className="truncate text-xs text-muted-foreground">
{variant}
</p>
)}
<div className="mt-2">
<QuantityStepper
value={quantity}
onValueChange={setQuantity}
min={1}
max={max}
static={isStatic}
aria-label={`${title} quantity`}
className="h-8"
/>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm font-semibold">
<DigitRoll
value={price * quantity}
locale={locale}
formatOptions={{ style: "currency", currency }}
static={isStatic}
/>
</div>
<button
type="button"
aria-label={`Remove ${title}`}
onClick={() =>
collapse ? onRemove?.() : setRemoved(true)
}
className="relative flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
>
<Trash2 className="size-4" aria-hidden />
</button>
</div>
</div>
</div>
</div>
);
}