Mini cart

Header cart icon with a count badge and a popover preview with View cart and Checkout.

componentpopoveranycart
$npx shadcn@latest add @storekit/cart-mini
cart-mini

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-mini
$pnpm dlx shadcn@latest add @storekit/cart-mini
$yarn dlx shadcn@latest add @storekit/cart-mini
$bunx --bun shadcn@latest add @storekit/cart-mini

Source

components/storekit/cart/cart-mini.tsx
143 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ShoppingBag } 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"

/* Header cart icon with a count badge. Opens a small popover that grows out
   of the trigger (scale 0.95 to 1, origin top right). Outside click and
   Escape close it and return focus to the trigger. */

type MiniLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number }

const SAMPLE: MiniLine[] = [
  { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 2 },
  { id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
]

export function CartMini({
  items = SAMPLE,
  defaultOpen = false,
  onViewCart,
  onCheckout,
  className,
  panelClassName,
}: {
  items?: MiniLine[]
  defaultOpen?: boolean
  onViewCart?: () => void
  onCheckout?: () => void
  className?: string
  /** Extra classes for the popover, e.g. to span a narrow header on mobile. */
  panelClassName?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-mini")
  const [open, setOpen] = React.useState(defaultOpen)
  const triggerRef = React.useRef<HTMLButtonElement>(null)
  const panelId = React.useId()
  const count = items.reduce((n, l) => n + l.quantity, 0)
  const subtotal = items.reduce((n, l) => n + l.price * l.quantity, 0)

  React.useEffect(() => {
    if (!open) return
    const onDown = (e: PointerEvent) => {
      if (sk.ref.current && !sk.ref.current.contains(e.target as Node)) setOpen(false)
    }
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setOpen(false)
        triggerRef.current?.focus()
      }
    }
    document.addEventListener("pointerdown", onDown)
    document.addEventListener("keydown", onKey)
    return () => {
      document.removeEventListener("pointerdown", onDown)
      document.removeEventListener("keydown", onKey)
    }
  }, [open, sk.ref])

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("relative inline-block", className)}>
      <button
        ref={triggerRef}
        type="button"
        aria-label={`Cart, ${count} items`}
        aria-expanded={open}
        aria-controls={panelId}
        aria-haspopup="dialog"
        onClick={() => setOpen((o) => !o)}
        className={cn(
          "relative grid h-11 w-11 place-items-center rounded-sk-btn text-sk-ink transition-[transform,background-color] duration-200 hover:bg-sk-ink/[0.05] active:scale-[0.97]",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper",
          open && "bg-sk-ink/[0.05]"
        )}
      >
        <ShoppingBag size={20} strokeWidth={1.75} />
        {count > 0 ? (
          <span className="absolute right-1 top-1 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-sk-accent px-1 text-[11px] font-semibold tabular-nums leading-none text-sk-accent-ink">
            {count}
          </span>
        ) : null}
      </button>

      <AnimatePresence>
        {open ? (
          <motion.div
            id={panelId}
            role="dialog"
            aria-label="Cart preview"
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
            transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
            style={{ transformOrigin: "top right" }}
            className={cn("absolute right-0 top-full z-40 mt-2 w-[min(320px,calc(100vw-32px))] rounded-sk-card border border-sk-line bg-sk-card p-4 text-left shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)]", panelClassName)}
          >
            <div className="flex items-baseline justify-between">
              <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">In your cart</p>
              <p className="text-[13px] tabular-nums text-sk-muted">{count} items</p>
            </div>
            <ul className="mt-3 divide-y divide-sk-line">
              {items.slice(0, 2).map((l) => (
                <li key={l.id} className="flex items-center gap-3 py-2.5">
                  <ProductImage src={l.image} alt={l.title} className="h-12 w-12 shrink-0 rounded-sk-chip" fit="cover" zoomOnHover={false} />
                  <div className="min-w-0 flex-1">
                    <p className="truncate text-sm font-medium">{l.title}</p>
                    <p className="text-[13px] text-sk-muted">
                      {l.variant ? `${l.variant} / ` : null}Qty {l.quantity}
                    </p>
                  </div>
                  <span className="text-sm font-medium tabular-nums text-sk-price">{money(l.price * l.quantity)}</span>
                </li>
              ))}
            </ul>
            <div className="mt-2 flex justify-between border-t border-sk-line pt-3 text-sm">
              <span className="text-sk-muted">Subtotal</span>
              <span className="font-semibold tabular-nums">{money(subtotal)}</span>
            </div>
            <div className="mt-3 grid grid-cols-2 gap-2">
              <Button variant="outline" size="sm" className="h-11" onClick={onViewCart}>View cart</Button>
              <Button
                size="sm"
                className="h-11"
                onClick={() => {
                  sk.emit("convert", "checkout", subtotal)
                  onCheckout?.()
                }}
              >
                Checkout
              </Button>
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  )
}

Usage

Import from "@/components/storekit/cart/cart-mini" and pass your product data. The demo above exercises the full prop surface with sample values.

Mini cart, StoreKit