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

Source

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.

One-time or subscribe tabs, StoreKit