Cart page

Two-column cart page: line items with steppers on the left, a sticky order summary with shipping math and checkout on the right. Stacks on mobile.

blockpageanycartquantitycompare-atshipping-threshold
$npx shadcn@latest add @storekit/cart-page
cart-page

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/cart-page
$pnpm dlx shadcn@latest add @storekit/cart-page
$yarn dlx shadcn@latest add @storekit/cart-page
$bunx --bun shadcn@latest add @storekit/cart-page

Source

components/storekit/cart/cart-page.tsx
205 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Lock, ShieldCheck, Trash2, Truck } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Price } from "@/components/storekit/ui/price"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { Stepper } from "@/components/storekit/ui/stepper"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Full cart page. Lines on the left, an order summary on the right that
   sticks while the list scrolls. Stacks on mobile with the summary last.
   Owns quantity and removal state after mount. */

export type CartPageLine = {
  id: string
  title: string
  variant?: string
  price: number
  compareAt?: number
  image: string
  quantity: number
}

const SAMPLE: CartPageLine[] = [
  { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 2 },
  { id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
  { id: "magnesium-night", title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.webp", quantity: 1 },
]

const RING = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

export function CartPage({
  items = SAMPLE,
  freeShippingThreshold = 15000,
  shippingRate = 895,
  onCheckout,
  className,
}: {
  items?: CartPageLine[]
  /** In cents. */
  freeShippingThreshold?: number
  /** Flat shipping in cents below the threshold. */
  shippingRate?: number
  onCheckout?: (lines: CartPageLine[]) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-page")
  const [lines, setLines] = React.useState(items)
  const [busy, setBusy] = React.useState(false)
  const titleId = React.useId()

  const count = lines.reduce((n, l) => n + l.quantity, 0)
  const subtotal = lines.reduce((n, l) => n + l.price * l.quantity, 0)
  const savings = lines.reduce((n, l) => n + (l.compareAt ? (l.compareAt - l.price) * l.quantity : 0), 0)
  const free = subtotal >= freeShippingThreshold
  const shipping = lines.length === 0 ? 0 : free ? 0 : shippingRate
  const total = subtotal + shipping
  const d = reduce ? 0 : 1

  const setQty = (id: string, quantity: number) => {
    sk.emit("interact", "quantity", quantity)
    setLines((ls) => ls.map((l) => (l.id === id ? { ...l, quantity } : l)))
  }
  const remove = (id: string) => {
    sk.emit("interact", "remove")
    setLines((ls) => ls.filter((l) => l.id !== id))
  }
  const checkout = () => {
    sk.emit("convert", "checkout", total)
    setBusy(true)
    window.setTimeout(() => {
      setBusy(false)
      onCheckout?.(lines)
    }, 900)
  }

  return (
    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} aria-labelledby={titleId} className={cn("w-full bg-sk-paper py-10 sm:py-14", className)}>
      <div className="mx-auto w-full max-w-6xl px-4 sm:px-8">
        <div className="flex items-baseline justify-between gap-4 border-b border-sk-line pb-5">
          <h1 id={titleId} className="font-sk-display text-3xl font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink">
            Cart
          </h1>
          <p className="text-sm tabular-nums text-sk-muted">
            {count} {count === 1 ? "item" : "items"}
          </p>
        </div>

        <div className="mt-6 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_360px] lg:gap-12">
          <div className="min-w-0">
            {lines.length === 0 ? (
              <div className="grid place-items-center gap-2 rounded-sk-card border border-dashed border-sk-line px-6 py-16 text-center">
                <p className="font-sk-display text-lg font-semibold tracking-[-0.02em]">Your cart is empty.</p>
                <p className="text-sm text-sk-muted">Items you add will show up here.</p>
              </div>
            ) : (
              <>
                <div className="hidden grid-cols-[minmax(0,1fr)_120px_96px] gap-4 pb-3 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted sm:grid">
                  <span>Product</span>
                  <span>Quantity</span>
                  <span className="text-right">Total</span>
                </div>
                <ul className="sm:border-t sm:border-sk-line">
                  <AnimatePresence initial={false}>
                    {lines.map((l) => (
                      <motion.li
                        key={l.id}
                        layout={!reduce}
                        initial={{ opacity: 0, height: 0 }}
                        animate={{ opacity: 1, height: "auto" }}
                        exit={{ opacity: 0, height: 0 }}
                        transition={{ duration: 0.32 * d, ease: [0.16, 1, 0.3, 1] }}
                        className="overflow-hidden border-b border-sk-line"
                      >
                        <div className="grid grid-cols-[80px_minmax(0,1fr)] gap-4 py-5 sm:grid-cols-[96px_minmax(0,1fr)_120px_96px] sm:items-center">
                          <ProductImage src={l.image} alt={l.title} className="w-20 rounded-sk-card border border-sk-line sm:w-24" fit="cover" zoomOnHover={false} />
                          <div className="min-w-0">
                            <p className="truncate text-[15px] font-medium text-sk-ink">{l.title}</p>
                            {l.variant ? <p className="text-sm text-sk-muted">{l.variant}</p> : null}
                            <p className="mt-1 text-sm tabular-nums text-sk-muted">{money(l.price)} each</p>
                            <div className="mt-3 flex items-center justify-between gap-3 sm:hidden">
                              <Stepper label={`Quantity, ${l.title}`} value={l.quantity} onChange={(q) => setQty(l.id, q)} size="sm" />
                              <Price amount={l.price * l.quantity} compareAt={l.compareAt ? l.compareAt * l.quantity : undefined} size="sm" />
                            </div>
                            <button
                              type="button"
                              onClick={() => remove(l.id)}
                              className={cn("-ml-2 mt-2 inline-flex h-11 items-center gap-1.5 rounded-sk-btn px-2 text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink active:scale-[0.97] sm:h-9", RING)}
                            >
                              <Trash2 size={14} />
                              Remove
                            </button>
                          </div>
                          <div className="hidden sm:block">
                            <Stepper label={`Quantity, ${l.title}`} value={l.quantity} onChange={(q) => setQty(l.id, q)} size="sm" />
                          </div>
                          <div className="hidden justify-end sm:flex">
                            <Price amount={l.price * l.quantity} compareAt={l.compareAt ? l.compareAt * l.quantity : undefined} size="sm" className="justify-end" />
                          </div>
                        </div>
                      </motion.li>
                    ))}
                  </AnimatePresence>
                </ul>
              </>
            )}
          </div>

          <aside className="lg:sticky lg:top-6 lg:self-start">
            <div className="rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6">
              <h2 className="font-sk-display text-lg font-semibold tracking-[-0.02em]">Order summary</h2>
              <dl className="mt-4 grid gap-2.5 text-sm">
                <div className="flex justify-between gap-4">
                  <dt className="text-sk-muted">Subtotal</dt>
                  <dd className="tabular-nums">{money(subtotal)}</dd>
                </div>
                {savings > 0 ? (
                  <div className="flex justify-between gap-4 text-sk-savings">
                    <dt>You save</dt>
                    <dd className="tabular-nums">{money(savings)}</dd>
                  </div>
                ) : null}
                <div className="flex justify-between gap-4">
                  <dt className="text-sk-muted">Shipping</dt>
                  <dd className={cn("tabular-nums", free && "text-sk-savings")}>{lines.length === 0 ? money(0) : free ? "Free" : money(shipping)}</dd>
                </div>
                <div className="flex justify-between gap-4">
                  <dt className="text-sk-muted">Tax</dt>
                  <dd className="text-sk-muted">At checkout</dd>
                </div>
                <div className="mt-2 flex items-baseline justify-between gap-4 border-t border-sk-line pt-4">
                  <dt className="font-medium">Total</dt>
                  <dd className="font-sk-display text-lg font-semibold tabular-nums text-sk-price">{money(total)}</dd>
                </div>
              </dl>
              <p className="mt-3 flex items-center gap-2 text-[13px]" aria-live="polite">
                {free ? <Check size={14} className="shrink-0 text-sk-savings" /> : <Truck size={14} className="shrink-0 text-sk-muted" />}
                {free ? (
                  <span className="text-sk-savings">Free shipping applied</span>
                ) : (
                  <span className="text-sk-muted">
                    Add <span className="font-medium tabular-nums text-sk-ink">{money(freeShippingThreshold - subtotal)}</span> for free shipping
                  </span>
                )}
              </p>
              <Button size="lg" className="mt-5 w-full active:scale-[0.97]" disabled={!lines.length} loading={busy} onClick={checkout}>
                <Lock size={14} />
                Checkout
              </Button>
              <p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-sk-muted">
                <ShieldCheck size={13} />
                Encrypted checkout. 30-day returns.
              </p>
            </div>
          </aside>
        </div>
      </div>
    </section>
  )
}

Usage

Import from "@/components/storekit/cart/cart-page" and pass your product data. The demo above exercises the full prop surface with sample values.

Cart page, StoreKit