A build-a-bundle picker with a tier-progress bar and a running total that tweens as discount tiers unlock.
npx shadcn@latest add @paragon/bundle-builder"use client";
import * as React from "react";
import {
AnimatePresence,
LayoutGroup,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { Check, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface BundleProduct {
id: string;
name: string;
price: number;
hue?: number;
}
export interface BundleTier {
/** Minimum item count to unlock this tier. */
min: number;
/** Discount fraction, 0–1. */
discount: number;
label: string;
}
export interface BundleBuilderProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
products: BundleProduct[];
tiers: BundleTier[];
currency?: string;
locale?: Intl.LocalesArgument;
onChange?: (ids: string[]) => void;
/** Disables press-scale and enter motion. */
static?: boolean;
}
function placeholder(hue: number) {
return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}
/**
* A build-a-bundle picker. Toggle products in; a progress bar fills toward the
* next discount tier and the running total + savings count via number-ticker
* as the tier changes. Selected products flow into a summary list that animates
* on add/remove. Money is tabular. Reduced motion drops the bar and count
* tweens.
*/
export function BundleBuilder({
products,
tiers,
currency = "USD",
locale = "en-US",
onChange,
static: isStatic = false,
className,
...props
}: BundleBuilderProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { once: true, margin: "0px 0px -32px 0px" });
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const animate = !isStatic && !reduced;
const revealed = !animate || inView;
const format = React.useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency }),
[locale, currency],
);
const currencyOptions = React.useMemo<Intl.NumberFormatOptions>(
() => ({ style: "currency", currency }),
[currency],
);
const sortedTiers = React.useMemo(
() => [...tiers].sort((a, b) => a.min - b.min),
[tiers],
);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange?.(Array.from(next));
return next;
});
};
const count = selected.size;
const subtotal = products
.filter((p) => selected.has(p.id))
.reduce((s, p) => s + p.price, 0);
const activeTier = [...sortedTiers].reverse().find((t) => count >= t.min);
const nextTier = sortedTiers.find((t) => count < t.min);
const discount = activeTier?.discount ?? 0;
const total = subtotal * (1 - discount);
const saved = subtotal - total;
const maxMin = sortedTiers[sortedTiers.length - 1]?.min ?? 1;
const progress = Math.min(1, count / maxMin);
return (
<TooltipProvider>
<motion.div
ref={rootRef}
initial={false}
animate={
revealed
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 12, filter: "blur(4px)" }
}
transition={
animate
? { duration: 0.4, ease: [0.22, 1, 0.36, 1] }
: { duration: 0 }
}
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 shadow-border",
className,
)}
{...(props as React.ComponentProps<typeof motion.div>)}
>
{/* Tier progress */}
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate font-medium text-card-foreground">
{activeTier ? activeTier.label : "Add items to unlock a discount"}
</span>
{nextTier && (
<span className="shrink-0 tabular-nums text-muted-foreground">
{nextTier.min - count} more for {Math.round(nextTier.discount * 100)}
% off
</span>
)}
</div>
<div
role="progressbar"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-2 overflow-hidden rounded-full bg-secondary"
>
<motion.span
className="block h-full rounded-full bg-foreground"
initial={false}
animate={{ scaleX: progress }}
transition={
reduced ? { duration: 0 } : { type: "spring", duration: 0.4, bounce: 0 }
}
style={{ transformOrigin: "left", width: "100%" }}
/>
{sortedTiers.map((t) => {
const unlocked = count >= t.min;
return (
<span
key={t.min}
aria-hidden
className={cn(
"absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200 ease-out",
unlocked ? "bg-background" : "bg-background/70",
)}
style={{ left: `${(t.min / maxMin) * 100}%` }}
/>
);
})}
</div>
{/* Products */}
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{products.map((p, i) => {
const on = selected.has(p.id);
return (
<Tooltip key={p.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={on}
onClick={() => toggle(p.id)}
className={cn(
"group relative overflow-hidden rounded-lg text-left outline-none transition-[box-shadow,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
!isStatic && "active:not-disabled:scale-[0.97]",
on
? "shadow-[0_0_0_2px_var(--color-foreground)]"
: "shadow-border hover:shadow-border-hover",
)}
>
<span
aria-hidden
className="block aspect-square w-full"
style={{ background: placeholder(p.hue ?? i * 47) }}
/>
<span
aria-hidden
className={cn(
"absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full transition-[background-color,color,scale] duration-150 ease-out",
on
? "scale-100 bg-foreground text-background"
: "scale-90 bg-background/80 text-muted-foreground backdrop-blur-sm",
)}
>
{on ? (
<Check className="size-3.5" />
) : (
<Plus className="size-3.5" />
)}
</span>
<span className="block p-2">
<span className="block truncate text-xs font-medium text-card-foreground">
{p.name}
</span>
<span className="block text-xs tabular-nums text-muted-foreground">
{format.format(p.price)}
</span>
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{on ? "Remove from bundle" : "Add to bundle"} · {p.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
{/* Selected list */}
<LayoutGroup>
<motion.ul layout={animate} className="mt-3 flex flex-col">
<AnimatePresence initial={false} mode="popLayout">
{products
.filter((p) => selected.has(p.id))
.map((p) => (
<motion.li
key={p.id}
layout={animate}
initial={
animate ? { opacity: 0, height: 0, filter: "blur(4px)" } : false
}
animate={{ opacity: 1, height: "auto", filter: "blur(0px)" }}
exit={
animate
? { opacity: 0, height: 0, filter: "blur(4px)" }
: { opacity: 0 }
}
transition={
animate ? { duration: 0.22, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
}
className="flex items-center gap-2 overflow-hidden text-xs"
>
<span className="flex items-center gap-2 py-0.5">
<span
aria-hidden
className="size-3.5 shrink-0 rounded-[3px]"
style={{ background: placeholder(p.hue ?? 0) }}
/>
<span className="min-w-0 truncate text-muted-foreground">
{p.name}
</span>
</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">
{format.format(p.price)}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
</LayoutGroup>
{/* Summary */}
<div className="mt-3 flex items-end justify-between gap-3 border-t border-border pt-3">
<div className="min-w-0">
<p className="text-xs tabular-nums text-muted-foreground">
{count} {count === 1 ? "item" : "items"} in bundle
</p>
{saved > 0 && (
<p className="flex items-center gap-1 text-xs font-medium text-success tabular-nums">
You save{" "}
<NumberTicker
value={saved}
duration={0.35}
locale={locale}
formatOptions={currencyOptions}
static={isStatic || !!reduced}
/>
</p>
)}
</div>
<div className="shrink-0 text-right">
{discount > 0 && (
<span className="mr-1.5 text-sm text-muted-foreground line-through tabular-nums">
{format.format(subtotal)}
</span>
)}
<NumberTicker
value={total}
duration={0.35}
locale={locale}
formatOptions={currencyOptions}
static={isStatic || !!reduced}
className="text-lg font-semibold text-card-foreground"
/>
</div>
</div>
</motion.div>
</TooltipProvider>
);
}