One-time or subscribe tabs
Two tabs that each carry their own price, benefits and call to action, with a per-serving line.
blockpurchase-tabssupplementsbeveragebeautysubscribeatc
$
npx shadcn@latest add @storekit/upsell-purchase-tabsupsell-purchase-tabs
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/upsell-purchase-tabs$
pnpm dlx shadcn@latest add @storekit/upsell-purchase-tabs$
yarn dlx shadcn@latest add @storekit/upsell-purchase-tabs$
bunx --bun shadcn@latest add @storekit/upsell-purchase-tabsSource
components/storekit/upsell/upsell-purchase-tabs.tsx
184 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check } 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 { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* One-time vs subscribe as two tabs. Each tab owns its price, its benefit
list and its call to action, so the shopper compares two complete offers
instead of a radio and a footnote. Prices are integer cents. */
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"
type Mode = "once" | "subscribe"
const DEFAULT_BENEFITS: Record<Mode, string[]> = {
once: ["Ships once, no commitment", "Third-party tested, lab results online", "Free shipping over $50"],
subscribe: ["15% off every delivery", "Free shipping on every order", "Skip, pause or cancel from your account", "Fresh batch each shipment"],
}
export function UpsellPurchaseTabs({
price = 5900,
discount = 0.15,
everyDays = 30,
servings,
benefits = DEFAULT_BENEFITS,
defaultMode = "subscribe",
onAddToCart,
className,
}: {
price?: number
/** 0.15 = 15% off when subscribed. */
discount?: number
everyDays?: number
/** Servings per unit. When set, a per-serving price shows under each price. */
servings?: number
benefits?: Record<Mode, string[]>
defaultMode?: Mode
onAddToCart?: (line: { subscribe: boolean; price: number; everyDays?: number }) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("upsell", "upsell-purchase-tabs")
const [mode, setMode] = useState<Mode>(defaultMode)
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const tabRefs = useRef<(HTMLButtonElement | null)[]>([])
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const pct = Math.round(discount * 100)
const subPrice = Math.round(price * (1 - discount))
const amount = mode === "subscribe" ? subPrice : price
const modes: { id: Mode; label: string }[] = [
{ id: "once", label: "One-time" },
{ id: "subscribe", label: `Subscribe, save ${pct}%` },
]
const pick = (m: Mode, focus = false) => {
if (m !== mode) sk.emit("interact", m === "subscribe" ? "subscribe" : "one_time", m === "subscribe" ? subPrice : price)
setMode(m)
if (focus) tabRefs.current[m === "once" ? 0 : 1]?.focus()
}
const add = () => {
if (status !== "idle") return
setStatus("loading")
sk.emit("convert", "add_to_cart", amount)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onAddToCart?.({ subscribe: mode === "subscribe", price: amount, everyDays: mode === "subscribe" ? everyDays : 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 overflow-hidden rounded-sk-card border border-sk-line bg-sk-card", className)}>
<div role="tablist" aria-label="Purchase option" className="grid grid-cols-2 gap-1 border-b border-sk-line bg-sk-surface p-1">
{modes.map((m, i) => {
const on = m.id === mode
return (
<button
key={m.id}
ref={(el) => {
tabRefs.current[i] = el
}}
id={`${uid}-tab-${m.id}`}
type="button"
role="tab"
aria-selected={on}
aria-controls={`${uid}-panel`}
tabIndex={on ? 0 : -1}
onClick={() => pick(m.id)}
onKeyDown={(e) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) {
e.preventDefault()
pick(mode === "once" ? "subscribe" : "once", true)
}
}}
className={cn(
"relative h-11 rounded-sk-btn px-3 text-sm font-medium transition-[color,transform] duration-200 active:scale-[0.97]",
on ? "text-sk-ink" : "text-sk-muted hover:text-sk-ink",
RING
)}
>
{on ? (
<motion.span
layoutId={`${uid}-pill`}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
className="absolute inset-0 rounded-sk-btn border border-sk-line bg-sk-card"
/>
) : null}
<span className="relative">{m.label}</span>
</button>
)
})}
</div>
<div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-tab-${mode}`} className="flex flex-col gap-5 p-5 sm:p-6">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={mode}
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1">
<Price
amount={amount}
compareAt={mode === "subscribe" ? price : undefined}
size="lg"
showSavings
per={servings ? { amount: Math.round(amount / servings), unit: "serving" } : undefined}
/>
<span className="text-[13px] text-sk-muted">
{mode === "subscribe" ? `Billed per delivery, every ${everyDays} days` : "Charged once at checkout"}
</span>
</div>
<ul className="flex flex-col gap-2.5">
{benefits[mode].map((b) => (
<li key={b} className="flex items-start gap-2.5 text-sm text-sk-ink">
<Check size={16} strokeWidth={2.25} className="mt-0.5 shrink-0 text-sk-accent" aria-hidden />
{b}
</li>
))}
</ul>
</motion.div>
</AnimatePresence>
<Button size="lg" className="w-full active:scale-[0.97]" loading={status === "loading"} onClick={add}>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={status === "added" ? "added" : mode}
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 items-center gap-2 tabular-nums"
>
{status === "added" ? (
<>
<Check size={16} strokeWidth={2.5} /> Added to cart
</>
) : mode === "subscribe" ? (
<>Start subscription · {money(amount)}</>
) : (
<>Add to cart · {money(amount)}</>
)}
</motion.span>
</AnimatePresence>
</Button>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-purchase-tabs" and pass your product data. The demo above exercises the full prop surface with sample values.
