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-tiers
upsell-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-tiers

Source

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.

Bulk rate table, StoreKit