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-modal
atc-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-modal

Source

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.

Add to cart modal, StoreKit