E-commerce

Cart Line Item

A cart row with a rolling line total, an inline quantity stepper, and a remove action that collapses the row.

Install

npx shadcn@latest add @paragon/cart-line-item

Also installs: digit-roll, quantity-stepper

cart-line-item.tsx

"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>
  );
}