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-minicart-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-miniSource
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.


