Tiered rewards bar

One progress track with three milestones at their real thresholds (free shipping, 10% off, free gift). Markers fill as reached.

componentmeteranyshipping-thresholddiscountgift
$npx shadcn@latest add @storekit/cart-tier-rewards
cart-tier-rewards

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/cart-tier-rewards
$pnpm dlx shadcn@latest add @storekit/cart-tier-rewards
$yarn dlx shadcn@latest add @storekit/cart-tier-rewards
$bunx --bun shadcn@latest add @storekit/cart-tier-rewards

Source

components/storekit/cart/cart-tier-rewards.tsx
114 lines
"use client"

import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check, Gift, Percent, Truck, type LucideIcon } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"

/* Multi-tier reward bar. One track, one evenly spaced milestone per tier
   (so labels always sit under their marker, however close the thresholds
   are). The fill moves through each segment in proportion to the amount
   between tiers. Markers fill as the subtotal passes them and the headline
   always names the next reward and the exact amount left. */

export type RewardTier = { threshold: number; label: string; icon?: "shipping" | "percent" | "gift" }

const ICONS: Record<NonNullable<RewardTier["icon"]>, LucideIcon> = { shipping: Truck, percent: Percent, gift: Gift }

const DEFAULT_TIERS: RewardTier[] = [
  { threshold: 7500, label: "Free shipping", icon: "shipping" },
  { threshold: 12500, label: "10% off", icon: "percent" },
  { threshold: 17500, label: "Free gift", icon: "gift" },
]

export function CartTierRewards({ subtotal = 9800, tiers = DEFAULT_TIERS, className }: { subtotal?: number; tiers?: RewardTier[]; className?: string }) {
  const reduce = useReducedMotion()
  const sorted = React.useMemo(() => [...tiers].sort((a, b) => a.threshold - b.threshold), [tiers])
  const max = sorted[sorted.length - 1]?.threshold ?? 1
  const next = sorted.find((t) => subtotal < t.threshold)
  const reached = sorted.filter((t) => subtotal >= t.threshold).length
  const n = Math.max(1, sorted.length)
  // Piecewise progress: each tier owns an equal share of the track.
  const pct = (() => {
    if (!next) return 1
    const i = sorted.indexOf(next)
    const prev = i > 0 ? sorted[i - 1].threshold : 0
    const span = Math.max(1, next.threshold - prev)
    return Math.min(1, Math.max(0, (i + (subtotal - prev) / span) / n))
  })()

  return (
    <div data-sk-section="cart" data-sk-variant="cart-tier-rewards" className={cn("w-full", className)}>
      <p className="text-sm text-sk-ink" aria-live="polite">
        {next ? (
          <>
            Add <span className="font-semibold tabular-nums">{money(next.threshold - subtotal)}</span> to get <span className="font-semibold">{next.label.toLowerCase()}</span>
          </>
        ) : (
          <span className="font-medium text-sk-savings">All {sorted.length} rewards unlocked</span>
        )}
      </p>

      <div className="relative mt-4 h-8 px-4">
        <div
          className="absolute inset-x-4 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-sk-chip bg-sk-ink/[0.08]"
          role="progressbar"
          aria-label="Progress to cart rewards"
          aria-valuemin={0}
          aria-valuemax={max}
          aria-valuenow={Math.min(subtotal, max)}
          aria-valuetext={`${reached} of ${sorted.length} rewards unlocked`}
        >
          <motion.div
            className="h-full w-full origin-left rounded-sk-chip bg-sk-accent"
            initial={false}
            animate={{ scaleX: pct }}
            transition={{ duration: reduce ? 0 : 0.6, ease: [0.16, 1, 0.3, 1] }}
          />
        </div>
        <div className="absolute inset-x-4 inset-y-0">
          {sorted.map((t, i) => {
            const on = subtotal >= t.threshold
            const Icon = t.icon ? ICONS[t.icon] : Gift
            return (
              <span
                key={t.label}
                className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
                style={{ left: `${((i + 1) / n) * 100}%` }}
              >
                <motion.span
                  initial={false}
                  animate={{ scale: on ? 1 : 0.9 }}
                  transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
                  className={cn(
                    "grid h-8 w-8 place-items-center rounded-sk-chip border-2 transition-colors duration-300",
                    on ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card text-sk-muted"
                  )}
                >
                  {on ? <Check size={14} strokeWidth={2.5} /> : <Icon size={14} />}
                </motion.span>
              </span>
            )
          })}
        </div>
      </div>

      <ol className="mt-3 grid gap-2" style={{ gridTemplateColumns: `repeat(${sorted.length}, minmax(0, 1fr))` }}>
        {sorted.map((t) => {
          const on = subtotal >= t.threshold
          const Icon = t.icon ? ICONS[t.icon] : Gift
          return (
            <li key={t.label} className="flex min-w-0 flex-col items-end text-right">
              <p className={cn("flex items-center gap-1 text-[13px] font-medium leading-tight", on ? "text-sk-ink" : "text-sk-muted")}>
                <Icon size={13} aria-hidden className={cn("shrink-0", on ? "text-sk-accent" : "text-sk-muted")} />
                <span className="min-w-0">{t.label}</span>
              </p>
              <p className="mt-0.5 text-xs tabular-nums text-sk-muted">{on ? "Unlocked" : money(t.threshold)}</p>
            </li>
          )
        })}
      </ol>
    </div>
  )
}

Usage

Import from "@/components/storekit/cart/cart-tier-rewards" and pass your product data. The demo above exercises the full prop surface with sample values.

Tiered rewards bar, StoreKit