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-drawercart-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-drawerSource
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.



