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-sheet
cart-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-sheet

Source

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.

Cart bottom sheet, StoreKit