Subscribe and save
One-time vs subscribe radio cards, frequency chips and a benefits list that opens on subscribe.
componentsubscribeanysubscribe
$
npx shadcn@latest add @storekit/upsell-subscribeupsell-subscribe
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-subscribe$
pnpm dlx shadcn@latest add @storekit/upsell-subscribe$
yarn dlx shadcn@latest add @storekit/upsell-subscribe$
bunx --bun shadcn@latest add @storekit/upsell-subscribeSource
components/storekit/upsell/upsell-subscribe.tsx
173 lines
"use client"
import { useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { CalendarClock, PackageCheck, Percent, Truck } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Standalone subscribe and save selector. Two radio cards, delivery
frequency chips, and a benefits list that opens when subscribe is picked.
Always shows the per-delivery price. */
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_BENEFITS = [
{ icon: Percent, label: "15% off every delivery" },
{ icon: Truck, label: "Free shipping on every order" },
{ icon: CalendarClock, label: "Skip, swap or pause from your account" },
{ icon: PackageCheck, label: "Fresh batch each shipment, third-party tested" },
]
function arrows(e: React.KeyboardEvent, n: number, current: number, pick: (i: number) => void) {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault()
pick((current + 1) % n)
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault()
pick((current - 1 + n) % n)
}
}
export function UpsellSubscribe({
price = 5900,
discount = 0.15,
frequencies = [30, 60, 90],
benefits = DEFAULT_BENEFITS,
defaultSubscribe = true,
onChange,
className,
}: {
price?: number
discount?: number
frequencies?: number[]
benefits?: { icon: React.ComponentType<{ size?: number; className?: string }>; label: string }[]
defaultSubscribe?: boolean
onChange?: (v: { subscribe: boolean; everyDays: number; price: number }) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("upsell", "upsell-subscribe")
const [subscribe, setSubscribe] = useState(defaultSubscribe)
const [every, setEvery] = useState(frequencies[0] ?? 30)
const planRefs = useRef<(HTMLButtonElement | null)[]>([])
const freqRefs = useRef<(HTMLButtonElement | null)[]>([])
const subPrice = Math.round(price * (1 - discount))
const pct = Math.round(discount * 100)
const t = { duration: reduce ? 0 : 0.35, ease: EASE }
const setPlan = (sub: boolean, focus = false) => {
if (sub !== subscribe) sk.emit("interact", sub ? "subscribe" : "one_time", sub ? subPrice : price)
setSubscribe(sub)
onChange?.({ subscribe: sub, everyDays: every, price: sub ? subPrice : price })
if (focus) planRefs.current[sub ? 1 : 0]?.focus()
}
const setFreq = (i: number, focus = false) => {
if (frequencies[i] !== every) sk.emit("interact", `every_${frequencies[i]}d`, undefined)
setEvery(frequencies[i])
onChange?.({ subscribe, everyDays: frequencies[i], price: subscribe ? subPrice : price })
if (focus) freqRefs.current[i]?.focus()
}
const plans = [
{ sub: false, title: "One-time purchase", note: "Ships once", amount: price },
{ sub: true, title: `Subscribe and save ${pct}%`, note: `${money(subPrice)} per delivery`, amount: subPrice },
]
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
<div role="radiogroup" aria-label="Purchase option" className="grid gap-2.5">
{plans.map((p, i) => {
const on = subscribe === p.sub
return (
<button
key={p.title}
ref={(el) => {
planRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setPlan(p.sub)}
onKeyDown={(e) => arrows(e, 2, subscribe ? 1 : 0, (n) => setPlan(n === 1, true))}
className={cn(
"flex min-h-[72px] items-start gap-3 rounded-sk-card border p-4 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]",
on ? "border-sk-accent bg-sk-accent/[0.05]" : "border-sk-line bg-sk-card hover:border-sk-ink/30",
RING
)}
>
<span className={cn("mt-0.5 grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border", on ? "border-sk-accent" : "border-sk-line")}>
<motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.2, 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 gap-0.5">
<span className="text-sm font-medium text-sk-ink">{p.title}</span>
<span className="text-[13px] text-sk-muted">{p.note}</span>
</span>
<span className="flex shrink-0 flex-col items-end tabular-nums">
<span className="text-sm font-semibold text-sk-price">{money(p.amount)}</span>
{p.sub ? <s className="text-[12px] text-sk-muted">{money(price)}</s> : null}
</span>
</button>
)
})}
</div>
<AnimatePresence initial={false}>
{subscribe ? (
<motion.div key="sub" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={t} className="overflow-hidden">
<div className="flex flex-col gap-4 rounded-sk-card bg-sk-surface p-4">
<div className="flex flex-col gap-2">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Deliver every</span>
<div role="radiogroup" aria-label="Delivery frequency" className="grid grid-cols-3 gap-2 sm:flex sm:flex-wrap">
{frequencies.map((f, i) => {
const on = f === every
return (
<button
key={f}
ref={(el) => {
freqRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setFreq(i)}
onKeyDown={(e) => arrows(e, frequencies.length, frequencies.indexOf(every), (n) => setFreq(n, true))}
className={cn(
"h-11 whitespace-nowrap rounded-sk-chip border px-3 text-sm font-medium tabular-nums sm:px-4 transition-[border-color,background-color,color,transform] duration-200 active:scale-[0.97]",
on ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card text-sk-ink hover:border-sk-ink/30",
RING
)}
>
{f} days
</button>
)
})}
</div>
</div>
<ul className="grid gap-2.5 sm:grid-cols-2">
{benefits.map(({ icon: Icon, label }, i) => (
<motion.li
key={label}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE, delay: reduce ? 0 : 0.08 + i * 0.05 }}
className="flex items-center gap-2.5 text-[13px] text-sk-ink"
>
<Icon size={15} className="shrink-0 text-sk-accent" />
{label}
</motion.li>
))}
</ul>
</div>
</motion.div>
) : null}
</AnimatePresence>
<p className="text-[12px] text-sk-muted">Cancel anytime. No fees, no minimum term.</p>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-subscribe" and pass your product data. The demo above exercises the full prop surface with sample values.
