Cart drawer

Right-side cart with a free-shipping meter, animated line items, a two-item upsell, a collapsible discount code and totals. Focus trap, Escape, scroll lock.

blockdraweranycartquantitydiscountshipping-thresholdupsell
$npx shadcn@latest add @storekit/cart-drawer
cart-drawer

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-drawer
$pnpm dlx shadcn@latest add @storekit/cart-drawer
$yarn dlx shadcn@latest add @storekit/cart-drawer
$bunx --bun shadcn@latest add @storekit/cart-drawer

Source

components/storekit/cart/cart-drawer.tsx
399 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, ChevronDown, Lock, Plus, Trash2, Truck, X } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Input } from "@/components/storekit/ui/input"
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"

/* The flagship cart. A right-side drawer with a free-shipping meter, animated
   line items, a two-up upsell row, a collapsible discount code and totals.
   Pass `contained` to pin it inside a relative parent instead of the viewport
   (no body scroll lock, no focus grab on first mount). */

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

export type CartDrawerUpsell = { id: string; title: string; variant?: string; price: number; image: string }

const SAMPLE_LINES: CartDrawerLine[] = [
  { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 1 },
  { 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 SAMPLE_UPSELLS: CartDrawerUpsell[] = [
  { id: "bac-water", title: "Electrolytes Travel Pack", variant: "10 sticks", price: 1200, image: "/brands/morrow/electrolytes.webp" },
  { id: "protein", title: "Protein", variant: "30 servings", price: 5400, image: "/brands/morrow/protein.webp" },
]

const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'

export function CartDrawer({
  open,
  onOpenChange,
  items = SAMPLE_LINES,
  upsells = SAMPLE_UPSELLS,
  freeShippingThreshold = 15000,
  discountCodes = { WELCOME10: 10 },
  contained = false,
  onCheckout,
  onLinesChange,
  className,
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
  /** Initial lines. The drawer owns quantity and removal state after mount. */
  items?: CartDrawerLine[]
  upsells?: CartDrawerUpsell[]
  /** In cents. */
  freeShippingThreshold?: number
  /** Code to percent off. */
  discountCodes?: Record<string, number>
  contained?: boolean
  onCheckout?: (lines: CartDrawerLine[]) => void
  /** Fires after every quantity change, removal or upsell add, e.g. to keep a header count in sync. */
  onLinesChange?: (lines: CartDrawerLine[]) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-drawer")
  const [lines, setLines] = React.useState(items)
  const [codeOpen, setCodeOpen] = React.useState(false)
  const [code, setCode] = React.useState("")
  const [codeError, setCodeError] = React.useState<string>()
  const [applied, setApplied] = React.useState<{ code: string; pct: number } | null>(null)
  const [checkingOut, setCheckingOut] = React.useState(false)
  const panelRef = React.useRef<HTMLDivElement>(null)
  const closeRef = React.useRef<HTMLButtonElement>(null)
  const firstMount = React.useRef(true)
  const titleId = React.useId()
  const codeRegion = 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 discount = applied ? Math.round((subtotal * applied.pct) / 100) : 0
  const afterDiscount = subtotal - discount
  const remaining = Math.max(0, freeShippingThreshold - afterDiscount)
  const unlocked = remaining === 0 && lines.length > 0
  const pct = Math.min(100, (afterDiscount / freeShippingThreshold) * 100)

  // Scroll lock while open (viewport mode only).
  React.useEffect(() => {
    if (!open || contained) return
    const prev = document.body.style.overflow
    document.body.style.overflow = "hidden"
    return () => {
      document.body.style.overflow = prev
    }
  }, [open, contained])

  // Report line changes (not the initial lines) to the parent.
  const linesChange = React.useRef(onLinesChange)
  linesChange.current = onLinesChange
  const initialLines = React.useRef(lines)
  React.useEffect(() => {
    if (lines !== initialLines.current) linesChange.current?.(lines)
  }, [lines])

  // Move focus in on open and hand it back to the opener on close. Skip the
  // very first mount in contained mode so a gallery card that renders open
  // does not steal focus or scroll the page.
  React.useEffect(() => {
    const skip = firstMount.current && contained
    firstMount.current = false
    if (!open || skip) return
    const opener = document.activeElement as HTMLElement | null
    const t = window.setTimeout(() => closeRef.current?.focus({ preventScroll: true }), 30)
    return () => {
      window.clearTimeout(t)
      if (opener && opener !== document.body && opener.isConnected) opener.focus({ preventScroll: true })
    }
  }, [open, contained])

  // Escape closes.
  React.useEffect(() => {
    if (!open) return
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onOpenChange(false)
    }
    document.addEventListener("keydown", onKey)
    return () => document.removeEventListener("keydown", onKey)
  }, [open, onOpenChange])

  // Focus trap: keep Tab inside the panel.
  const onPanelKeyDown = (e: React.KeyboardEvent) => {
    if (e.key !== "Tab" || !panelRef.current) return
    const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
    if (!nodes.length) return
    const first = nodes[0]
    const last = nodes[nodes.length - 1]
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault()
      last.focus()
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault()
      first.focus()
    }
  }

  const setQty = (id: string, quantity: number) => setLines((ls) => ls.map((l) => (l.id === id ? { ...l, quantity } : l)))
  const remove = (id: string) => setLines((ls) => ls.filter((l) => l.id !== id))
  const addUpsell = (u: CartDrawerUpsell) => {
    sk.emit("interact", "upsell_add", u.price)
    setLines((ls) => (ls.some((l) => l.id === u.id) ? ls.map((l) => (l.id === u.id ? { ...l, quantity: l.quantity + 1 } : l)) : [...ls, { ...u, quantity: 1 }]))
  }

  const applyCode = () => {
    const key = code.trim().toUpperCase()
    if (!key) return setCodeError("Enter a code.")
    const off = discountCodes[key]
    if (!off) return setCodeError("That code is not valid.")
    setApplied({ code: key, pct: off })
    setCodeError(undefined)
    setCode("")
  }

  const checkout = () => {
    sk.emit("convert", "checkout", afterDiscount)
    setCheckingOut(true)
    window.setTimeout(() => {
      setCheckingOut(false)
      onCheckout?.(lines)
    }, 900)
  }

  const d = reduce ? 0 : 1
  const position = contained ? "absolute" : "fixed"

  return (
    <AnimatePresence>
      {open ? (
        <div key="sk-cart-drawer" ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn(position, "inset-0 z-50", className)}>
          <motion.div
            aria-hidden
            className="absolute inset-0 bg-sk-ink/40"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 * d }}
            onClick={() => onOpenChange(false)}
          />
          <motion.div
            ref={panelRef}
            role="dialog"
            aria-modal="true"
            aria-labelledby={titleId}
            onKeyDown={onPanelKeyDown}
            className="absolute inset-y-0 right-0 flex w-full max-w-[420px] flex-col border-l border-sk-line bg-sk-paper shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)]"
            initial={{ x: "100%" }}
            animate={{ x: 0 }}
            exit={{ x: "100%" }}
            transition={{ duration: 0.34 * d, ease: [0.32, 0.72, 0, 1] }}
          >
            {/* Header */}
            <div className="flex items-center justify-between border-b border-sk-line px-5 py-3">
              <h2 id={titleId} className="font-sk-display text-lg font-semibold tracking-[-0.02em]">
                Your cart <span className="tabular-nums text-sk-muted">({count})</span>
              </h2>
              <button
                ref={closeRef}
                type="button"
                aria-label="Close cart"
                onClick={() => onOpenChange(false)}
                className="-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
              >
                <X size={18} />
              </button>
            </div>

            {/* Free shipping meter */}
            <div className="border-b border-sk-line px-5 py-4">
              <p className="flex items-center gap-2 text-[13px] text-sk-ink" aria-live="polite">
                {unlocked ? <Check size={14} className="text-sk-savings" /> : <Truck size={14} className="text-sk-muted" />}
                {unlocked ? (
                  <span className="font-medium text-sk-savings">Free shipping unlocked</span>
                ) : (
                  <span>
                    You are <span className="font-semibold tabular-nums">{money(remaining)}</span> away from free shipping
                  </span>
                )}
              </p>
              <div className="mt-2.5 h-1.5 overflow-hidden rounded-full bg-sk-ink/[0.08]">
                <motion.div
                  className={cn("h-full w-full origin-left rounded-full transition-colors duration-300", unlocked ? "bg-sk-savings" : "bg-sk-accent")}
                  initial={false}
                  animate={{ scaleX: pct / 100 }}
                  transition={{ duration: 0.5 * d, ease: [0.16, 1, 0.3, 1] }}
                />
              </div>
            </div>

            {/* Body */}
            <div className="flex-1 overflow-y-auto overscroll-contain">
              {lines.length === 0 ? (
                <div className="grid place-items-center gap-3 px-5 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">The best sellers are a good place to start.</p>
                </div>
              ) : (
                <ul className="px-5">
                  <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, x: reduce ? 0 : 24 }}
                        transition={{ duration: 0.32 * d, ease: [0.16, 1, 0.3, 1] }}
                        className="overflow-hidden border-b border-sk-line last:border-b-0"
                      >
                        <div className="flex gap-3 py-4">
                          <ProductImage src={l.image} alt={l.title} className="h-20 w-20 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
                          <div className="flex min-w-0 flex-1 flex-col gap-2">
                            <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
                              <div className="min-w-0">
                                <p className="line-clamp-2 text-sm font-medium text-sk-ink">{l.title}</p>
                                {l.variant ? <p className="text-[13px] text-sk-muted">{l.variant}</p> : null}
                              </div>
                              <Price amount={l.price * l.quantity} compareAt={l.compareAt ? l.compareAt * l.quantity : undefined} size="sm" className="shrink-0 sm:justify-end" />
                            </div>
                            <div className="flex items-center justify-between">
                              <Stepper label={`Quantity, ${l.title}`} value={l.quantity} onChange={(q) => setQty(l.id, q)} size="sm" />
                              <button
                                type="button"
                                aria-label={`Remove ${l.title}`}
                                onClick={() => remove(l.id)}
                                className="-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-colors duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]"
                              >
                                <Trash2 size={16} />
                              </button>
                            </div>
                          </div>
                        </div>
                      </motion.li>
                    ))}
                  </AnimatePresence>
                </ul>
              )}

              {/* Upsell row */}
              {upsells.length ? (
                <div className="border-t border-sk-line bg-sk-surface/60 px-5 py-4">
                  <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Pairs well with</p>
                  <div className="mt-3 grid gap-2">
                    {upsells.slice(0, 2).map((u) => {
                      const inCart = lines.some((l) => l.id === u.id)
                      return (
                        <div key={u.id} className="flex items-center gap-2.5 rounded-sk-card border border-sk-line bg-sk-card p-2">
                          <ProductImage src={u.image} alt={u.title} className="h-11 w-11 shrink-0 rounded-sk-chip" fit="cover" zoomOnHover={false} />
                          <div className="min-w-0 flex-1">
                            <p className="truncate text-[13px] font-medium leading-tight">{u.title}</p>
                            <p className="text-[13px] tabular-nums text-sk-muted">
                              {u.variant ? `${u.variant} / ` : null}
                              {money(u.price)}
                            </p>
                          </div>
                          <button
                            type="button"
                            aria-label={inCart ? `Add another ${u.title}` : `Add ${u.title}`}
                            onClick={() => addUpsell(u)}
                            className={cn(
                              "grid h-11 w-11 shrink-0 place-items-center rounded-sk-btn border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]",
                              inCart ? "border-sk-savings/40 text-sk-savings" : "border-sk-line text-sk-ink hover:bg-sk-ink/[0.05]"
                            )}
                          >
                            {inCart ? <Check size={16} /> : <Plus size={16} />}
                          </button>
                        </div>
                      )
                    })}
                  </div>
                </div>
              ) : null}
            </div>

            {/* Footer */}
            <div className="border-t border-sk-line px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-3">
              <button
                type="button"
                aria-expanded={codeOpen}
                aria-controls={codeRegion}
                onClick={() => setCodeOpen((o) => !o)}
                className="flex h-11 w-full items-center justify-between rounded-sk-btn text-[13px] font-medium text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
              >
                {applied ? `Code ${applied.code} applied` : "Add a discount code"}
                <ChevronDown size={16} className={cn("text-sk-muted transition-transform duration-200", codeOpen && "rotate-180")} />
              </button>
              <AnimatePresence initial={false}>
                {codeOpen ? (
                  <motion.div
                    id={codeRegion}
                    initial={{ height: 0, opacity: 0 }}
                    animate={{ height: "auto", opacity: 1 }}
                    exit={{ height: 0, opacity: 0 }}
                    transition={{ duration: 0.28 * d, ease: [0.16, 1, 0.3, 1] }}
                    className="overflow-hidden"
                  >
                    <form
                      className="flex items-start gap-2 px-px pb-3 pt-1"
                      onSubmit={(e) => {
                        e.preventDefault()
                        applyCode()
                      }}
                    >
                      <div className="flex-1">
                        <Input aria-label="Discount code" placeholder="WELCOME10" value={code} onChange={(e) => setCode(e.target.value)} error={codeError} />
                      </div>
                      <Button type="submit" variant="outline">Apply</Button>
                    </form>
                  </motion.div>
                ) : null}
              </AnimatePresence>

              <dl className="grid gap-1.5 text-sm">
                <div className="flex justify-between">
                  <dt className="text-sk-muted">Subtotal</dt>
                  <dd className="tabular-nums">{money(subtotal)}</dd>
                </div>
                {applied ? (
                  <div className="flex justify-between text-sk-savings">
                    <dt>Discount ({applied.pct}%)</dt>
                    <dd className="tabular-nums">-{money(discount)}</dd>
                  </div>
                ) : null}
                <div className="flex justify-between">
                  <dt className="text-sk-muted">Shipping</dt>
                  <dd className={cn(unlocked && "text-sk-savings")}>{unlocked ? "Free" : "Calculated at checkout"}</dd>
                </div>
              </dl>
              <div className="mt-4 grid gap-2">
                <Button size="lg" className="w-full" disabled={!lines.length} loading={checkingOut} onClick={checkout}>
                  <Lock size={14} />
                  Checkout <span className="tabular-nums">{money(afterDiscount)}</span>
                </Button>
                <Button variant="ghost" className="w-full" onClick={() => onOpenChange(false)}>
                  Continue shopping
                </Button>
              </div>
            </div>
          </motion.div>
        </div>
      ) : null}
    </AnimatePresence>
  )
}

Usage

Controlled by open/onOpenChange. Pass contained to pin it inside a relative parent instead of the viewport.

Cart drawer, StoreKit