Bulk rate table
Stock-up pricing by quantity band. The matching row highlights as the stepper changes.
componentbulksupplementswellnessquantitybundle
$
npx shadcn@latest add @storekit/upsell-bulk-tiersupsell-bulk-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/upsell-bulk-tiers$
pnpm dlx shadcn@latest add @storekit/upsell-bulk-tiers$
yarn dlx shadcn@latest add @storekit/upsell-bulk-tiers$
bunx --bun shadcn@latest add @storekit/upsell-bulk-tiersSource
components/storekit/upsell/upsell-bulk-tiers.tsx
109 lines
"use client"
import { useId, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Stepper } from "@/components/storekit/ui/stepper"
/* Stock-up pricing. The row that matches the current quantity is
highlighted by a bar that slides with a shared layoutId. Clicking a row
jumps the quantity to that tier. */
type Tier = { min: number; max?: number; discount: number }
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-inset"
const DEFAULT_TIERS: Tier[] = [
{ min: 1, max: 4, discount: 0 },
{ min: 5, max: 9, discount: 0.08 },
{ min: 10, max: 24, discount: 0.15 },
{ min: 25, discount: 0.22 },
]
export function UpsellBulkTiers({
unitPrice = 5900,
tiers = DEFAULT_TIERS,
defaultQty = 6,
title = "Stock up and save",
onChange,
className,
}: {
unitPrice?: number
tiers?: Tier[]
defaultQty?: number
title?: string
onChange?: (qty: number, unit: number) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const [qty, setQty] = useState(defaultQty)
const activeIndex = tiers.findIndex((t) => qty >= t.min && (t.max === undefined || qty <= t.max))
const active = tiers[activeIndex] ?? tiers[0]
const unit = Math.round(unitPrice * (1 - active.discount))
const update = (n: number) => {
setQty(n)
const t = tiers.find((x) => n >= x.min && (x.max === undefined || n <= x.max)) ?? tiers[0]
onChange?.(n, Math.round(unitPrice * (1 - t.discount)))
}
return (
<div data-sk-section="upsell" data-sk-variant="upsell-bulk-tiers" className={cn("w-full rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)}>
<div className="flex items-baseline justify-between gap-4">
<h3 className="min-w-0 font-sk-display text-lg font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink">{title}</h3>
<span className="shrink-0 whitespace-nowrap font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Per unit</span>
</div>
<ul className="mt-4 flex flex-col" aria-label="Bulk price tiers">
<li className="grid grid-cols-[1fr_auto_auto] gap-3 px-3 pb-2 font-sk-mono text-[11px] uppercase tracking-[0.08em] text-sk-muted" aria-hidden>
<span>Units</span>
<span className="w-20 text-right">Price</span>
<span className="w-12 text-right">Save</span>
</li>
{tiers.map((t, i) => {
const on = i === activeIndex
const price = Math.round(unitPrice * (1 - t.discount))
return (
<li key={t.min}>
<button
type="button"
aria-pressed={on}
onClick={() => update(t.min)}
className={cn("relative grid min-h-[44px] w-full grid-cols-[1fr_auto_auto] items-center gap-4 rounded-sk-btn px-3 text-left text-sm transition-colors duration-200 hover:bg-sk-ink/[0.03]", RING)}
>
{on ? (
<motion.span
layoutId={`${uid}-bar`}
aria-hidden
transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}
className="absolute inset-0 rounded-sk-btn border border-sk-accent/40 bg-sk-accent/[0.08]"
/>
) : null}
<span className={cn("relative whitespace-nowrap font-medium tabular-nums", on ? "text-sk-ink" : "text-sk-muted")}>
{t.max === undefined ? `${t.min}+` : `${t.min} to ${t.max}`}
</span>
<span className="relative w-20 text-right font-semibold tabular-nums text-sk-price">{money(price)}</span>
<span className={cn("relative w-12 text-right font-sk-mono text-[12px] tabular-nums", t.discount > 0 ? "text-sk-savings" : "text-sk-muted")}>
{t.discount > 0 ? `${Math.round(t.discount * 100)}%` : "None"}
</span>
</button>
</li>
)
})}
</ul>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 border-t border-sk-line pt-5">
<Stepper value={qty} onChange={update} max={200} />
<div className="flex flex-col items-end" aria-live="polite">
<span className="text-[13px] tabular-nums text-sk-muted">
{qty} x {money(unit)}
</span>
<span className="text-xl font-semibold tabular-nums text-sk-price">{money(unit * qty)}</span>
</div>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-bulk-tiers" and pass your product data. The demo above exercises the full prop surface with sample values.