Single vs best value tabs

A single unit and a multipack as two tabs, with the per-unit price and saving shown on the pack.

blockbest-value-tabsanybundleatc
$npx shadcn@latest add @storekit/upsell-best-value-tabs
upsell-best-value-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-best-value-tabs
$pnpm dlx shadcn@latest add @storekit/upsell-best-value-tabs
$yarn dlx shadcn@latest add @storekit/upsell-best-value-tabs
$bunx --bun shadcn@latest add @storekit/upsell-best-value-tabs

Source

components/storekit/upsell/upsell-best-value-tabs.tsx
177 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"

/* Single vs best value as two tabs. The best value tab is the multipack:
   its tab label carries the saving, its panel shows the per-unit price next
   to the single price so the maths is visible. 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 Offer = { id: string; tab: string; units: number; price: number; points: string[] }

export function UpsellBestValueTabs({
  title = "Daily Greens",
  image,
  unitPrice = 4900,
  packUnits = 3,
  packPrice,
  defaultTab = "pack",
  onAddToCart,
  className,
}: {
  title?: string
  image?: string
  /** Price of one unit in integer cents. */
  unitPrice?: number
  packUnits?: number
  /** Multipack price in integer cents. Defaults to 20% off the unit total. */
  packPrice?: number
  defaultTab?: "single" | "pack"
  onAddToCart?: (v: { offerId: string; units: number; price: number }) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const uid = useId()
  const sk = useSkBlock("upsell", "upsell-best-value-tabs")
  const pack = packPrice ?? Math.round(unitPrice * packUnits * 0.8)
  const fullPack = unitPrice * packUnits
  const pct = Math.round((1 - pack / fullPack) * 100)
  const offers: Offer[] = [
    { id: "single", tab: "Single", units: 1, price: unitPrice, points: [`One ${title}`, `30 servings, ${money(Math.round(unitPrice / 30))} each`, "Free shipping over $50"] },
    { id: "pack", tab: "Best value", units: packUnits, price: pack, points: [`${packUnits} x ${title}, shipped together`, `${money(fullPack - pack)} less than buying separately`, "Free shipping on this order"] },
  ]
  const [tab, setTab] = useState(defaultTab === "single" ? 0 : 1)
  const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
  const refs = useRef<(HTMLButtonElement | null)[]>([])
  const timers = useRef<number[]>([])
  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
  const offer = offers[tab]

  const pick = (i: number, focus = false) => {
    if (i !== tab) sk.emit("interact", offers[i].id, offers[i].price)
    setTab(i)
    if (focus) refs.current[i]?.focus()
  }

  const add = () => {
    if (status !== "idle") return
    setStatus("loading")
    sk.emit("convert", "add_to_cart", offer.price)
    timers.current.push(
      window.setTimeout(() => {
        setStatus("added")
        onAddToCart?.({ offerId: offer.id, units: offer.units, price: offer.price })
        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-4", className)}>
      <div role="tablist" aria-label="Pack size" className="grid grid-cols-2 gap-1 rounded-sk-card border border-sk-line bg-sk-surface p-1">
        {offers.map((o, i) => {
          const on = i === tab
          return (
            <button
              key={o.id}
              ref={(el) => {
                refs.current[i] = el
              }}
              id={`${uid}-tab-${o.id}`}
              type="button"
              role="tab"
              aria-selected={on}
              aria-controls={`${uid}-panel`}
              tabIndex={on ? 0 : -1}
              onClick={() => pick(i)}
              onKeyDown={(e) => {
                if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
                  e.preventDefault()
                  pick(tab === 0 ? 1 : 0, true)
                }
              }}
              className={cn("relative flex min-h-[52px] flex-col items-center justify-center rounded-sk-btn px-3 py-2 transition-transform duration-200 active:scale-[0.97]", 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={cn("relative text-sm font-medium transition-colors duration-200", on ? "text-sk-ink" : "text-sk-muted")}>{o.tab}</span>
              <span className={cn("relative text-[12px] tabular-nums", o.id === "pack" ? "font-semibold text-sk-savings" : "text-sk-muted")}>
                {o.id === "pack" ? `${o.units} pack, save ${pct}%` : "1 unit"}
              </span>
            </button>
          )
        })}
      </div>

      <div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-tab-${offer.id}`} className="flex flex-col gap-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6">
        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={offer.id}
            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 gap-4"
          >
            {image ? (
              <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-sk-btn bg-sk-surface">
                <img src={image} alt="" className="h-full w-full object-cover" />
                {offer.units > 1 ? (
                  <span className="absolute bottom-1 right-1 rounded-sk-chip bg-sk-ink px-1.5 py-0.5 text-[11px] font-semibold text-sk-paper tabular-nums">x{offer.units}</span>
                ) : null}
              </div>
            ) : null}
            <div className="flex min-w-0 flex-1 flex-col gap-3">
              <Price
                amount={offer.price}
                compareAt={offer.units > 1 ? fullPack : undefined}
                size="lg"
                per={offer.units > 1 ? { amount: Math.round(offer.price / offer.units), unit: "each" } : undefined}
              />
              <ul className="flex flex-col gap-2">
                {offer.points.map((p) => (
                  <li key={p} className="flex items-start gap-2 text-sm text-sk-ink tabular-nums">
                    <Check size={15} strokeWidth={2.25} className="mt-0.5 shrink-0 text-sk-accent" aria-hidden />
                    {p}
                  </li>
                ))}
              </ul>
            </div>
          </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" : offer.id}
              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
                </>
              ) : (
                <>
                  Add {offer.units > 1 ? `${offer.units} pack` : "to cart"} · {money(offer.price)}
                </>
              )}
            </motion.span>
          </AnimatePresence>
        </Button>
      </div>
    </div>
  )
}

Usage

Import from "@/components/storekit/upsell/upsell-best-value-tabs" and pass your product data. The demo above exercises the full prop surface with sample values.

Single vs best value tabs, StoreKit