Cart bottom sheet
Mobile bottom sheet after add to cart with a drag handle, the added item, cart subtotal, View cart and Checkout. Focus trap, Escape, scroll lock.
componentsheetanycartatc
$
npx shadcn@latest add @storekit/cart-sheetcart-sheet
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-sheet$
pnpm dlx shadcn@latest add @storekit/cart-sheet$
yarn dlx shadcn@latest add @storekit/cart-sheet$
bunx --bun shadcn@latest add @storekit/cart-sheetSource
components/storekit/cart/cart-sheet.tsx
209 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react"
import { Check, X } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Mobile bottom sheet shown after add to cart. Keeps the shopper on the
page with the item, the cart subtotal and two clear next steps. Drag the
handle down (or tap the scrim, or press Escape) to dismiss. Pass
`contained` to pin it inside a relative parent instead of the viewport. */
export type CartSheetItem = { title: string; variant?: string; price: number; image: string; quantity?: number }
const SAMPLE_ITEM: CartSheetItem = { title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 1 }
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
export function CartSheet({
open,
onOpenChange,
item = SAMPLE_ITEM,
cartCount = 3,
cartSubtotal = 19700,
contained = false,
onViewCart,
onCheckout,
className,
}: {
open: boolean
onOpenChange: (open: boolean) => void
item?: CartSheetItem
/** Items in the cart after this add. */
cartCount?: number
/** Cart subtotal in cents after this add. */
cartSubtotal?: number
contained?: boolean
onViewCart?: () => void
onCheckout?: () => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-sheet")
const panelRef = React.useRef<HTMLDivElement>(null)
const primaryRef = React.useRef<HTMLButtonElement>(null)
const firstMount = React.useRef(true)
const titleId = React.useId()
const d = reduce ? 0 : 1
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])
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(() => primaryRef.current?.focus({ preventScroll: true }), 30)
return () => {
window.clearTimeout(t)
if (opener && opener !== document.body && opener.isConnected) opener.focus({ preventScroll: true })
}
}, [open, contained])
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])
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 onDragEnd = (_: unknown, info: PanInfo) => {
if (info.offset.y > 96 || info.velocity.y > 500) onOpenChange(false)
}
const qty = item.quantity ?? 1
return (
<AnimatePresence>
{open ? (
<div key="sk-cart-sheet" ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn(contained ? "absolute" : "fixed", "inset-0 z-50", className)}>
<motion.div
aria-hidden
className="absolute inset-0 bg-[rgb(0_0_0/0.45)]"
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}
drag={reduce ? false : "y"}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={onDragEnd}
className="absolute inset-x-0 bottom-0 mx-auto w-full max-w-md rounded-t-sk-card border border-b-0 border-sk-line bg-sk-paper px-5 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-24px_60px_-24px_rgb(0_0_0/0.4)]"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ duration: 0.36 * d, ease: [0.32, 0.72, 0, 1] }}
>
<div className="flex justify-center py-2" aria-hidden>
<span className="h-1 w-10 cursor-grab rounded-sk-chip bg-sk-ink/20 active:cursor-grabbing" />
</div>
<div className="flex items-center justify-between gap-3">
<h2 id={titleId} className="flex items-center gap-2 text-[15px] font-medium text-sk-ink">
<span className="grid h-6 w-6 place-items-center rounded-sk-chip bg-sk-savings/15 text-sk-savings">
<Check size={14} />
</span>
Added to cart
</h2>
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
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]"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex items-center gap-3 rounded-sk-card border border-sk-line bg-sk-card p-3">
<ProductImage src={item.image} alt={item.title} className="h-16 w-16 shrink-0 rounded-sk-card" fit="cover" zoomOnHover={false} />
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-sm font-medium">{item.title}</p>
<p className="text-[13px] text-sk-muted">
{item.variant ? `${item.variant} / ` : null}Qty <span className="tabular-nums">{qty}</span>
</p>
</div>
<p className="shrink-0 text-sm font-semibold tabular-nums text-sk-price">{money(item.price * qty)}</p>
</div>
<div className="mt-4 flex items-baseline justify-between text-sm">
<span className="text-sk-muted">
Cart subtotal (<span className="tabular-nums">{cartCount}</span> {cartCount === 1 ? "item" : "items"})
</span>
<span className="font-semibold tabular-nums">{money(cartSubtotal)}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button
variant="outline"
size="lg"
className="w-full px-3 active:scale-[0.97]"
onClick={() => {
sk.emit("interact", "view_cart")
onViewCart?.()
onOpenChange(false)
}}
>
View cart
</Button>
<Button
ref={primaryRef}
size="lg"
className="w-full px-3 active:scale-[0.97]"
onClick={() => {
sk.emit("convert", "checkout", cartSubtotal)
onCheckout?.()
}}
>
Checkout
</Button>
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
className="mt-2 h-11 w-full rounded-sk-btn text-[13px] font-medium text-sk-muted transition-colors duration-200 hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]"
>
Keep shopping
</button>
</motion.div>
</div>
) : null}
</AnimatePresence>
)
}
Usage
Controlled by open/onOpenChange. Drag down past 96px to dismiss. Pass contained to pin it inside a relative parent.
