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

Source

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.

Bundle tiers, StoreKit