Add to cart modal
Centered confirmation with the item, cart subtotal, Continue shopping and Checkout. Focus trap, Escape, scroll lock, focus return.
componentmodalanyatccart
$
npx shadcn@latest add @storekit/atc-modalatc-modal
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/atc-modal$
pnpm dlx shadcn@latest add @storekit/atc-modal$
yarn dlx shadcn@latest add @storekit/atc-modal$
bunx --bun shadcn@latest add @storekit/atc-modalSource
components/storekit/cart/atc-modal.tsx
192 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Lock, 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"
/* Centered add-to-cart confirmation. Shows the item, the cart subtotal and
two choices: keep shopping or go to checkout. Focus is trapped inside,
Escape and the scrim close it, and the page scroll locks while open.
Pass `contained` to pin it inside a relative parent. */
export type AtcModalItem = { title: string; variant?: string; price: number; compareAt?: number; image: string; quantity?: number }
const SAMPLE_ITEM: AtcModalItem = { title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.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 AtcModal({
open,
onOpenChange,
item = SAMPLE_ITEM,
cartCount = 2,
cartSubtotal = 14800,
contained = false,
onCheckout,
className,
}: {
open: boolean
onOpenChange: (open: boolean) => void
item?: AtcModalItem
cartCount?: number
/** In cents, after this add. */
cartSubtotal?: number
contained?: boolean
onCheckout?: () => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "atc-modal")
const panelRef = React.useRef<HTMLDivElement>(null)
const continueRef = React.useRef<HTMLButtonElement>(null)
const restoreRef = React.useRef<HTMLElement | null>(null)
const firstMount = React.useRef(true)
const titleId = React.useId()
const descId = React.useId()
const [busy, setBusy] = React.useState(false)
const d = reduce ? 0 : 1
const qty = item.quantity ?? 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])
// Focus in on open and give focus back to the trigger on close.
React.useEffect(() => {
const skip = firstMount.current && contained
firstMount.current = false
if (!open || skip) return
restoreRef.current = document.activeElement as HTMLElement | null
const t = window.setTimeout(() => continueRef.current?.focus({ preventScroll: true }), 30)
return () => {
window.clearTimeout(t)
restoreRef.current?.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()
}
}
return (
<AnimatePresence>
{open ? (
<div key="sk-atc-modal" ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn(contained ? "absolute" : "fixed", "inset-0 z-50 grid place-items-center p-4", className)}>
<motion.div
aria-hidden
className="absolute inset-0 bg-[rgb(0_0_0/0.5)]"
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}
aria-describedby={descId}
onKeyDown={onPanelKeyDown}
className="relative w-full max-w-md rounded-sk-card border border-sk-line bg-sk-paper p-5 shadow-[0_24px_60px_-24px_rgb(0_0_0/0.5)] sm:p-6"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.3 * d, ease: [0.16, 1, 0.3, 1] }}
>
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="flex items-center gap-2 font-sk-display text-lg font-semibold tracking-[-0.02em]">
<span className="grid h-7 w-7 place-items-center rounded-sk-chip bg-sk-savings/15 text-sk-savings">
<Check size={15} />
</span>
Added to cart
</h2>
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="-mr-2 -mt-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-4 flex items-center gap-4">
<ProductImage src={item.image} alt={item.title} className="h-20 w-20 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-medium">{item.title}</p>
<p className="text-sm text-sk-muted">
{item.variant ? `${item.variant} / ` : null}Qty <span className="tabular-nums">{qty}</span>
</p>
<p className="mt-1 flex items-baseline gap-2 text-sm tabular-nums">
<span className="font-semibold text-sk-price">{money(item.price * qty)}</span>
{item.compareAt && item.compareAt > item.price ? <s className="text-[13px] text-sk-muted">{money(item.compareAt * qty)}</s> : null}
</p>
</div>
</div>
<dl id={descId} className="mt-5 flex items-baseline justify-between border-t border-sk-line pt-4 text-sm">
<dt className="text-sk-muted">
Cart subtotal, <span className="tabular-nums">{cartCount}</span> {cartCount === 1 ? "item" : "items"}
</dt>
<dd className="font-semibold tabular-nums">{money(cartSubtotal)}</dd>
</dl>
<p className="mt-1 text-xs text-sk-muted">Shipping and tax are calculated at checkout.</p>
<div className="mt-5 grid gap-2 sm:grid-cols-2">
<Button ref={continueRef} variant="outline" size="lg" className="w-full px-4 active:scale-[0.97]" onClick={() => onOpenChange(false)}>
Continue shopping
</Button>
<Button
size="lg"
className="w-full px-4 active:scale-[0.97]"
loading={busy}
onClick={() => {
sk.emit("convert", "checkout", cartSubtotal)
setBusy(true)
window.setTimeout(() => {
setBusy(false)
onCheckout?.()
}, 900)
}}
>
<Lock size={14} />
Checkout
</Button>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
)
}
Usage
Import from "@/components/storekit/cart/atc-modal" and pass your product data. The demo above exercises the full prop surface with sample values.
