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-subscribe
upsell-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-subscribe

Source

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.