Bundle tiers
Buy 1, 3 or 5 tiles with per unit price, total and savings. The selection frame glides between tiles.
componentbundleanybundlequantity
$
npx shadcn@latest add @storekit/pdp-bundle-tierspdp-bundle-tiers
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/pdp-bundle-tiers$
pnpm dlx shadcn@latest add @storekit/pdp-bundle-tiers$
yarn dlx shadcn@latest add @storekit/pdp-bundle-tiers$
bunx --bun shadcn@latest add @storekit/pdp-bundle-tiersSource
components/storekit/pdp/pdp-bundle-tiers.tsx
130 lines
"use client"
import { useId, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Quantity bundle tiles for inside a buy box. The accent frame travels to
the chosen tile with a shared layoutId. Each tile shows per unit, total and
what you save against buying singles. */
type Tier = { qty: number; discount: number; label?: string; popular?: boolean }
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_TIERS: Tier[] = [
{ qty: 1, discount: 0 },
{ qty: 3, discount: 0.1, popular: true },
{ qty: 5, discount: 0.18 },
]
export function PdpBundleTiers({
unitPrice = 5900,
tiers = DEFAULT_TIERS,
defaultQty,
onChange,
className,
}: {
unitPrice?: number
tiers?: Tier[]
defaultQty?: number
onChange?: (tier: Tier) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-bundle-tiers")
const [qty, setQty] = useState(defaultQty ?? tiers.find((t) => t.popular)?.qty ?? tiers[0]?.qty)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const current = Math.max(0, tiers.findIndex((t) => t.qty === qty))
const select = (i: number, focus = false) => {
const t = tiers[i]
if (t.qty !== qty) sk.emit("interact", "bundle_tier", Math.round(unitPrice * (1 - t.discount)) * t.qty)
setQty(t.qty)
onChange?.(t)
if (focus) refs.current[i]?.focus()
}
const onKeyDown = (e: React.KeyboardEvent) => {
const n = tiers.length
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault()
select((current + 1) % n, true)
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault()
select((current - 1 + n) % n, true)
}
}
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} role="radiogroup" aria-label="Bundle size" className={cn("grid w-full grid-cols-3 gap-2 pt-3", className)}>
{tiers.map((t, i) => {
const selected = t.qty === qty
const each = Math.round(unitPrice * (1 - t.discount))
const total = each * t.qty
const saved = unitPrice * t.qty - total
return (
<button
key={t.qty}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => select(i)}
onKeyDown={onKeyDown}
className={cn(
"relative flex min-h-[44px] flex-col items-center gap-1 rounded-sk-card border border-sk-line bg-sk-card px-1.5 pb-3 pt-4 text-center sm:px-2 transition-[border-color,transform] duration-200 hover:border-sk-ink/30 active:scale-[0.97]",
RING
)}
>
{selected ? (
<motion.span
layoutId={`${uid}-frame`}
aria-hidden
transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}
className="pointer-events-none absolute -inset-px rounded-sk-card border-2 border-sk-accent bg-sk-accent/[0.05]"
/>
) : null}
{t.popular ? (
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-sk-chip bg-sk-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-sk-accent-ink">
<span className="sm:hidden">Popular</span>
<span className="hidden sm:inline">Most popular</span>
</span>
) : t.discount > 0 ? (
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-sk-chip border border-sk-savings/40 bg-sk-card px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-sk-savings">
Save {Math.round(t.discount * 100)}%
</span>
) : null}
<span className="relative text-sm font-medium text-sk-ink">{t.label ?? `Buy ${t.qty}`}</span>
<span className="relative text-[15px] font-semibold tabular-nums text-sk-price sm:text-base">{money(each)}</span>
<span className="relative font-sk-mono text-[11px] tabular-nums text-sk-muted">each</span>
<span className="relative mt-1 flex flex-wrap justify-center gap-x-1 text-[11px] leading-tight tabular-nums text-sk-muted sm:text-[12px]">
<span className="whitespace-nowrap">{money(total)}</span>
<span>total</span>
</span>
<span className={cn("relative flex flex-wrap justify-center gap-x-1 text-[11px] font-medium leading-tight tabular-nums sm:text-[12px]", saved > 0 ? "text-sk-savings" : "text-sk-muted")}>
{saved > 0 ? (
<>
<span>Save</span>
<span className="whitespace-nowrap">{money(saved)}</span>
</>
) : (
<>
<span>Single</span>
<span>unit</span>
</>
)}
</span>
</button>
)
})}
</div>
)
}
Usage
Import from "@/components/storekit/pdp/pdp-bundle-tiers" and pass your product data. The demo above exercises the full prop surface with sample values.
