E-commerce

Add to Cart Button

A button that morphs in place into an inline quantity stepper, flashes a success tick, and ticks a cart badge.

Install

npx shadcn@latest add @paragon/add-to-cart-button

Also installs: quantity-stepper

add-to-cart-button.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Plus, ShoppingBag } from "lucide-react";
import { cn } from "@/lib/utils";
import { QuantityStepper } from "@/registry/paragon/ui/quantity-stepper";

export interface AddToCartButtonProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** Controlled cart quantity for this line. 0 = not in cart. */
  quantity?: number;
  /** Initial quantity when uncontrolled. */
  defaultQuantity?: number;
  onQuantityChange?: (quantity: number) => void;
  /** Label for the pre-add state. */
  label?: string;
  max?: number;
  disabled?: boolean;
  /** Disables the press-scale and morph motion. */
  static?: boolean;
}

/**
 * A button that morphs into an inline quantity stepper in place. The first
 * add flashes a success tick, then the control cross-fades to the stepper at
 * the same width so nothing jumps. Dropping to zero morphs back to the button.
 * The morph is a spring; reduced motion swaps states without movement.
 */
export function AddToCartButton({
  quantity: quantityProp,
  defaultQuantity = 0,
  onQuantityChange,
  label = "Add to cart",
  max = 10,
  disabled = false,
  static: isStatic = false,
  className,
  ...props
}: AddToCartButtonProps) {
  const reduced = useReducedMotion() ?? false;
  const [uncontrolled, setUncontrolled] = React.useState(defaultQuantity);
  const quantity = quantityProp ?? uncontrolled;
  const [ticked, setTicked] = React.useState(false);
  const tickTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  const inCart = quantity > 0;

  React.useEffect(
    () => () => {
      if (tickTimer.current) clearTimeout(tickTimer.current);
    },
    [],
  );

  const setQuantity = (next: number) => {
    if (quantityProp === undefined) setUncontrolled(next);
    onQuantityChange?.(next);
  };

  const add = () => {
    setQuantity(1);
    if (!isStatic && !reduced) {
      setTicked(true);
      if (tickTimer.current) clearTimeout(tickTimer.current);
      tickTimer.current = setTimeout(() => setTicked(false), 850);
    }
  };

  const springOrNone = isStatic || reduced
    ? { duration: 0 }
    : { type: "spring" as const, duration: 0.35, bounce: 0 };

  return (
    <div
      className={cn("relative inline-flex h-9 w-44 items-stretch", className)}
      {...props}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {!inCart ? (
          <motion.button
            key="add"
            type="button"
            disabled={disabled}
            onClick={add}
            initial={{ opacity: 0, filter: "blur(4px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, filter: "blur(4px)" }}
            transition={springOrNone}
            className={cn(
              "inline-flex h-9 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-[background-color,scale] duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
              !isStatic && "active:not-disabled:scale-[0.97]",
            )}
          >
            <ShoppingBag className="size-4" aria-hidden />
            {label}
          </motion.button>
        ) : (
          <motion.div
            key="stepper"
            initial={{ opacity: 0, filter: "blur(4px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, filter: "blur(4px)" }}
            transition={springOrNone}
            className="flex w-full items-center justify-between"
          >
            <QuantityStepper
              value={quantity}
              onValueChange={setQuantity}
              min={0}
              max={max}
              static={isStatic}
              aria-label="Quantity in cart"
              className="w-full justify-between"
            />
          </motion.div>
        )}
      </AnimatePresence>

      {/* One-shot success tick over the freshly-added control. */}
      <AnimatePresence>
        {ticked && (
          <motion.span
            key="tick"
            aria-hidden
            initial={{ opacity: 0, scale: 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.9 }}
            transition={{ type: "spring", duration: 0.3, bounce: 0.15 }}
            className="pointer-events-none absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-success text-success-foreground shadow-border"
          >
            <Check className="size-3" strokeWidth={3} />
          </motion.span>
        )}
      </AnimatePresence>
    </div>
  );
}

export interface CartBadgeProps extends React.ComponentProps<"span"> {
  /** Total item count. Ticks when it changes. */
  count: number;
  static?: boolean;
}

/**
 * A shopping-bag trigger with a count badge that pops when the count rises.
 * Pairs with AddToCartButton but works with any cart source.
 */
export function CartBadge({
  count,
  static: isStatic = false,
  className,
  ...props
}: CartBadgeProps) {
  const reduced = useReducedMotion() ?? false;
  const prev = React.useRef(count);
  const rising = count > prev.current;
  React.useEffect(() => {
    prev.current = count;
  }, [count]);

  return (
    <span
      className={cn(
        "relative inline-flex size-9 items-center justify-center rounded-full text-foreground",
        className,
      )}
      {...props}
    >
      <ShoppingBag className="size-5" aria-hidden />
      <AnimatePresence>
        {count > 0 && (
          <motion.span
            key="badge"
            initial={{ opacity: 0, scale: 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.6 }}
            transition={
              isStatic || reduced
                ? { duration: 0 }
                : { type: "spring", duration: 0.3, bounce: rising ? 0.2 : 0 }
            }
            className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-semibold text-primary-foreground tabular-nums"
          >
            {count > 99 ? "99+" : count}
          </motion.span>
        )}
      </AnimatePresence>
      <span className="sr-only">{count} items in cart</span>
    </span>
  );
}