Cart

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-01
Open in v0

Code

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