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-tabsupsell-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-tabsSource
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.
