A saved-items grid where cards move to cart with a success flash or collapse out on remove as survivors reflow.
npx shadcn@latest add @paragon/wishlist-grid"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ShoppingCart, X, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface WishlistItem {
id: string;
name: string;
price: number;
/** Optional image; a deterministic gradient renders when omitted. */
src?: string;
hue?: number;
inStock?: boolean;
}
export interface WishlistGridProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
items: WishlistItem[];
currency?: string;
locale?: Intl.LocalesArgument;
onMoveToCart?: (id: string) => void;
onRemove?: (id: string) => void;
}
function placeholder(hue: number) {
return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}
/**
* A saved-items grid. Each card can be moved to cart (flashes a tick, then the
* card collapses out) or removed (collapses out). Removal uses AnimatePresence
* with a scale+opacity exit and the surviving cards reflow via layout. Prices
* are tabular. Reduced motion drops the movement but keeps the fade.
*/
export function WishlistGrid({
items: initial,
currency = "USD",
locale = "en-US",
onMoveToCart,
onRemove,
className,
...props
}: WishlistGridProps) {
const reduced = useReducedMotion();
const [items, setItems] = React.useState(initial);
const [moved, setMoved] = React.useState<string | null>(null);
const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => {
if (timeout.current) clearTimeout(timeout.current);
}, []);
const format = React.useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency }),
[locale, currency],
);
const remove = (id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
onRemove?.(id);
};
const moveToCart = (id: string) => {
setMoved(id);
onMoveToCart?.(id);
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
setMoved(null);
setItems((prev) => prev.filter((i) => i.id !== id));
}, 650);
};
return (
<div
className={cn(
"grid w-full max-w-md grid-cols-2 gap-3 sm:grid-cols-3",
className,
)}
{...props}
>
<AnimatePresence mode="popLayout" initial={false}>
{items.map((item) => (
<motion.div
key={item.id}
layout={!reduced}
initial={false}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.9, filter: "blur(4px)" }
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="group relative flex flex-col overflow-hidden rounded-xl bg-card shadow-border"
>
<div className="relative aspect-square">
{item.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.src}
alt={item.name}
className="size-full object-cover"
/>
) : (
<span
role="img"
aria-label={item.name}
className="block size-full"
style={{ background: placeholder(item.hue ?? 40) }}
/>
)}
<button
type="button"
aria-label={`Remove ${item.name} from wishlist`}
onClick={() => remove(item.id)}
className="pressable absolute right-1.5 top-1.5 flex size-7 items-center justify-center rounded-full bg-background/80 text-muted-foreground backdrop-blur-sm transition-colors duration-150 hover:bg-background hover:text-foreground"
>
<X className="size-3.5" aria-hidden />
</button>
</div>
<div className="flex flex-1 flex-col gap-2 p-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-card-foreground">
{item.name}
</p>
<p className="text-xs tabular-nums text-muted-foreground">
{format.format(item.price)}
</p>
</div>
<button
type="button"
disabled={item.inStock === false || moved === item.id}
onClick={() => moveToCart(item.id)}
className={cn(
"pressable inline-flex h-8 items-center justify-center gap-1.5 rounded-lg text-xs font-medium transition-[background-color,color] duration-150 ease-out disabled:pointer-events-none",
moved === item.id
? "bg-success text-success-foreground"
: item.inStock === false
? "bg-secondary text-muted-foreground opacity-60"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
{moved === item.id ? (
<>
<Check className="size-3.5" aria-hidden />
Added
</>
) : item.inStock === false ? (
"Sold out"
) : (
<>
<ShoppingCart className="size-3.5" aria-hidden />
Move to cart
</>
)}
</button>
</div>
</motion.div>
))}
</AnimatePresence>
{items.length === 0 && (
<p className="col-span-full py-8 text-center text-sm text-muted-foreground">
Your wishlist is empty.
</p>
)}
</div>
);
}