# Expand Card: Shop

> Product cards that open into a product page with close-ups and a size picker. Add something to the bag, then pull the page down to watch the count behind it change.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/expand-card-shop-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/expand-card-shop-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/expand-card-shop-demo-light-ae8356c6bf3fe056.png
- Registry item: https://keyframeui.com/r/expand-card-shop-demo.json
- Categories: card, gesture, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/expand-card-shop-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/expand-card.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/expand-card#expand-card-shop-demo

## Files

### `registry/demos/expand-card-shop-demo.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { ExpandCard } from "@/components/ui/expand-card";

type Product = {
  id: string;
  name: string;
  color: string;
  price: number;
  about: string;
  soldOut: string[];
  // Focal points for the detail shots, as fractions of the photo.
  details: [number, number][];
};

const products: Product[] = [
  { id: "1542291026-7eec264c27ff", name: "Ember Runner", color: "Crimson", price: 140, about: "A daily trainer with a springy foam midsole and a knit upper that stretches where your foot does.", soldOut: ["7", "12"], details: [[0.42, 0.45], [0.62, 0.62], [0.3, 0.6]] },
  { id: "1606107557195-0e29a4b5b4aa", name: "Volt Trainer", color: "Lime / Black", price: 120, about: "Built for the gym floor: a flat, grippy sole, a wide toe box and a heel that locks in for lifts.", soldOut: ["8.5"], details: [[0.5, 0.42], [0.4, 0.6], [0.62, 0.55]] },
  { id: "1595950653106-6c9ebd614d3a", name: "Pastel Court", color: "Lilac / Mint", price: 110, about: "A retro court shoe in soft suede, with a cupsole that's comfortable from the first wear.", soldOut: ["10", "10.5"], details: [[0.5, 0.45], [0.45, 0.62], [0.55, 0.35]] },
  { id: "1600185365483-26d7a4cc7519", name: "Coral Air", color: "Sail / Coral", price: 150, about: "A visible air unit under the heel and a mesh upper that keeps long days cool.", soldOut: ["7.5"], details: [[0.5, 0.5], [0.35, 0.55], [0.66, 0.5]] },
  { id: "1549298916-b41d501d3772", name: "Desert Low", color: "Wheat", price: 130, about: "Waxed nubuck that shrugs off rain, on a lugged sole made for gravel paths and cobbles.", soldOut: ["9", "12"], details: [[0.5, 0.5], [0.38, 0.62], [0.62, 0.42]] },
  { id: "1608231387042-66d1773070a5", name: "Night Court", color: "White / Black", price: 95, about: "The classic high-contrast low-top, cut from smooth leather that creases in all the right places.", soldOut: [], details: [[0.48, 0.5], [0.35, 0.58], [0.65, 0.46]] },
];

const SIZES = ["7", "7.5", "8", "8.5", "9", "9.5", "10", "10.5", "11", "12"];

const photo = (id: string, width: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=max&q=80&w=${width}`;
const detail = (id: string, [x, y]: [number, number]) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=focalpoint&fp-x=${x}&fp-y=${y}&fp-z=2.2&w=480&h=480&q=80`;

export function ExpandCardShopDemo() {
  const [bag, setBag] = React.useState(0);

  return (
    <section className="w-full max-w-3xl">
      <div className="mb-6 flex items-end justify-between gap-4">
        <div>
          <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Spring drop</p>
          <h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
            New arrivals
          </h3>
        </div>
        <Bag count={bag} />
      </div>

      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
        {products.map((product) => (
          <ExpandCard
            key={product.id}
            width={760}
            coverHeight={460}
            className="aspect-[4/5] w-full rounded-2xl bg-zinc-100 dark:bg-zinc-900"
            articleClassName="bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50"
            cover={<ProductCover product={product} />}
          >
            <ProductPage product={product} onAdd={() => setBag((count) => count + 1)} />
          </ExpandCard>
        ))}
      </div>
    </section>
  );
}

function ProductCover({ product }: { product: Product }) {
  return (
    <div className="relative size-full">
      <img
        src={photo(product.id, 1400)}
        alt=""
        draggable={false}
        className="absolute inset-0 size-full object-cover"
      />
      <div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] rounded-xl bg-white/85 px-3 py-2 shadow-sm backdrop-blur-md dark:bg-zinc-900/80">
        <p className="truncate text-[13px] font-semibold text-zinc-900 dark:text-zinc-50">{product.name}</p>
        <p className="truncate text-xs text-zinc-500 tabular-nums">
          {product.color} · ${product.price}
        </p>
      </div>
    </div>
  );
}

function ProductPage({ product, onAdd }: { product: Product; onAdd: () => void }) {
  const [size, setSize] = React.useState<string | null>(null);
  const [added, setAdded] = React.useState(false);
  const sizes = React.useId();

  React.useEffect(() => {
    if (!added) return;
    const timer = window.setTimeout(() => setAdded(false), 1800);
    return () => window.clearTimeout(timer);
  }, [added]);

  return (
    <div className="px-5 pt-5 pb-10 sm:px-8">
      {/* Gallery: swipe for close-ups. */}
      <div className="-mx-5 flex snap-x snap-mandatory gap-2 overflow-x-auto px-5 [scrollbar-width:none] sm:-mx-8 sm:px-8">
        {product.details.map((point, index) => (
          <img
            key={index}
            src={detail(product.id, point)}
            alt={`${product.name}, close-up ${index + 1}`}
            draggable={false}
            className="size-28 shrink-0 snap-start rounded-xl bg-zinc-100 object-cover dark:bg-zinc-900"
          />
        ))}
      </div>

      <div className="mt-6 flex items-start justify-between gap-4">
        <div>
          <h4 className="text-2xl font-semibold tracking-tight">{product.name}</h4>
          <p className="mt-0.5 text-sm text-zinc-500">{product.color}</p>
        </div>
        <p className="text-xl font-semibold tabular-nums">${product.price}</p>
      </div>
      <p className="mt-4 max-w-prose text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400">
        {product.about}
      </p>

      <fieldset className="mt-7">
        <div className="flex items-baseline justify-between">
          <legend id={sizes} className="text-sm font-medium">
            Size <span className="text-zinc-500">(US)</span>
          </legend>
          <p className="text-xs text-zinc-500">{size ? `US ${size} selected` : "Pick a size"}</p>
        </div>
        <div aria-labelledby={sizes} className="mt-3 grid grid-cols-5 gap-2">
          {SIZES.map((value) => {
            const soldOut = product.soldOut.includes(value);
            return (
              <label
                key={value}
                className={
                  "relative grid h-11 cursor-pointer place-items-center rounded-xl text-sm font-medium tabular-nums ring-1 ring-zinc-200 transition-[background-color,color,box-shadow,scale] duration-150 ease-out select-none active:scale-95 dark:ring-zinc-800 " +
                  "has-checked:bg-zinc-900 has-checked:text-white has-checked:ring-zinc-900 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 " +
                  "dark:has-checked:bg-white dark:has-checked:text-zinc-900 dark:has-checked:ring-white dark:has-focus-visible:outline-white " +
                  (soldOut ? "cursor-not-allowed text-zinc-300 line-through active:scale-100 dark:text-zinc-700" : "hover:ring-zinc-400 dark:hover:ring-zinc-600")
                }
              >
                <input
                  type="radio"
                  name={`${product.id}-size`}
                  value={value}
                  disabled={soldOut}
                  checked={size === value}
                  onChange={() => setSize(value)}
                  className="sr-only"
                />
                {value}
              </label>
            );
          })}
        </div>
      </fieldset>

      <button
        type="button"
        disabled={!size}
        onClick={() => {
          onAdd();
          setAdded(true);
        }}
        className="relative mt-6 flex h-12 w-full items-center justify-center overflow-hidden rounded-full bg-zinc-900 text-[15px] font-semibold text-white transition-[background-color,opacity,scale] duration-150 ease-out hover:bg-zinc-800 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
      >
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={added ? "added" : "add"}
            initial={{ opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -12 }}
            transition={{ type: "spring", visualDuration: 0.25, bounce: 0.2 }}
            className="flex items-center gap-2"
          >
            {added ? (
              <>
                <svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
                  <path d="M5 12l5 5L20 7" />
                </svg>
                Added to bag
              </>
            ) : (
              "Add to bag"
            )}
          </motion.span>
        </AnimatePresence>
      </button>
      <p className="mt-3 text-center text-xs text-zinc-500">Free returns within 30 days</p>

      <dl className="mt-8 divide-y divide-zinc-200 border-y border-zinc-200 text-sm dark:divide-zinc-800 dark:border-zinc-800">
        {[
          ["Fit", "True to size. Between sizes? Go up half."],
          ["Upper", "Engineered knit with a suede heel"],
          ["Sole", "Rubber outsole, recycled foam midsole"],
          ["Shipping", "Free over $100. Arrives in 2–4 days."],
        ].map(([term, value]) => (
          <div key={term} className="flex gap-4 py-3">
            <dt className="w-20 shrink-0 text-zinc-500">{term}</dt>
            <dd className="text-zinc-800 dark:text-zinc-200">{value}</dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

function Bag({ count }: { count: number }) {
  const reduceMotion = useReducedMotion();
  const pop = count > 0 && !reduceMotion;

  return (
    <div className="relative grid size-10 place-items-center rounded-full bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-50">
      <motion.svg
        key={count}
        aria-hidden
        viewBox="0 0 24 24"
        className="size-[18px]"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="round"
        strokeLinejoin="round"
        initial={pop ? { scale: 1.25, rotate: -8 } : false}
        animate={{ scale: 1, rotate: 0 }}
        transition={{ type: "spring", visualDuration: 0.4, bounce: 0.5 }}
      >
        <path d="M6 8h12l-1 12H7zM9 8V6a3 3 0 0 1 6 0v2" />
      </motion.svg>
      <span role="status" className="sr-only">
        {count} {count === 1 ? "item" : "items"} in your bag
      </span>
      <AnimatePresence>
        {count > 0 ? (
          <motion.span
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.15 }}
            className="absolute -top-1 -right-1"
          >
            {/* Remounts on every change, so each new item pops the badge. */}
            <motion.span
              key={count}
              initial={pop ? { scale: count === 1 ? 0.3 : 1.35 } : false}
              animate={{ scale: 1 }}
              transition={{ type: "spring", visualDuration: 0.35, bounce: 0.45 }}
              className="grid h-5 min-w-5 place-items-center rounded-full bg-rose-500 px-1 text-[11px] font-bold text-white tabular-nums ring-2 ring-white dark:ring-zinc-950"
            >
              {count}
            </motion.span>
          </motion.span>
        ) : null}
      </AnimatePresence>
    </div>
  );
}
```
