E-commerce

Wishlist Grid

A saved-items grid where cards move to cart with a success flash or collapse out on remove as survivors reflow.

Install

npx shadcn@latest add @paragon/wishlist-grid

wishlist-grid.tsx

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