"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
type AddToCartSize = "sm" | "md" | "lg";
type ElementTarget = React.RefObject<HTMLElement | null> | string;
export interface AddToCartButtonProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** The cart element the item flies into (ref or CSS selector). Measured at click time. Optional. */
target?: ElementTarget;
/** Element cloned as the flying thumbnail (ref or selector), e.g. the product image. Default: an accent dot. */
source?: ElementTarget;
/** Fires immediately on click — put your cart / API call here. */
onAdd?: () => void;
/** Fires when the item lands in the cart (immediately without a target or with reduced motion). */
onArrive?: () => void;
/** Current number of items, used for the live announcement "Added to cart, 3 items". */
cartCount?: number;
label?: string;
/** Text of the brief confirmation state. */
addedLabel?: string;
/** Flight time in ms. */
duration?: number;
/** Extra height of the parabola above the straight line between the two points, in px. */
arc?: number;
/** How long the "Added" state lasts, in ms. */
resetAfter?: number;
/** Dot colour, check badge and focus ring. */
accent?: string;
size?: AddToCartSize;
}
const SIZES: Record<AddToCartSize, { h: string; px: string; text: string; icon: string }> = {
sm: { h: "h-9", px: "px-3.5", text: "text-[13px]", icon: "size-4" },
md: { h: "h-11", px: "px-5", text: "text-sm", icon: "size-[18px]" },
lg: { h: "h-13", px: "px-6", text: "text-[15px]", icon: "size-5" },
};
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function resolve(target: ElementTarget | undefined): HTMLElement | null {
if (!target) return null;
if (typeof target === "string") return document.querySelector<HTMLElement>(target);
return target.current;
}
function visibleRect(el: HTMLElement | null) {
if (!el) return null;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 ? rect : null;
}
/** Builds the flying element: a clone of `source`, or a small accent dot centred on `from`. */
function createFlyer(source: HTMLElement | null, from: DOMRect, accent: string) {
let el: HTMLElement;
if (source) {
el = source.cloneNode(true) as HTMLElement;
el.removeAttribute("id");
el.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id"));
el.style.borderRadius = getComputedStyle(source).borderRadius;
el.style.boxShadow = "0 14px 30px -12px rgb(0 0 0 / 0.5)";
} else {
el = document.createElement("div");
el.style.background = accent;
el.style.borderRadius = "9999px";
el.style.boxShadow = "0 4px 12px -2px rgb(0 0 0 / 0.35)";
}
el.setAttribute("aria-hidden", "true");
el.inert = true;
Object.assign(el.style, {
position: "fixed",
left: `${from.left}px`,
top: `${from.top}px`,
width: `${from.width}px`,
height: `${from.height}px`,
margin: "0",
boxSizing: "border-box",
overflow: "hidden",
pointerEvents: "none",
zIndex: "2147483000",
transformOrigin: "50% 50%",
willChange: "transform, opacity",
});
return el;
}
/**
* An add-to-cart button whose click throws the product (a clone of `source`, or a dot) along a
* parabola into the cart element, bumps it, and briefly confirms with a check. The flight is a
* Web Animations keyframe path on a detached element — no React renders per frame.
*/
export const AddToCartButton = React.forwardRef<HTMLButtonElement, AddToCartButtonProps>(function AddToCartButton(
{
target,
source,
onAdd,
onArrive,
cartCount,
label = "Add to cart",
addedLabel = "Added",
duration = 800,
arc = 120,
resetAfter = 1600,
accent = "#FF7448",
size = "md",
className = "",
style,
onClick,
disabled,
type = "button",
...props
},
forwardedRef,
) {
const reduceMotion = usePrefersReducedMotion();
const s = SIZES[size] ?? SIZES.md;
const [added, setAdded] = React.useState(false);
// Bumped on every landing; the announcement text is derived from it and the latest cartCount.
const [arrivals, setArrivals] = React.useState(0);
const resetTimer = React.useRef(0);
const flights = React.useRef(new Set<Animation>());
const mounted = React.useRef(true);
const latest = React.useRef({ onArrive });
React.useEffect(() => {
latest.current = { onArrive };
});
React.useEffect(() => {
mounted.current = true;
const active = flights.current;
return () => {
mounted.current = false;
window.clearTimeout(resetTimer.current);
active.forEach((animation) => {
animation.cancel();
(animation.effect as KeyframeEffect | null)?.target?.remove();
});
active.clear();
};
}, []);
const confirm = () => {
setAdded(true);
window.clearTimeout(resetTimer.current);
resetTimer.current = window.setTimeout(() => setAdded(false), Math.max(400, resetAfter));
};
const land = () => {
if (!mounted.current) return;
setArrivals((n) => n + 1);
latest.current.onArrive?.();
};
const launch = (button: HTMLButtonElement) => {
const cart = resolve(target);
const to = visibleRect(cart);
if (!cart || !to || reduceMotion || typeof document.body.animate !== "function") {
land();
return;
}
const sourceEl = resolve(source);
const sourceRect = visibleRect(sourceEl);
const buttonRect = button.getBoundingClientRect();
const dot = Math.round(Math.min(18, buttonRect.height * 0.42));
const from =
sourceEl && sourceRect
? sourceRect
: new DOMRect(buttonRect.left + buttonRect.width / 2 - dot / 2, buttonRect.top + buttonRect.height / 2 - dot / 2, dot, dot);
const flyer = createFlyer(sourceEl && sourceRect ? sourceEl : null, from, accent);
// Inside a modal <dialog> the top layer would cover a body-level flyer.
(button.closest("dialog[open]") ?? document.body).appendChild(flyer);
// Centre-to-centre parabola, sampled into keyframes; easing on the whole effect keeps the path.
const dx = to.left + to.width / 2 - (from.left + from.width / 2);
const dy = to.top + to.height / 2 - (from.top + from.height / 2);
const lift = Math.max(0, arc);
const endScale = Math.min(1, 22 / Math.max(from.width, from.height));
const steps = 28;
const keyframes: Keyframe[] = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = dx * t;
const y = dy * t - lift * 4 * t * (1 - t);
// Shrink early (ease-out cubic) so a large clone reads as a thumbnail for most of the flight.
const scale = 1 + (endScale - 1) * (1 - Math.pow(1 - t, 3));
keyframes.push({
offset: t,
transform: `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${scale.toFixed(3)})`,
opacity: t < 0.88 ? 1 : 1 - (t - 0.88) / 0.12,
});
}
const flight = flyer.animate(keyframes, { duration: Math.max(200, duration), easing: "cubic-bezier(0.45, 0, 0.55, 1)", fill: "forwards" });
flights.current.add(flight);
flight.onfinish = () => {
flights.current.delete(flight);
flyer.remove();
if (!mounted.current) return;
// Bump the cart: individual `scale` composes with any transform the cart already has.
cart.animate([{ scale: "1" }, { scale: "1.2" }, { scale: "0.94" }, { scale: "1.03" }, { scale: "1" }], {
duration: 460,
easing: "ease-out",
});
land();
};
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) return;
onAdd?.();
confirm();
launch(event.currentTarget);
};
const announcement =
arrivals === 0
? ""
: `${cartCount === undefined ? "Added to cart" : `Added to cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}${arrivals % 2 ? "" : "\u00a0"}`;
const swap = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.12 } }
: {
initial: { opacity: 0, y: 14, filter: "blur(2px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: -14, filter: "blur(2px)" },
transition: { type: "spring" as const, stiffness: 460, damping: 32 },
};
return (
<>
<button
{...props}
ref={forwardedRef}
type={type}
disabled={disabled}
onClick={handleClick}
className={`group/atc relative inline-flex ${s.h} ${s.px} ${s.text} touch-manipulation select-none items-center justify-center overflow-hidden rounded-full bg-zinc-900 font-medium tracking-tight text-white shadow-[0_1px_0_rgb(255_255_255/0.08)_inset,0_10px_24px_-14px_rgb(0_0_0/0.6)] outline-hidden transition-[background-color,scale] duration-150 [-webkit-tap-highlight-color:transparent] hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--atc-accent)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45 disabled:active:scale-100 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white ${className}`}
style={{ ["--atc-accent" as string]: accent, ...style }}
>
{/* Both labels share one grid cell so the width never jumps. */}
<span className="grid items-center [grid-template-areas:'stack']">
<span aria-hidden="true" className="invisible flex items-center gap-2 [grid-area:stack]">
<span className={s.icon} />
{label.length >= addedLabel.length ? label : addedLabel}
</span>
<AnimatePresence initial={false}>
{added ? (
<motion.span key="added" className="flex items-center justify-center gap-2 [grid-area:stack]" {...swap}>
<span className={`grid ${s.icon} place-items-center rounded-full bg-[var(--atc-accent)] text-white`}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-[70%]" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="m3.5 8.4 2.9 2.8 6.1-6.4"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.32, delay: 0.08, ease: "easeOut" }}
/>
</svg>
</span>
{addedLabel}
</motion.span>
) : (
<motion.span key="idle" className="flex items-center justify-center gap-2 [grid-area:stack]" {...swap}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={s.icon} stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h2.2l2.1 10.4a1.6 1.6 0 0 0 1.6 1.3h7.7a1.6 1.6 0 0 0 1.6-1.2L20 8H6.1" />
<circle cx="9.5" cy="19.5" r="1.2" />
<circle cx="16.5" cy="19.5" r="1.2" />
</svg>
{label}
</motion.span>
)}
</AnimatePresence>
</span>
</button>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</span>
</>
);
});
export interface CartIndicatorProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Items in the cart; the badge rolls to each new value. */
count: number;
/** Accessible name prefix: "Cart, 3 items". */
label?: string;
/** Badge colour. */
accent?: string;
}
/** A cart icon button with a rolling count badge — a ready-made `target` for AddToCartButton. */
export const CartIndicator = React.forwardRef<HTMLButtonElement, CartIndicatorProps>(function CartIndicator(
{ count, label = "Cart", accent = "#FF7448", className = "", style, type = "button", ...props },
forwardedRef,
) {
const reduceMotion = usePrefersReducedMotion();
const roll = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: {
initial: { y: "100%", opacity: 0 },
animate: { y: "0%", opacity: 1 },
exit: { y: "-100%", opacity: 0 },
transition: { type: "spring" as const, stiffness: 520, damping: 34 },
};
return (
<button
{...props}
ref={forwardedRef}
type={type}
aria-label={`${label}, ${count} ${count === 1 ? "item" : "items"}`}
className={`relative grid size-11 shrink-0 place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-800 outline-hidden transition-colors hover:border-zinc-900/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--atc-accent)] dark:border-white/10 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:border-white/20 ${className}`}
style={{ ["--atc-accent" as string]: accent, ...style }}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h2.2l2.1 10.4a1.6 1.6 0 0 0 1.6 1.3h7.7a1.6 1.6 0 0 0 1.6-1.2L20 8H6.1" />
<circle cx="9.5" cy="19.5" r="1.2" />
<circle cx="16.5" cy="19.5" r="1.2" />
</svg>
<AnimatePresence initial={false}>
{count > 0 ? (
<motion.span
key="badge"
aria-hidden="true"
className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full bg-[var(--atc-accent)] px-1 font-mono text-[10px] font-medium tabular-nums text-white ring-2 ring-white dark:ring-[#131619]"
initial={reduceMotion ? { opacity: 0 } : { scale: 0 }}
animate={reduceMotion ? { opacity: 1 } : { scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { scale: 0 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 26 }}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={count} className="block" {...roll}>
{count > 99 ? "99+" : count}
</motion.span>
</AnimatePresence>
</motion.span>
) : null}
</AnimatePresence>
</button>
);
});