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-buybox
pdp-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-buybox

Source

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.

Buy box, StoreKit