Mix and match builder
Pick a pack size, spread units across flavors with steppers, watch the meter fill and add when complete.
blockmix-matchbeveragesupplementsbundleatc
$
npx shadcn@latest add @storekit/upsell-mix-matchupsell-mix-match
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-mix-match$
pnpm dlx shadcn@latest add @storekit/upsell-mix-match$
yarn dlx shadcn@latest add @storekit/upsell-mix-match$
bunx --bun shadcn@latest add @storekit/upsell-mix-matchSource
components/storekit/upsell/upsell-mix-match.tsx
240 lines
"use client"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Shuffle } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Stepper } from "@/components/storekit/ui/stepper"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Mix and match builder. Pick a pack size, then spread that many units
across flavors with steppers. A meter tracks progress to the required
count, the discount and total update live, and the add button unlocks
only when the pack is full. 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 Flavor = { id: string; name: string; note?: string; soldOut?: boolean }
type Pack = { size: number; discount: number }
const DEFAULT_FLAVORS: Flavor[] = [
{ id: "citrus", name: "Citrus Salt", note: "Lemon, lime, sea salt" },
{ id: "berry", name: "Wild Berry", note: "Blackberry, raspberry" },
{ id: "mango", name: "Mango Chili", note: "Ripe mango, mild heat" },
{ id: "plain", name: "Unflavored", note: "No sweetener" },
]
const DEFAULT_PACKS: Pack[] = [
{ size: 6, discount: 0.1 },
{ size: 12, discount: 0.15 },
]
export function UpsellMixMatch({
unitPrice = 450,
unitLabel = "stick",
flavors = DEFAULT_FLAVORS,
packs = DEFAULT_PACKS,
onAddToCart,
className,
}: {
/** Price of one unit in integer cents before the pack discount. */
unitPrice?: number
unitLabel?: string
flavors?: Flavor[]
packs?: Pack[]
onAddToCart?: (v: { pack: number; picks: Record<string, number>; total: number }) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("upsell", "upsell-mix-match")
const [packIdx, setPackIdx] = useState(0)
const [picks, setPicks] = useState<Record<string, number>>({})
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const packRefs = useRef<(HTMLButtonElement | null)[]>([])
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const pack = packs[packIdx] ?? packs[0]
const count = Object.values(picks).reduce((a, b) => a + b, 0)
const left = pack.size - count
const complete = left === 0
const full = unitPrice * pack.size
const total = Math.round(full * (1 - pack.discount))
const pct = Math.round(pack.discount * 100)
const choosePack = (i: number, focus = false) => {
if (i === packIdx) return
sk.emit("interact", `pack_${packs[i].size}`, undefined)
setPackIdx(i)
// Trim picks that no longer fit, removing from the last flavor first.
setPicks((prev) => {
const next = { ...prev }
let over = Object.values(next).reduce((a, b) => a + b, 0) - packs[i].size
for (const f of [...flavors].reverse()) {
if (over <= 0) break
const take = Math.min(next[f.id] ?? 0, over)
next[f.id] = (next[f.id] ?? 0) - take
over -= take
}
return next
})
if (focus) packRefs.current[i]?.focus()
}
const setFlavor = (id: string, n: number) => {
sk.emit("interact", `flavor_${id}`, n)
setPicks((prev) => ({ ...prev, [id]: n }))
}
const fillEvenly = () => {
const open = flavors.filter((f) => !f.soldOut)
if (!open.length) return
sk.emit("interact", "fill_evenly", undefined)
const next: Record<string, number> = {}
open.forEach((f, i) => {
next[f.id] = Math.floor(pack.size / open.length) + (i < pack.size % open.length ? 1 : 0)
})
setPicks(next)
}
const add = () => {
if (!complete || status !== "idle") return
setStatus("loading")
sk.emit("convert", "add_to_cart", total)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onAddToCart?.({ pack: pack.size, picks, total })
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-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)}>
<div className="flex flex-col gap-2">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Build your pack</span>
<div role="radiogroup" aria-label="Pack size" className="grid grid-cols-2 gap-2">
{packs.map((p, i) => {
const on = i === packIdx
return (
<button
key={p.size}
ref={(el) => {
packRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => choosePack(i)}
onKeyDown={(e) => {
const f = e.key === "ArrowRight" || e.key === "ArrowDown"
const b = e.key === "ArrowLeft" || e.key === "ArrowUp"
if (!f && !b) return
e.preventDefault()
choosePack((packIdx + (f ? 1 : -1) + packs.length) % packs.length, true)
}}
className={cn(
"flex h-14 flex-col items-center justify-center rounded-sk-btn border tabular-nums transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]",
on ? "border-sk-accent bg-sk-accent/[0.08]" : "border-sk-line hover:border-sk-ink/30",
RING
)}
>
<span className="text-sm font-medium text-sk-ink">
{p.size} {unitLabel}s
</span>
<span className="text-[12px] font-medium text-sk-savings">Save {Math.round(p.discount * 100)}%</span>
</button>
)
})}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3 text-sm" aria-live="polite">
<span className="font-medium text-sk-ink tabular-nums">
{count} of {pack.size} picked
</span>
<span className="text-[13px] text-sk-muted tabular-nums">{complete ? "Pack complete" : `${left} to go`}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-sk-surface" role="progressbar" aria-valuemin={0} aria-valuemax={pack.size} aria-valuenow={count} aria-label="Pack progress">
<motion.div
initial={false}
animate={{ scaleX: pack.size ? count / pack.size : 0 }}
transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}
className={cn("h-full w-full origin-left rounded-full transition-colors duration-300", complete ? "bg-sk-savings" : "bg-sk-accent")}
/>
</div>
</div>
<ul className="flex flex-col divide-y divide-sk-line/70 border-y border-sk-line/70">
{flavors.map((f) => {
const n = picks[f.id] ?? 0
return (
<li key={f.id} className={cn("flex flex-wrap items-center gap-x-3 gap-y-2 py-3", f.soldOut && "opacity-60")}>
<span aria-hidden className="grid h-10 w-10 shrink-0 place-items-center rounded-sk-btn bg-sk-surface font-sk-display text-sm font-semibold text-sk-ink">
{f.name.slice(0, 1)}
</span>
<span className="flex min-w-[8rem] flex-1 basis-0 flex-col">
<span className="text-sm font-medium leading-[1.35] text-sk-ink">{f.name}</span>
<span className="text-[13px] leading-[1.35] text-sk-muted">{f.soldOut ? "Sold out" : f.note}</span>
</span>
{f.soldOut ? null : (
<Stepper size="sm" label={`${f.name} quantity`} value={n} min={0} max={n + left} onChange={(v) => setFlavor(f.id, v)} className="ml-auto" />
)}
</li>
)
})}
</ul>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
<div className="flex min-w-0 flex-col gap-0.5 tabular-nums">
<span className="text-[13px] text-sk-muted">
{pct}% off, {money(Math.round(total / pack.size))} / {unitLabel}
</span>
<span className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-sk-price">{money(total)}</span>
<s className="text-sm text-sk-muted">{money(full)}</s>
</span>
</div>
<button
type="button"
onClick={fillEvenly}
className={cn("inline-flex h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-sk-btn px-3 text-[13px] font-medium text-sk-muted transition-[color,background-color,transform] duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink active:scale-[0.97]", RING)}
>
<Shuffle size={14} aria-hidden /> Fill evenly
</button>
</div>
<Button size="lg" className="w-full active:scale-[0.97]" disabled={!complete} loading={status === "loading"} onClick={add}>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={status === "added" ? "added" : complete ? "ready" : "wait"}
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
</>
) : complete ? (
<>Add pack of {pack.size} · {money(total)}</>
) : (
<>
Pick {left} more to add
</>
)}
</motion.span>
</AnimatePresence>
</Button>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-mix-match" and pass your product data. The demo above exercises the full prop surface with sample values.
