E-commerce

Bundle Builder

A build-a-bundle picker with a tier-progress bar and a running total that tweens as discount tiers unlock.

Install

npx shadcn@latest add @paragon/bundle-builder

bundle-builder.tsx

"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>
  );
}