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.
@keyframeui
registry/demos/expand-card-shop-demo.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>
);
}
Live preview, examples and props: https://keyframeui.com/components/expand-card#expand-card-shop-demo























