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-rewardscart-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-rewardsSource
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.
