Buy box
Rating, title, live price, variant pills with sold out state, subscribe and save, quantity, add to cart, trust row and accordion.
blockbuyboxanyvariantsatcsubscribequantity
$
npx shadcn@latest add @storekit/pdp-buyboxpdp-buybox
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/pdp-buybox$
pnpm dlx shadcn@latest add @storekit/pdp-buybox$
yarn dlx shadcn@latest add @storekit/pdp-buybox$
bunx --bun shadcn@latest add @storekit/pdp-buyboxSource
components/storekit/pdp/pdp-buybox.tsx
358 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, ChevronDown, FileCheck2, ShieldCheck, Truck } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Price } from "@/components/storekit/ui/price"
import { Stars } from "@/components/storekit/ui/stars"
import { Stepper } from "@/components/storekit/ui/stepper"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* The product page buy box: rating, title, live price, variant pills,
one-time vs subscribe, quantity, add to cart with confirmation, a trust row
and an accordion. Prices are integer cents. */
type Variant = { id: string; label: string; price: number; compareAt?: number; soldOut?: boolean }
type Detail = { title: string; body: React.ReactNode }
const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const DEFAULT_VARIANTS: Variant[] = [
{ id: "30", label: "30 servings", price: 4900, compareAt: 5900 },
{ id: "60", label: "60 servings", price: 8900, compareAt: 11800 },
{ id: "bundle", label: "3-pouch bundle", price: 12900, soldOut: true },
]
const DEFAULT_DETAILS: Detail[] = [
{ title: "Details", body: "28 greens, adaptogens and probiotics in one scoop. 30 servings per pouch, vegan and gluten free, made in the USA." },
{ title: "Shipping", body: "Orders placed before 2pm ET ship the same day. Free shipping on subscriptions and orders over $75." },
{ title: "Lab results", body: "Every batch is third-party tested for heavy metals, pesticides and microbials. Results are posted for each batch code." },
]
const FREQUENCIES = [30, 60, 90]
function onRadioKeys<T>(e: React.KeyboardEvent, items: T[], current: number, enabled: (t: T) => boolean, pick: (i: number) => void) {
const forward = e.key === "ArrowRight" || e.key === "ArrowDown"
const back = e.key === "ArrowLeft" || e.key === "ArrowUp"
if (!forward && !back) return
e.preventDefault()
const n = items.length
for (let step = 1; step <= n; step++) {
const i = (current + (forward ? step : -step) + n * 2) % n
if (enabled(items[i])) return pick(i)
}
}
export function PdpBuybox({
category = "Powders",
title = "Daily Greens",
rating = 4.8,
reviewCount = 1284,
variants = DEFAULT_VARIANTS,
subscribeDiscount = 0.15,
details = DEFAULT_DETAILS,
onAddToCart,
className,
}: {
category?: string
title?: string
rating?: number
reviewCount?: number
variants?: Variant[]
/** 0.15 = 15% off when subscribed. */
subscribeDiscount?: number
details?: Detail[]
onAddToCart?: (line: { variantId: string; quantity: number; subscribe: boolean; everyDays?: number }) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-buybox")
const firstAvailable = Math.max(0, variants.findIndex((v) => !v.soldOut))
const [vIndex, setVIndex] = useState(firstAvailable)
const [subscribe, setSubscribe] = useState(true)
const [every, setEvery] = useState(30)
const [qty, setQty] = useState(1)
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const [open, setOpen] = useState<number | null>(0)
const pillRefs = useRef<(HTMLButtonElement | null)[]>([])
const planRefs = useRef<(HTMLButtonElement | null)[]>([])
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const variant = variants[vIndex] ?? variants[0]
const pct = Math.round(subscribeDiscount * 100)
const subUnit = Math.round(variant.price * (1 - subscribeDiscount))
const unit = subscribe ? subUnit : variant.price
const shownCompare = subscribe ? variant.compareAt ?? variant.price : variant.compareAt
const t = { duration: reduce ? 0 : 0.35, ease: EASE }
const chooseVariant = (i: number) => {
if (i !== vIndex) sk.emit("interact", "variant", undefined)
setVIndex(i)
}
const choosePlan = (sub: boolean) => {
if (sub !== subscribe) sk.emit("interact", sub ? "subscribe" : "one_time", undefined)
setSubscribe(sub)
}
const pickVariant = (i: number) => {
chooseVariant(i)
pillRefs.current[i]?.focus()
}
const pickPlan = (i: number) => {
choosePlan(i === 1)
planRefs.current[i]?.focus()
}
const add = () => {
if (status !== "idle" || variant.soldOut) return
setStatus("loading")
sk.emit("convert", "add_to_cart", unit * qty)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onAddToCart?.({ variantId: variant.id, quantity: qty, subscribe, everyDays: subscribe ? every : undefined })
timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
}, 700)
)
}
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-6", className)}>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{category}</span>
<Stars value={rating} count={reviewCount} size={13} showValue />
</div>
<h1 className="font-sk-display text-3xl font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink sm:text-4xl">{title}</h1>
<div className="relative h-9 overflow-hidden" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${variant.id}-${unit}`}
initial={{ opacity: 0, y: reduce ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -10 }}
transition={t}
className="absolute inset-x-0 top-0"
>
<Price amount={unit} compareAt={shownCompare} size="lg" showSavings />
</motion.div>
</AnimatePresence>
</div>
</div>
<fieldset className="flex flex-col gap-2.5">
<legend className="mb-2.5 flex w-full items-center justify-between text-sm">
<span className="font-medium text-sk-ink">Size</span>
<span className="text-sk-muted">{variant.label}</span>
</legend>
<div role="radiogroup" aria-label="Size" className="flex flex-wrap gap-2">
{variants.map((v, i) => {
const selected = i === vIndex
return (
<span key={v.id} className="group/pill relative">
<button
ref={(el) => {
pillRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={selected}
aria-disabled={v.soldOut || undefined}
aria-describedby={v.soldOut ? `${uid}-so-${i}` : undefined}
tabIndex={selected ? 0 : -1}
onClick={() => !v.soldOut && chooseVariant(i)}
onKeyDown={(e) => onRadioKeys(e, variants, vIndex, (x) => !x.soldOut, pickVariant)}
className={cn(
"h-11 min-w-[72px] rounded-sk-btn border px-4 text-sm font-medium tabular-nums transition-[border-color,background-color,color] duration-200 ease-sk-out",
selected ? "border-sk-accent bg-sk-accent/[0.08] text-sk-ink" : "border-sk-line text-sk-ink hover:border-sk-ink/40",
v.soldOut && "cursor-not-allowed text-sk-muted line-through decoration-sk-muted/70 hover:border-sk-line",
RING
)}
>
{v.label}
</button>
{v.soldOut ? (
<span
id={`${uid}-so-${i}`}
role="tooltip"
className="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 origin-bottom scale-95 whitespace-nowrap rounded-sk-chip bg-sk-ink px-2 py-1 text-[11px] font-medium text-sk-paper opacity-0 transition-[opacity,transform] duration-150 ease-sk-out group-hover/pill:scale-100 group-hover/pill:opacity-100 motion-reduce:transition-none"
>
Sold out
</span>
) : null}
</span>
)
})}
</div>
</fieldset>
<div role="radiogroup" aria-label="Purchase option" className="flex flex-col gap-2.5">
{[false, true].map((isSub, i) => {
const selected = subscribe === isSub
return (
<div
key={String(isSub)}
className={cn(
"overflow-hidden rounded-sk-card border transition-[border-color,background-color] duration-200 ease-sk-out",
selected ? "border-sk-accent bg-sk-accent/[0.04]" : "border-sk-line bg-sk-card hover:border-sk-ink/30"
)}
>
<button
ref={(el) => {
planRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => choosePlan(isSub)}
onKeyDown={(e) => onRadioKeys(e, [0, 1], subscribe ? 1 : 0, () => true, pickPlan)}
className={cn("flex min-h-[56px] w-full items-center gap-3 rounded-sk-card px-4 py-3 text-left", RING)}
>
<span className={cn("grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border transition-colors duration-200", selected ? "border-sk-accent" : "border-sk-line")}>
<motion.span
initial={false}
animate={{ scale: selected ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
className="h-2.5 w-2.5 rounded-full bg-sk-accent"
/>
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium text-sk-ink">{isSub ? `Subscribe and save ${pct}%` : "One-time purchase"}</span>
{isSub ? <span className="text-[13px] text-sk-muted">Skip or cancel anytime</span> : null}
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-sk-price">{money(isSub ? subUnit : variant.price)}</span>
</button>
{isSub ? (
<AnimatePresence initial={false}>
{selected ? (
<motion.div
key="freq"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={t}
>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-sk-line/70 px-4 py-3">
<label className="flex items-center gap-2 text-[13px] text-sk-muted">
Deliver every
<span className="relative">
<select
value={every}
onChange={(e) => setEvery(Number(e.target.value))}
className={cn("h-11 appearance-none rounded-sk-btn border border-sk-line bg-sk-card pl-3 pr-8 text-base text-sk-ink sm:text-sm", RING)}
>
{FREQUENCIES.map((f) => (
<option key={f} value={f}>
{f} days
</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sk-muted" />
</span>
</label>
<span className="text-[13px] text-sk-muted">
<span className="font-semibold tabular-nums text-sk-ink">{money(subUnit * qty)}</span> per delivery
</span>
</div>
</motion.div>
) : null}
</AnimatePresence>
) : null}
</div>
)
})}
</div>
<div className="flex flex-col gap-3">
<div className="flex gap-3">
<Stepper value={qty} onChange={setQty} max={20} className="shrink-0" />
<Button size="lg" className="h-auto min-h-11 min-w-0 flex-1 whitespace-normal px-4 py-2 text-center leading-tight sm:px-6" loading={status === "loading"} disabled={variant.soldOut} onClick={add}>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={status === "added" ? "added" : "add"}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}
className="inline-flex flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5"
>
{status === "added" ? (
<>
<Check size={16} strokeWidth={2.5} /> Added to cart
</>
) : (
<>
<span className="whitespace-nowrap">Add to cart</span>
<span className="whitespace-nowrap">
<span aria-hidden className="mr-1.5 hidden sm:inline">·</span>
{money(unit * qty)}
</span>
</>
)}
</motion.span>
</AnimatePresence>
</Button>
</div>
<ul className="grid grid-cols-3 gap-2 text-[12px] text-sk-muted">
{[
{ icon: FileCheck2, label: "Third-party tested" },
{ icon: Truck, label: "Ships same day" },
{ icon: ShieldCheck, label: "30-day guarantee" },
].map(({ icon: Icon, label }) => (
<li key={label} className="flex flex-col items-center justify-center gap-1.5 rounded-sk-card bg-sk-surface px-2 py-2.5 text-center leading-tight">
<Icon size={15} className="shrink-0 text-sk-ink" aria-hidden />
{label}
</li>
))}
</ul>
</div>
<div className="border-t border-sk-line">
{details.map((d, i) => {
const isOpen = open === i
const panelId = `${uid}-acc-${i}`
return (
<div key={d.title} className="border-b border-sk-line">
<h3>
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpen(isOpen ? null : i)}
className={cn("flex min-h-[48px] w-full items-center justify-between gap-4 py-3 text-left text-sm font-medium text-sk-ink", RING)}
>
{d.title}
<motion.span animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: reduce ? 0 : 0.25, ease: EASE }} className="text-sk-muted">
<ChevronDown size={16} />
</motion.span>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
id={panelId}
key="panel"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={t}
className="overflow-hidden"
>
<div className="pb-4 text-sm leading-[1.5] text-sk-muted">{d.body}</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
})}
</div>
</div>
)
}
Usage
Pass variants in integer cents. Mark a variant soldOut to strike it and skip it in keyboard navigation.
