A button that morphs in place into an inline quantity stepper, flashes a success tick, and ticks a cart badge.
npx shadcn@latest add @paragon/add-to-cart-buttonAlso installs: quantity-stepper
"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>
);
}