Product grid
Products in grids, lists, rows and editorial layouts, with filters, quick view and categories.
Variants
Details
What Product grid brings along, read from the registry and the source.
- Category
- Products
- Area
- E-Commerce
- Variants
- 8
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/product-grid.tsx
- Brings along
- utilskitbuttoniconsectionrevealproduct-image
- npm packages
- None
- Languages
- English and German, 34 texts of its own
- Studio
- Adjust in code
- In collections
- Online shop
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ProductGridProps interface from components/ui/product-grid.tsx, with the comments from the code.
variantoptionalProductGridVariantgridwith-filterslistfeaturedcategory-tilesquick-viewcarousel-rowlookbook
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeproductsoptionalShopProduct[]actionoptional{ label: string; href: string }Link next to the heading, e.g. "Shop all"
categoriesoptionalProductCategory[]category-tiles
filtersoptionalFilterGroup[]with-filters
featuredoptionalShopProductfeatured: the product in the big card, defaults to the first product
featuredLabeloptionalstringfeatured: small label on the big card
lookbookoptional{ image: React.ReactNode; spots: LookbookSpot[]; caption?: string }lookbook: the editorial image and the products placed on it
initialQuickViewoptionalstringquick-view: open this product's dialog on load (for previews)
currencyoptionalstringonAddToCartoptional(product: ShopProduct, options?: { color?: string; size?: string; quantity: number }) => void
Built-in texts
Product grid writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 34 texts
| Key | English | German |
|---|---|---|
| add | English Add to cart | German In den Warenkorb |
| added | English Added | German Hinzugefügt |
| quickAdd | English Quick add | German Schnell hinzufügen |
| wishlist | English Save to wishlist | German Auf die Wunschliste |
| saved | English Saved to wishlist | German Auf der Wunschliste |
| quickView | English Quick view | German Schnellansicht |
| close | English Close | German Schließen |
| all | English All | German Alle |
| filters | English Filters | German Filter |
| sort | English Sort by | German Sortieren nach |
| sortFeatured | English Featured | German Empfohlen |
| sortPriceAsc | English Price, low to high | German Preis aufsteigend |
| sortPriceDesc | English Price, high to low | German Preis absteigend |
| sortRating | English Best rated | German Beste Bewertung |
| product | English product | German Produkt |
| products | English products | German Produkte |
| clearAll | English Clear all | German Alle entfernen |
| show | English Show | German Zeige |
| remove | English Remove filter | German Filter entfernen |
| noResults | English Nothing matches these filters. | German Zu diesen Filtern passt nichts. |
| resetFilters | English Reset filters | German Filter zurücksetzen |
| previous | English Previous products | German Vorherige Produkte |
| next | English Next products | German Nächste Produkte |
| shopTheLook | English Shop the look | German Den Look kaufen |
| addLook | English Add the whole look | German Ganzen Look hinzufügen |
| viewDetails | English View full details | German Alle Details ansehen |
| loadMore | English Load more | German Mehr laden |
| showing | English Showing | German Zeige |
| of | English of | German von |
| soldOut | English Sold out | German Ausverkauft |
| size | English Size | German Größe |
| shop | English Shop | German Ansehen |
| more | English more | German weitere |
| carousel | English Products, scroll sideways | German Produkte, seitlich scrollen |
Accessibility
Read from the source of the component.
- Product grid handles the keys Arrow left, Arrow right and Space itself.
- Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
- Sets aria-current, aria-haspopup, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles tab and tablist.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 16 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,234 lines
'use client';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Section, SectionHeader } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Magnetic, Parallax, useScrollProgress } from '@/components/ui/motion';
import { FlyUp, Price, ProductMedia, Quantity, Rating, ShopMotion, SizeGroup, Swap, SwatchGroup, TaxNote, TweenPrice, defaultSize, formatNumber, formatPrice, useAddFlash, type ShopProduct } from '@/components/ui/product-image';
export type ProductGridVariant = 'grid' | 'with-filters' | 'list' | 'featured' | 'category-tiles' | 'quick-view' | 'carousel-row' | 'lookbook';
export interface ProductCategory {
name: string;
href: string;
image: React.ReactNode;
/** Number of products, shown under the name */
count?: number;
description?: string;
}
export interface FilterOption {
value: string;
label: string;
/** Swatch color for color filters */
color?: string;
/** Does a product match this option? */
match: (p: ShopProduct) => boolean;
}
export interface FilterGroup {
id: string;
label: string;
type?: 'check' | 'swatch';
options: FilterOption[];
}
export interface LookbookSpot {
productId: string;
/** Position on the image in percent */
x: number;
y: number;
}
export interface ProductGridProps {
variant?: ProductGridVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
products?: ShopProduct[];
/** Link next to the heading, e.g. "Shop all" */
action?: { label: string; href: string };
/** category-tiles */
categories?: ProductCategory[];
/** with-filters */
filters?: FilterGroup[];
/** featured: the product in the big card, defaults to the first product */
featured?: ShopProduct;
/** featured: small label on the big card */
featuredLabel?: string;
/** lookbook: the editorial image and the products placed on it */
lookbook?: { image: React.ReactNode; spots: LookbookSpot[]; caption?: string };
/** quick-view: open this product's dialog on load (for previews) */
initialQuickView?: string;
currency?: string;
onAddToCart?: (product: ShopProduct, options?: { color?: string; size?: string; quantity: number }) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
add: 'Add to cart',
added: 'Added',
quickAdd: 'Quick add',
wishlist: 'Save to wishlist',
saved: 'Saved to wishlist',
quickView: 'Quick view',
close: 'Close',
all: 'All',
filters: 'Filters',
sort: 'Sort by',
sortFeatured: 'Featured',
sortPriceAsc: 'Price, low to high',
sortPriceDesc: 'Price, high to low',
sortRating: 'Best rated',
product: 'product',
products: 'products',
clearAll: 'Clear all',
show: 'Show',
remove: 'Remove filter',
noResults: 'Nothing matches these filters.',
resetFilters: 'Reset filters',
previous: 'Previous products',
next: 'Next products',
shopTheLook: 'Shop the look',
addLook: 'Add the whole look',
viewDetails: 'View full details',
loadMore: 'Load more',
showing: 'Showing',
of: 'of',
soldOut: 'Sold out',
size: 'Size',
shop: 'Shop',
more: 'more',
carousel: 'Products, scroll sideways',
},
de: {
add: 'In den Warenkorb',
added: 'Hinzugefügt',
quickAdd: 'Schnell hinzufügen',
wishlist: 'Auf die Wunschliste',
saved: 'Auf der Wunschliste',
quickView: 'Schnellansicht',
close: 'Schließen',
all: 'Alle',
filters: 'Filter',
sort: 'Sortieren nach',
sortFeatured: 'Empfohlen',
sortPriceAsc: 'Preis aufsteigend',
sortPriceDesc: 'Preis absteigend',
sortRating: 'Beste Bewertung',
product: 'Produkt',
products: 'Produkte',
clearAll: 'Alle entfernen',
show: 'Zeige',
remove: 'Filter entfernen',
noResults: 'Zu diesen Filtern passt nichts.',
resetFilters: 'Filter zurücksetzen',
previous: 'Vorherige Produkte',
next: 'Nächste Produkte',
shopTheLook: 'Den Look kaufen',
addLook: 'Ganzen Look hinzufügen',
viewDetails: 'Alle Details ansehen',
loadMore: 'Mehr laden',
showing: 'Zeige',
of: 'von',
soldOut: 'Ausverkauft',
size: 'Größe',
shop: 'Ansehen',
more: 'weitere',
carousel: 'Produkte, seitlich scrollen',
},
};
type T = (typeof TEXTS)['en'];
/** "Added" feedback that resets itself, with a count for the rising "+1" */
const useFlash = useAddFlash;
/**
* A marker that glides under the active item of a row (pills, tabs). Until it
* is measured every item draws its own active state, so nothing is missing
* without JavaScript. Returns the ref for the row and the marker style.
*/
function useGlide<T extends HTMLElement>(index: number) {
const ref = useRef<T>(null);
const [box, setBox] = useState<{ x: number; y: number; w: number; h: number; ready: boolean } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
const item = el.querySelectorAll<HTMLElement>('[data-glide]')[index];
if (!item) return setBox(null);
setBox((b) => ({ x: item.offsetLeft, y: item.offsetTop, w: item.offsetWidth, h: item.offsetHeight, ready: !!b }));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [index]);
const style: React.CSSProperties | undefined = box ? { width: box.w, height: box.h, transform: `translate3d(${box.x}px, ${box.y}px, 0)` } : undefined;
return [ref, style, !!box?.ready] as const;
}
/**
* Lets the items of a list glide to their new place when filters or sorting
* change, new ones fade in. Items carry data-flip with a stable key. Only
* transform and opacity move, through the Web Animations API.
*/
function useFlip<T extends HTMLElement>(dep: unknown) {
const ref = useRef<T>(null);
const last = useRef(new Map<string, { x: number; y: number }>());
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const base = el.getBoundingClientRect();
const items = Array.from(el.children) as HTMLElement[];
const next = new Map<string, { x: number; y: number }>();
for (const n of items) {
const r = n.getBoundingClientRect();
if (n.dataset.flip) next.set(n.dataset.flip, { x: r.left - base.left, y: r.top - base.top });
}
const prev = last.current;
if (prev.size && !prefersReducedMotion()) {
items.forEach((n, i) => {
const key = n.dataset.flip;
const b = key && next.get(key);
if (!b) return;
const a = prev.get(key!);
if (a) {
const dx = a.x - b.x;
const dy = a.y - b.y;
if (Math.abs(dx) + Math.abs(dy) < 1) return;
n.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 560, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)' });
} else {
n.animate([{ opacity: 0, transform: 'translateY(12px) scale(0.97)' }, { opacity: 1, transform: 'none' }], { duration: 480, delay: 120 + Math.min(i, 6) * 40, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)', fill: 'backwards' });
}
});
}
last.current = next;
}, [dep]);
return ref;
}
const count = (t: T, n: number) => `${n} ${n === 1 ? t.product : t.products}`;
function Wishlist({ t, className }: { t: T; className?: string }) {
const [on, setOn] = useState(false);
return (
<button
type="button"
aria-pressed={on}
aria-label={on ? t.saved : t.wishlist}
onClick={() => setOn(!on)}
className={cn(
'relative z-10 grid size-9 place-items-center rounded-full bg-background/80 text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-[transform,background-color] duration-200 after:absolute after:-inset-1 hover:bg-background active:scale-90',
className,
)}
>
<Icon name="heart" size={16} className={cn('transition-colors', on && 'fill-current text-[color-mix(in_oklch,var(--destructive)_85%,var(--foreground))]')} />
</button>
);
}
function SmallBadge({ children, tone = 'default' }: { children: React.ReactNode; tone?: 'default' | 'sale' }) {
return (
<span
className={cn(
'inline-flex h-6 items-center rounded-md px-2 text-[11px] font-semibold tracking-[0.01em] shadow-[var(--shadow-sm)]',
tone === 'sale' ? 'bg-[color-mix(in_oklch,var(--destructive)_88%,var(--foreground))] text-destructive-foreground' : 'bg-background/90 text-foreground backdrop-blur',
)}
>
{children}
</span>
);
}
const isSale = (p: ShopProduct) => p.compareAt !== undefined && p.compareAt > p.price;
function ColorDots({ product, max = 4 }: { product: ShopProduct; t: T; max?: number }) {
if (!product.colors?.length || product.colors.length < 2) return null;
const rest = product.colors.length - max;
return (
<div className="mt-2.5 flex items-center gap-1.5">
{product.colors.slice(0, max).map((c) => (
<span key={c.name} title={c.name} className="size-3 rounded-full shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--foreground)_16%,transparent)]" style={{ background: c.color }} />
))}
{rest > 0 && <span className="text-[11px] text-muted-foreground">+{rest}</span>}
<span className="sr-only">{product.colors.map((c) => c.name).join(', ')}</span>
</div>
);
}
/** The product card all grid variants share */
function ProductCard({
product,
t,
currency,
aspect = 'aspect-[4/5]',
onAdd,
addedId,
action,
sizes = 'md',
flyCount = 0,
}: {
product: ShopProduct;
t: T;
currency?: string;
aspect?: string;
onAdd?: (p: ShopProduct) => void;
addedId?: string | null;
/** Replaces the quick add button, e.g. with "Quick view" */
action?: React.ReactNode;
sizes?: 'sm' | 'md';
/** From useAddFlash: lets a "+1" rise from the add button */
flyCount?: number;
}) {
const added = addedId === product.id;
return (
<article className="group/card relative flex flex-col">
<div className="relative">
{/* Hover: the image zooms a little inside its frame and crossfades to the second motif */}
<ProductMedia className={cn(aspect, 'rounded-xl [&>*]:transition-[transform,opacity] [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/card:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none')}>
{product.image}
{product.hoverImage && (
<div className="absolute inset-0 opacity-0 group-hover/card:opacity-100" aria-hidden>
{product.hoverImage}
</div>
)}
</ProductMedia>
<div className="pointer-events-none absolute inset-x-3 top-3 flex items-start justify-between">
<div className="flex gap-1.5">
{product.soldOut ? <SmallBadge>{t.soldOut}</SmallBadge> : product.badge && <SmallBadge tone={isSale(product) ? 'sale' : 'default'}>{product.badge}</SmallBadge>}
</div>
<Wishlist t={t} className="pointer-events-auto" />
</div>
{action}
{!action && onAdd && !product.soldOut && (
<>
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex translate-y-2 opacity-0 transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] group-hover/card:translate-y-0 group-hover/card:opacity-100 group-focus-within/card:translate-y-0 group-focus-within/card:opacity-100 max-lg:hidden motion-reduce:transition-none">
<button
type="button"
onClick={() => onAdd(product)}
className={cn('pointer-events-auto relative flex h-11 w-full items-center justify-center overflow-hidden rounded-lg text-[13px] font-medium shadow-[var(--shadow-md)] backdrop-blur transition-colors duration-300', added ? 'bg-foreground text-background' : 'bg-background/92 text-foreground hover:bg-background')}
>
<Swap value={added} className="gap-2">
<Icon name={added ? 'check' : 'plus'} size={15} />
{added ? t.added : t.quickAdd}
</Swap>
</button>
<FlyUp count={added ? flyCount : 0} className="-top-3" />
</div>
<button
type="button"
aria-label={`${t.add}: ${product.name}`}
onClick={() => onAdd(product)}
className={cn('absolute bottom-3 right-3 z-10 grid size-9 place-items-center rounded-full shadow-[var(--shadow-md)] backdrop-blur transition-[transform,background-color,color] duration-300 after:absolute after:-inset-1 active:scale-90 lg:hidden', added ? 'bg-foreground text-background' : 'bg-background/90 text-foreground')}
>
<Swap value={added}>
<Icon name={added ? 'check' : 'plus'} size={16} />
</Swap>
<span className="lg:hidden">
<FlyUp count={added ? flyCount : 0} className="-top-4" />
</span>
</button>
</>
)}
</div>
<div className={cn('flex flex-col gap-1 pt-3.5', sizes === 'md' && 'sm:flex-row sm:items-start sm:justify-between sm:gap-4')}>
<div className="min-w-0">
<h3 className="text-[14px] font-medium leading-snug text-foreground sm:text-[15px]">
<a href={product.href ?? '#'} className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:outline-2 focus-visible:after:outline-offset-4 focus-visible:after:outline-[var(--ring)]">
{product.name}
</a>
</h3>
{product.subtitle && <p className="mt-0.5 truncate text-[13px] text-muted-foreground">{product.subtitle}</p>}
</div>
<Price value={product.price} compareAt={product.compareAt} currency={currency} size="sm" className={cn('sm:text-[15px]', sizes === 'md' && 'sm:flex-col sm:items-end sm:gap-0')} />
</div>
<ColorDots product={product} t={t} />
</article>
);
}
/* ------------------------------------------------------------------ */
/* Variants */
/* ------------------------------------------------------------------ */
function Header({ eyebrow, title, description, action, align = 'split' }: Pick<ProductGridProps, 'eyebrow' | 'title' | 'description' | 'action'> & { align?: 'split' | 'left' | 'center' }) {
if (!title) return null;
return (
<SectionHeader
eyebrow={eyebrow}
title={title}
description={description}
align={align}
actions={
action && (
<a href={action.href} className="group inline-flex h-11 items-center gap-1.5 text-[14px] font-medium text-foreground underline-offset-4 hover:underline">
{action.label}
<Icon name="arrow-right" size={15} className="transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none" />
</a>
)
}
/>
);
}
/** Category pills that filter the grid */
function CategoryPills({ cats, value, onChange, t }: { cats: string[]; value: string; onChange: (c: string) => void; t: T }) {
const all = [t.all, ...cats];
const [ref, marker, ready] = useGlide<HTMLDivElement>(Math.max(0, all.indexOf(value)));
// Arrow keys move between the pills, as in any tab row
const onKey = (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
const i = all.indexOf(value) + (e.key === 'ArrowRight' ? 1 : -1);
const next = all[(i + all.length) % all.length];
onChange(next);
ref.current?.querySelectorAll<HTMLElement>('[data-glide]')[all.indexOf(next)]?.focus();
};
return (
<div className="-mx-6 mb-8 max-sm:kit-fade-x sm:mx-0 sm:mb-10">
<div ref={ref} role="tablist" aria-label={t.filters} onKeyDown={onKey} className="kit-no-scrollbar relative isolate flex gap-2 overflow-x-auto px-6 sm:flex-wrap sm:px-0">
{/* The dark pill glides from the old category to the new one */}
{marker && <span aria-hidden className={cn('absolute left-0 top-0 -z-10 rounded-full bg-foreground', ready && 'transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={marker} />}
{all.map((c) => {
const on = value === c;
return (
<button
key={c}
data-glide
role="tab"
type="button"
aria-selected={on}
tabIndex={on ? 0 : -1}
onClick={() => onChange(c)}
className={cn(
'h-10 shrink-0 rounded-full border px-4 text-[13px] font-medium transition-[color,border-color,background-color] duration-300',
on ? cn('border-foreground text-background', !marker && 'bg-foreground') : 'border-border-strong text-muted-foreground hover:border-foreground/40 hover:text-foreground',
)}
>
{c}
</button>
);
})}
</div>
</div>
);
}
function GridVariant(props: ProductGridProps & { t: T }) {
const { products = [], t, currency, onAddToCart } = props;
const cats = useMemo(() => Array.from(new Set(products.map((p) => p.category).filter(Boolean) as string[])), [products]);
const [cat, setCat] = useState(t.all);
const [addedId, flash, flyCount] = useFlash();
const list = cat === t.all ? products : products.filter((p) => p.category === cat);
const shown = list.slice(0, 8);
const grid = useFlip<HTMLUListElement>(cat);
const [bar, barIn] = useInView<HTMLDivElement>();
return (
<Section className={props.className}>
<Header {...props} />
{cats.length > 1 && <CategoryPills cats={cats} value={cat} onChange={setCat} t={t} />}
<ul ref={grid} className="grid grid-cols-2 gap-x-3 gap-y-9 sm:gap-x-5 md:grid-cols-3 lg:grid-cols-4 lg:gap-x-6 lg:gap-y-12">
{shown.map((p, i) => (
<li key={p.id} data-flip={p.id}>
<Reveal delay={(i % 4) * 70}>
<ProductCard product={p} t={t} currency={currency} addedId={addedId} flyCount={flyCount} sizes="sm" onAdd={(x) => { flash(x.id); onAddToCart?.(x, { quantity: 1 }); }} />
</Reveal>
</li>
))}
</ul>
{list.length > 0 && (
<div className="mt-14 flex flex-col items-center gap-4">
<p className="text-[13px] text-muted-foreground">
{t.showing} {shown.length} {t.of} {list.length}
</p>
{/* Fills to its share when it scrolls in */}
<div ref={bar} className="h-0.5 w-40 overflow-hidden rounded-full bg-border">
<div className="h-full origin-left rounded-full bg-foreground transition-transform duration-[900ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${barIn ? Math.round((shown.length / list.length) * 100) / 100 : 0})` }} />
</div>
{shown.length < list.length && (
<Button variant="outline" className="mt-2">
{t.loadMore}
</Button>
)}
</div>
)}
</Section>
);
}
/* ---------- with-filters ---------- */
type Sort = 'featured' | 'asc' | 'desc' | 'rating';
function FilterGroups({ groups, products, active, toggle, idPrefix }: { groups: FilterGroup[]; products: ShopProduct[]; active: Record<string, string[]>; toggle: (g: string, v: string) => void; idPrefix: string }) {
return (
<div className="divide-y divide-border">
{groups.map((g) => (
<details key={g.id} open className="group/fg py-5 first:pt-0">
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between text-[14px] font-medium text-foreground [&::-webkit-details-marker]:hidden">
{g.label}
<Icon name="chevron-down" size={16} className="text-muted-foreground transition-transform group-open/fg:rotate-180 motion-reduce:transition-none" />
</summary>
{g.type === 'swatch' ? (
<div className="mt-2 flex flex-wrap gap-2">
{g.options.map((o) => {
const on = active[g.id]?.includes(o.value);
return (
<label key={o.value} className={cn('flex h-10 cursor-pointer items-center gap-2 rounded-full border pl-1.5 pr-3.5 text-[13px] transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', on ? 'border-foreground text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
<input type="checkbox" className="sr-only" checked={!!on} onChange={() => toggle(g.id, o.value)} />
<span className="size-6 rounded-full shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--foreground)_14%,transparent)]" style={{ background: o.color }} aria-hidden />
{o.label}
</label>
);
})}
</div>
) : (
<ul className="mt-1 space-y-0.5">
{g.options.map((o) => {
const on = active[g.id]?.includes(o.value);
const n = products.filter(o.match).length;
const id = `${idPrefix}-${g.id}-${o.value}`.replace(/[^a-zA-Z0-9-]/g, '');
return (
<li key={o.value}>
<label htmlFor={id} className="flex min-h-11 cursor-pointer items-center gap-3 text-[14px] text-muted-foreground hover:text-foreground">
<input id={id} type="checkbox" checked={!!on} onChange={() => toggle(g.id, o.value)} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[18px] place-items-center rounded-[5px] border transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)]', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{on && <Icon name="check" size={13} strokeWidth={2.4} />}
</span>
<span className={cn('flex-1', on && 'text-foreground')}>{o.label}</span>
<span className="text-[12px] tabular-nums text-subtle-foreground">{n}</span>
</label>
</li>
);
})}
</ul>
)}
</details>
))}
</div>
);
}
function FiltersVariant(props: ProductGridProps & { t: T }) {
const { products = [], filters = [], t, currency, onAddToCart } = props;
const [active, setActive] = useState<Record<string, string[]>>({});
const [sort, setSort] = useState<Sort>('featured');
const [addedId, flash, flyCount] = useFlash();
const sheet = useRef<HTMLDialogElement>(null);
const toggle = (g: string, v: string) => setActive((a) => ({ ...a, [g]: a[g]?.includes(v) ? a[g].filter((x) => x !== v) : [...(a[g] ?? []), v] }));
const filtered = useMemo(() => {
const list = products.filter((p) =>
filters.every((g) => {
const on = active[g.id];
if (!on?.length) return true;
return g.options.filter((o) => on.includes(o.value)).some((o) => o.match(p));
}),
);
const sorted = [...list];
if (sort === 'asc') sorted.sort((a, b) => a.price - b.price);
if (sort === 'desc') sorted.sort((a, b) => b.price - a.price);
if (sort === 'rating') sorted.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
return sorted;
}, [products, filters, active, sort]);
const chips = filters.flatMap((g) => (active[g.id] ?? []).map((v) => ({ g: g.id, v, label: g.options.find((o) => o.value === v)?.label ?? v })));
const activeCount = chips.length;
// Filtering and sorting let the cards glide to their new place
const grid = useFlip<HTMLUListElement>(JSON.stringify(active) + sort);
const sortSelect = (
<label className="relative flex h-11 items-center gap-2 rounded-lg border border-border-strong bg-background pl-3.5 pr-9 text-[13px] text-muted-foreground shadow-[var(--shadow-sm)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]">
<span className="whitespace-nowrap max-sm:sr-only">{t.sort}</span>
<select value={sort} onChange={(e) => setSort(e.target.value as Sort)} className="appearance-none bg-transparent font-medium text-foreground outline-none">
<option value="featured">{t.sortFeatured}</option>
<option value="asc">{t.sortPriceAsc}</option>
<option value="desc">{t.sortPriceDesc}</option>
<option value="rating">{t.sortRating}</option>
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute right-3" />
</label>
);
return (
<Section spacing="sm" className={cn('pt-10 sm:pt-12 lg:pt-14', props.className)}>
<div className="flex flex-col gap-6 border-b border-border pb-6 lg:flex-row lg:items-end lg:justify-between">
<div>
{props.eyebrow && <p className="mb-3 text-[13px] text-muted-foreground">{props.eyebrow}</p>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>
{props.description && <p className="mt-3 max-w-[34rem] text-pretty text-[15px] text-muted-foreground">{props.description}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" iconLeft="sliders" onClick={() => sheet.current?.showModal()} className="lg:hidden">
{t.filters}
{activeCount > 0 && <span className="grid size-5 place-items-center rounded-full bg-primary text-[11px] text-primary-foreground">{activeCount}</span>}
</Button>
{sortSelect}
</div>
</div>
<div className="mt-8 grid gap-10 lg:grid-cols-[15rem_1fr] lg:gap-12">
<aside className="max-lg:hidden" aria-label={t.filters}>
<div className="sticky top-6">
<FilterGroups groups={filters} products={products} active={active} toggle={toggle} idPrefix="side" />
</div>
</aside>
<div className="min-w-0">
<div className="mb-6 flex min-h-8 flex-wrap items-center gap-2" aria-live="polite">
<p className="mr-2 text-[13px] text-muted-foreground">{count(t, filtered.length)}</p>
<ShopMotion />
{chips.map((c) => (
<button key={c.g + c.v} type="button" onClick={() => toggle(c.g, c.v)} className="group inline-flex h-8 animate-[kitshop-pop_0.36s_var(--ease-kit)] items-center gap-1.5 rounded-full bg-muted pl-3 pr-2 text-[12px] font-medium text-foreground hover:bg-accent" aria-label={`${t.remove}: ${c.label}`}>
{c.label}
<Icon name="x" size={13} className="text-muted-foreground group-hover:text-foreground" />
</button>
))}
{activeCount > 0 && (
<button type="button" onClick={() => setActive({})} className="h-8 px-2 text-[12px] font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t.clearAll}
</button>
)}
</div>
{filtered.length ? (
<ul ref={grid} className="grid grid-cols-2 gap-x-3 gap-y-9 sm:gap-x-5 md:grid-cols-3 lg:gap-x-6 lg:gap-y-11">
{filtered.map((p, i) => (
<li key={p.id} data-flip={p.id}>
<Reveal delay={(i % 3) * 70}>
<ProductCard product={p} t={t} currency={currency} addedId={addedId} flyCount={flyCount} sizes="sm" onAdd={(x) => { flash(x.id); onAddToCart?.(x, { quantity: 1 }); }} />
</Reveal>
</li>
))}
</ul>
) : (
<div className="grid place-items-center rounded-xl border border-dashed border-border-strong px-6 py-20 text-center">
<Icon name="search" size={22} className="text-muted-foreground" />
<p className="mt-3 text-[15px] text-foreground">{t.noResults}</p>
<Button variant="outline" size="sm" className="mt-5" onClick={() => setActive({})}>
{t.resetFilters}
</Button>
</div>
)}
</div>
</div>
<dialog
ref={sheet}
aria-label={t.filters}
onClick={(e) => e.target === e.currentTarget && sheet.current?.close()}
className="m-0 mt-auto max-h-[88dvh] w-full max-w-none rounded-t-2xl border-t border-border bg-background p-0 text-foreground shadow-[var(--shadow-lg)] backdrop:bg-scrim backdrop:backdrop-blur-[2px] open:flex open:animate-[kitshop-sheet_0.45s_var(--ease-kit)] open:flex-col open:backdrop:animate-[kitshop-fade_0.3s_ease-out] sm:mx-auto sm:max-w-lg"
>
<div className="relative flex items-center justify-between border-b border-border px-5 pb-3 pt-4">
<span className="absolute left-1/2 top-1.5 h-1 w-10 -translate-x-1/2 rounded-full bg-border-strong sm:hidden" aria-hidden />
<h3 className="text-[16px] font-semibold">{t.filters}</h3>
<button type="button" onClick={() => sheet.current?.close()} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={t.close}>
<Icon name="x" size={18} />
</button>
</div>
<div className="kit-scrollbar flex-1 overflow-y-auto px-5 pt-5">
<FilterGroups groups={filters} products={products} active={active} toggle={toggle} idPrefix="sheet" />
</div>
<div className="flex gap-3 border-t border-border px-5 py-4">
<Button variant="outline" className="flex-1" onClick={() => setActive({})}>
{t.clearAll}
</Button>
<Button className="flex-[1.4]" onClick={() => sheet.current?.close()}>
{t.show} {count(t, filtered.length)}
</Button>
</div>
</dialog>
</Section>
);
}
/* ---------- list ---------- */
function ListVariant(props: ProductGridProps & { t: T }) {
const { products = [], t, currency, onAddToCart } = props;
const [addedId, flash, flyCount] = useFlash();
return (
<Section className={props.className}>
<Header {...props} />
<ul className="border-y border-border">
{products.map((p, i) => {
const added = addedId === p.id;
return (
<Reveal as="li" key={p.id} delay={Math.min(i, 5) * 70} className="group/row relative border-b border-border last:border-b-0">
{/* Hover: a soft surface fades in behind the row, the image zooms inside its frame */}
<span aria-hidden className="pointer-events-none absolute inset-y-0 -inset-x-3 rounded-xl bg-surface-2 opacity-0 transition-opacity duration-300 group-hover/row:opacity-100 max-sm:hidden" />
<div className="relative grid grid-cols-[6rem_1fr] gap-x-4 gap-y-4 py-5 sm:grid-cols-[8.5rem_1fr_auto] sm:items-center sm:gap-x-7 sm:py-6 lg:grid-cols-[10rem_1fr_13rem]">
<ProductMedia className="aspect-square rounded-lg sm:row-span-1 [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/row:[&>*]:scale-[1.06] motion-reduce:[&>*]:transition-none">{p.image}</ProductMedia>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{p.category && <span className="text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_70%,var(--muted-foreground))]">{p.category}</span>}
{p.badge && <span className="rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-foreground">{p.badge}</span>}
</div>
<h3 className="mt-1 text-[16px] font-semibold tracking-[-0.01em] text-foreground sm:text-[18px]">
<a href={p.href ?? '#'} className="after:absolute after:inset-0">
{p.name}
</a>
</h3>
{p.description && <p className="mt-1.5 line-clamp-2 max-w-[36rem] text-pretty text-[14px] leading-relaxed text-muted-foreground max-sm:hidden">{p.description}</p>}
{p.subtitle && <p className="mt-1 text-[13px] text-muted-foreground sm:hidden">{p.subtitle}</p>}
{p.rating !== undefined && <Rating value={p.rating} count={p.reviews} size={12} className="mt-2.5" />}
</div>
<div className="col-span-2 flex items-center justify-between gap-4 sm:col-span-1 sm:flex-col sm:items-end sm:justify-center sm:gap-3">
<div className="sm:text-right">
<Price value={p.price} compareAt={p.compareAt} currency={currency} size="lg" className="sm:justify-end" />
<TaxNote className="mt-0.5" />
</div>
<span className="relative z-10">
<Button
size="sm"
variant={added ? 'secondary' : 'primary'}
onClick={() => { flash(p.id); onAddToCart?.(p, { quantity: 1 }); }}
className="h-11 min-h-11 min-w-[9.5rem] overflow-hidden px-4"
>
<Swap value={added} className="gap-2">
<Icon name={added ? 'check' : 'bag'} size={16} />
{added ? t.added : t.add}
</Swap>
</Button>
<FlyUp count={added ? flyCount : 0} />
</span>
</div>
</div>
</Reveal>
);
})}
</ul>
</Section>
);
}
/* ---------- featured ---------- */
function FeaturedVariant(props: ProductGridProps & { t: T }) {
const { products = [], t, currency, onAddToCart } = props;
const hero = props.featured ?? products[0];
const rest = products.filter((p) => p.id !== hero?.id).slice(0, 4);
const [addedId, flash, flyCount] = useFlash();
const locale = useLocale();
if (!hero) return null;
const heroAdded = addedId === hero.id;
return (
<Section className={props.className}>
<Header {...props} />
<div className="grid grid-cols-2 gap-x-3 gap-y-9 sm:gap-x-5 lg:grid-cols-4 lg:grid-rows-[auto_auto] lg:gap-x-6 lg:gap-y-10">
<Reveal effect="scale" className="col-span-2 lg:row-span-2">
<article className="group/hero relative flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
{/* The image drifts a little slower than the page, the frame stays put */}
<ProductMedia className="aspect-[4/3] flex-1 rounded-none lg:aspect-auto lg:min-h-[22rem]">
<div className="absolute inset-x-0 -top-6 !h-[calc(100%+3rem)]">
<Parallax speed={40} className="size-full [&>*]:size-full [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/hero:[&>*]:scale-[1.03] motion-reduce:[&>*]:transition-none">
{hero.images?.[3] ?? hero.image}
</Parallax>
</div>
</ProductMedia>
<div className="absolute left-4 top-4 flex gap-2 sm:left-5 sm:top-5">
{props.featuredLabel && (
<span className="inline-flex h-7 items-center gap-1.5 rounded-full bg-background/90 px-3 text-[12px] font-medium text-foreground shadow-[var(--shadow-sm)] backdrop-blur">
<Icon name="sparkle" size={13} className="text-primary" />
{props.featuredLabel}
</span>
)}
</div>
<div className="flex flex-col gap-5 border-t border-border p-5 sm:flex-row sm:items-end sm:justify-between sm:p-7">
<div className="min-w-0">
{hero.rating !== undefined && <Rating value={hero.rating} count={hero.reviews} size={13} />}
<h3 className="mt-2 text-[22px] font-semibold tracking-[-0.02em] text-foreground sm:text-[26px]">
<a href={hero.href ?? '#'} className="hover:underline hover:decoration-border-strong hover:underline-offset-4">
{hero.name}
</a>
</h3>
{hero.description && <p className="mt-2 max-w-[28rem] text-pretty text-[14px] leading-relaxed text-muted-foreground">{hero.description}</p>}
</div>
<div className="flex shrink-0 flex-col gap-3 sm:items-end">
<Price value={hero.price} compareAt={hero.compareAt} currency={currency} size="xl" />
<Magnetic className="relative">
<Button onClick={() => { flash(hero.id); onAddToCart?.(hero, { quantity: 1 }); }} className="overflow-hidden">
<Swap value={heroAdded} className="gap-2">
<Icon name={heroAdded ? 'check' : 'bag'} size={17} />
{heroAdded ? t.added : `${t.add} · ${formatPrice(locale, hero.price, currency)}`}
</Swap>
</Button>
<FlyUp count={heroAdded ? flyCount : 0} />
</Magnetic>
</div>
</div>
</article>
</Reveal>
{rest.map((p, i) => (
<Reveal key={p.id} delay={120 + i * 80}>
<ProductCard product={p} t={t} currency={currency} addedId={addedId} flyCount={flyCount} sizes="sm" aspect="aspect-square" onAdd={(x) => { flash(x.id); onAddToCart?.(x, { quantity: 1 }); }} />
</Reveal>
))}
</div>
</Section>
);
}
/* ---------- category-tiles ---------- */
function CategoryVariant(props: ProductGridProps & { t: T }) {
const { categories = [], t } = props;
const locale = useLocale();
return (
<Section className={props.className}>
<Header {...props} />
<ul className="grid auto-rows-[13rem] grid-cols-2 gap-3 sm:auto-rows-[15rem] sm:gap-4 lg:auto-rows-[17rem] lg:grid-cols-4 lg:gap-5">
{categories.slice(0, 5).map((c, i) => (
<Reveal as="li" key={c.name} effect="scale" delay={i * 80} className={cn(i === 0 && 'col-span-2 row-span-2')}>
<a href={c.href} className="group/tile relative flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-surface-2 shadow-[var(--highlight),var(--shadow-sm)] outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]">
{/* Scroll: the image drifts inside the tile. Hover: it zooms a little. */}
<div className="absolute inset-x-0 -top-5 h-[calc(100%+2.5rem)]">
<Parallax speed={i === 0 ? 36 : 28} className="size-full">
<div className="size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/tile:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{c.image}</div>
</Parallax>
</div>
<div className={cn('relative p-4 transition-transform duration-500 ease-[var(--ease-kit)] group-hover/tile:translate-x-1 motion-reduce:transition-none sm:p-5', i === 0 && 'lg:p-7')}>
<h3 className={cn('font-semibold tracking-[-0.015em] text-foreground', i === 0 ? 'text-[19px] sm:text-[24px]' : 'text-[15px] sm:text-[16px]')}>{c.name}</h3>
{c.count !== undefined && <p className="mt-0.5 text-[12px] text-muted-foreground sm:text-[13px]">{formatNumber(locale, c.count)} {t.products}</p>}
{i === 0 && c.description && <p className="mt-2 max-w-[15rem] text-pretty text-[14px] text-muted-foreground max-sm:hidden">{c.description}</p>}
</div>
<span className={cn('absolute bottom-3 right-3 grid place-items-center rounded-full bg-foreground text-background shadow-[var(--shadow-md)] transition-transform duration-300 group-hover/tile:-rotate-45 motion-reduce:transition-none sm:bottom-4 sm:right-4', i === 0 ? 'size-11' : 'size-8 sm:size-10')} aria-hidden>
<Icon name="arrow-right" size={i === 0 ? 17 : 15} />
</span>
</a>
</Reveal>
))}
</ul>
</Section>
);
}
/* ---------- quick-view ---------- */
function QuickView({ product, t, currency, onClose, onAdd, dialogRef }: { product: ShopProduct | null; t: T; currency?: string; onClose: () => void; onAdd?: ProductGridProps['onAddToCart']; dialogRef: React.RefObject<HTMLDialogElement | null> }) {
const [color, setColor] = useState('');
const [size, setSize] = useState('');
const [qty, setQty] = useState(1);
const [img, setImg] = useState(0);
const [added, setAdded] = useState(false);
const [fly, setFly] = useState(0);
useEffect(() => {
setColor(product?.colors?.[0]?.name ?? '');
setSize(defaultSize(product?.sizes));
setQty(1);
setImg(0);
setAdded(false);
}, [product]);
const p = product;
const colorImage = p?.colors?.find((c) => c.name === color)?.image;
const gallery = p ? (p.images?.length ? p.images : [p.image]) : [];
const main = img === 0 && colorImage ? colorImage : gallery[img];
return (
<dialog
ref={dialogRef}
onClose={onClose}
onClick={(e) => e.target === e.currentTarget && dialogRef.current?.close()}
aria-labelledby="qv-title"
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-border bg-background p-0 text-foreground shadow-[var(--shadow-lg)] backdrop:bg-scrim backdrop:backdrop-blur-[3px] open:animate-[kitshop-sheet_0.45s_var(--ease-kit)] open:backdrop:animate-[kitshop-fade_0.3s_ease-out] sm:m-auto sm:max-h-[min(44rem,90dvh)] sm:max-w-[min(58rem,calc(100vw-3rem))] sm:rounded-2xl sm:open:animate-[kitshop-zoom_0.42s_var(--ease-kit)]"
>
<ShopMotion />
{p && (
<div className="kit-scrollbar grid max-h-[inherit] grid-cols-1 overflow-y-auto sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:overflow-hidden">
<div className="relative bg-surface-2 sm:max-h-[inherit]">
{/* A new color or thumbnail crossfades the main image */}
<ProductMedia className="aspect-square rounded-none sm:aspect-auto sm:h-full sm:min-h-[30rem]">
<div key={`${img}-${color}`} className="animate-[kitshop-fade_0.45s_var(--ease-kit)] [&>*]:size-full">
{main}
</div>
</ProductMedia>
{gallery.length > 1 && (
<div className="flex justify-center gap-2 py-3 sm:absolute sm:inset-x-0 sm:bottom-4 sm:py-0">
{gallery.slice(0, 4).map((g, i) => (
<button key={i} type="button" onClick={() => setImg(i)} aria-label={`${i + 1} / ${gallery.length}`} aria-current={img === i} className={cn('size-12 overflow-hidden rounded-lg border-2 bg-background shadow-[var(--shadow-sm)] transition-[border-color,transform] [&>*]:size-full', img === i ? 'border-foreground' : 'border-transparent opacity-80 hover:opacity-100')}>
{g}
</button>
))}
</div>
)}
</div>
<div className="kit-scrollbar flex flex-col p-5 sm:max-h-[min(44rem,90dvh)] sm:overflow-y-auto sm:p-8">
<div className="flex items-start justify-between gap-4">
<div>
{p.category && <p className="text-[12px] font-medium text-muted-foreground">{p.category}</p>}
<h2 id="qv-title" className="mt-1 text-[22px] font-semibold tracking-[-0.02em] sm:text-[26px]">
{p.name}
</h2>
</div>
<button type="button" onClick={() => dialogRef.current?.close()} className="-mr-2 -mt-1 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={t.close}>
<Icon name="x" size={18} />
</button>
</div>
{p.rating !== undefined && <Rating value={p.rating} count={p.reviews} className="mt-2" />}
<div className="mt-5">
<Price value={p.price} compareAt={p.compareAt} currency={currency} size="xl" />
<TaxNote className="mt-1" />
</div>
{p.description && <p className="mt-5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{p.description}</p>}
{p.colors && p.colors.length > 1 && (
<fieldset className="mt-6">
<legend className="mb-2 text-[13px] text-muted-foreground">
<span className="font-medium text-foreground">{color}</span>
</legend>
<SwatchGroup colors={p.colors} value={color} onChange={(c) => { setColor(c); setImg(0); }} name={`qv-color-${p.id}`} />
</fieldset>
)}
{p.sizes && (
<fieldset className="mt-6">
<legend className="mb-2 text-[13px] font-medium text-foreground">{t.size}</legend>
<SizeGroup sizes={p.sizes} value={size} onChange={setSize} name={`qv-size-${p.id}`} className="sm:grid-cols-5" />
</fieldset>
)}
<div className="mt-7 flex gap-3 sm:mt-auto sm:pt-7">
<Quantity value={qty} onChange={setQty} className="shrink-0" />
<span className="relative flex min-w-0 flex-1">
<Button className="min-w-0 flex-1 overflow-hidden" onClick={() => { setAdded(true); setFly((n) => n + 1); onAdd?.(p, { color, size, quantity: qty }); }}>
<Swap value={added} className="min-w-0 gap-2">
<Icon name={added ? 'check' : 'bag'} size={17} className="shrink-0" />
{added ? (
t.added
) : (
<span className="truncate">
{t.add}
{/* Below desktop the button keeps only its words, the price stands right above it */}
<span className="max-lg:hidden">
{' · '}
<TweenPrice value={p.price * qty} currency={currency} />
</span>
</span>
)}
</Swap>
</Button>
<FlyUp count={fly} label={`+${qty}`} />
</span>
</div>
<a href={p.href ?? '#'} className="group mt-4 inline-flex h-11 items-center justify-center gap-1.5 text-[13px] font-medium text-muted-foreground hover:text-foreground">
{t.viewDetails}
<Icon name="arrow-right" size={14} className="transition-transform group-hover:translate-x-0.5" />
</a>
</div>
</div>
)}
</dialog>
);
}
function QuickViewVariant(props: ProductGridProps & { t: T }) {
const { products = [], t, currency, onAddToCart } = props;
const [current, setCurrent] = useState<ShopProduct | null>(null);
const dialog = useRef<HTMLDialogElement>(null);
const open = (p: ShopProduct) => {
setCurrent(p);
requestAnimationFrame(() => dialog.current?.showModal());
};
useEffect(() => {
const p = products.find((x) => x.id === props.initialQuickView);
if (p) open(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Section className={props.className}>
<Header {...props} />
<ul className="grid grid-cols-2 gap-x-3 gap-y-9 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
{products.slice(0, 8).map((p, i) => (
<Reveal as="li" key={p.id} delay={(i % 4) * 60}>
<ProductCard
product={p}
t={t}
currency={currency}
sizes="sm"
action={
<button
type="button"
onClick={() => open(p)}
aria-haspopup="dialog"
aria-label={`${t.quickView}: ${p.name}`}
className="absolute bottom-3 right-3 z-10 flex h-9 items-center gap-1.5 rounded-full bg-background/92 px-3 text-[12px] font-medium text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-[opacity,transform] duration-300 after:absolute after:-inset-1 hover:bg-background lg:left-3 lg:h-11 lg:translate-y-2 lg:justify-center lg:rounded-lg lg:text-[13px] lg:opacity-0 lg:group-hover/card:translate-y-0 lg:group-hover/card:opacity-100 lg:group-focus-within/card:translate-y-0 lg:group-focus-within/card:opacity-100 motion-reduce:transition-none"
>
<Icon name="eye" size={15} />
<span className="max-sm:sr-only">{t.quickView}</span>
</button>
}
/>
</Reveal>
))}
</ul>
<QuickView product={current} t={t} currency={currency} onClose={() => {}} onAdd={onAddToCart} dialogRef={dialog} />
</Section>
);
}
/* ---------- carousel-row ---------- */
function CarouselVariant(props: ProductGridProps & { t: T }) {
const { products = [], t, currency, onAddToCart } = props;
const track = useRef<HTMLUListElement>(null);
const [pos, setPos] = useState({ start: true, end: false, progress: 0, view: 0.3 });
const [addedId, flash, flyCount] = useFlash();
// Mouse drag scrolls the row like a touch swipe. Snapping pauses while dragging,
// and a drag never counts as a click on a card.
const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
const [dragging, setDragging] = useState(false);
const onDown = (e: React.PointerEvent<HTMLUListElement>) => {
if (e.pointerType !== 'mouse' || e.button !== 0 || !track.current) return;
drag.current = { x: e.clientX, left: track.current.scrollLeft, moved: false };
};
const onMove = (e: React.PointerEvent<HTMLUListElement>) => {
const d = drag.current;
const el = track.current;
if (!d || !el) return;
const dx = e.clientX - d.x;
if (!d.moved && Math.abs(dx) > 5) {
d.moved = true;
setDragging(true);
el.setPointerCapture(e.pointerId);
}
if (d.moved) el.scrollLeft = d.left - dx;
};
const onUp = () => {
const moved = drag.current?.moved;
drag.current = null;
if (!moved) return;
// Let the click that ends the drag pass by, then snap again
requestAnimationFrame(() => setDragging(false));
};
const measure = () => {
const el = track.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setPos({ start: el.scrollLeft <= 2, end: el.scrollLeft >= max - 2, progress: max > 0 ? el.scrollLeft / max : 0, view: el.clientWidth / el.scrollWidth });
};
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
const go = (dir: 1 | -1) => {
const el = track.current;
const card = el?.querySelector('li');
if (!el || !card) return;
const step = (card.getBoundingClientRect().width + 24) * Math.max(1, Math.floor(el.clientWidth / card.getBoundingClientRect().width) - 1);
el.scrollBy({ left: dir * step, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
};
const arrow = (dir: 1 | -1) => (
<button
type="button"
onClick={() => go(dir)}
disabled={dir < 0 ? pos.start : pos.end}
aria-label={dir < 0 ? t.previous : t.next}
className="grid size-11 place-items-center rounded-full border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-[background-color,opacity] hover:bg-accent disabled:pointer-events-none disabled:opacity-35"
>
<Icon name={dir < 0 ? 'arrow-left' : 'arrow-right'} size={17} />
</button>
);
const thumb = Math.max(0.12, Math.min(1, pos.view));
return (
<Section bleed className={cn('overflow-x-clip', props.className)}>
<div className="kit-container">
<div className="mb-10 flex items-end justify-between gap-6 sm:mb-12">
<div className="max-w-[40rem]">
{props.eyebrow && <p className="mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>
{props.description && <p className="mt-4 text-pretty text-lead text-muted-foreground">{props.description}</p>}
</div>
<div className="flex shrink-0 items-center gap-2 max-sm:hidden">
{props.action && (
<a href={props.action.href} className="mr-3 text-[14px] font-medium text-foreground underline-offset-4 hover:underline">
{props.action.label}
</a>
)}
{arrow(-1)}
{arrow(1)}
</div>
</div>
</div>
<div className="kit-fade-x">
<ul
ref={track}
onScroll={measure}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onClickCapture={(e) => dragging && (e.preventDefault(), e.stopPropagation())}
onDragStart={(e) => e.preventDefault()}
tabIndex={0}
aria-label={t.carousel}
className={cn(
"kit-no-scrollbar flex gap-4 overflow-x-auto px-[max(1.5rem,calc((100%-1200px)/2))] pb-2 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[var(--ring)] motion-reduce:scroll-auto sm:gap-6 lg:cursor-grab lg:scroll-px-[max(2rem,calc((100%-1200px)/2))] lg:px-[max(2rem,calc((100%-1200px)/2))] scroll-px-[max(1.5rem,calc((100%-1200px)/2))] after:w-px after:shrink-0 after:content-['']",
dragging ? 'cursor-grabbing select-none' : 'snap-x snap-mandatory scroll-smooth',
)}
>
{products.map((p, i) => (
<Reveal as="li" key={p.id} delay={Math.min(i, 5) * 80} className="w-[68%] shrink-0 snap-start sm:w-[38%] lg:w-[calc((min(100vw-4rem,1200px)-72px)/4)]">
<ProductCard product={p} t={t} currency={currency} addedId={addedId} flyCount={flyCount} sizes="sm" onAdd={(x) => { flash(x.id); onAddToCart?.(x, { quantity: 1 }); }} />
</Reveal>
))}
</ul>
</div>
<div className="kit-container mt-8 flex items-center gap-6">
<div className="relative h-0.5 flex-1 overflow-hidden rounded-full bg-border" aria-hidden>
<div className="absolute inset-y-0 left-0 rounded-full bg-foreground transition-transform duration-150" style={{ width: `${Math.round(thumb * 100)}%`, transform: `translateX(${Math.round((pos.progress * (1 - thumb) * 10000) / thumb) / 100}%)` }} />
</div>
<div className="flex gap-2 sm:hidden">
{arrow(-1)}
{arrow(1)}
</div>
</div>
</Section>
);
}
/* ---------- lookbook ---------- */
function LookbookVariant(props: ProductGridProps & { t: T }) {
const { products = [], lookbook, t, currency, onAddToCart } = props;
const [active, setActive] = useState<string | null>(null);
const [addedId, flash, flyCount] = useFlash();
const locale = useLocale();
// Scroll: the image grows from slightly smaller to full size, then the spots pop in one by one
const grow = useScrollProgress<HTMLDivElement>({ start: 0, end: 0.45 });
const [pins, pinsIn] = useInView<HTMLDivElement>('0px 0px -25% 0px');
if (!lookbook) return null;
const items = lookbook.spots.map((s, i) => ({ ...s, n: i + 1, product: products.find((p) => p.id === s.productId)! })).filter((x) => x.product);
const total = items.reduce((a, x) => a + x.product.price, 0);
return (
<Section className={props.className}>
<div className="grid gap-10 lg:grid-cols-[1.25fr_1fr] lg:items-center lg:gap-16">
<Reveal effect="fade" className="relative w-full sm:max-lg:mx-auto sm:max-lg:max-w-[32rem]">
<figure ref={grow}>
<div ref={pins} className="kit-scroll-scale relative aspect-[4/5] overflow-hidden rounded-2xl border border-border bg-surface-2 shadow-[var(--highlight),var(--shadow-lg)] [&>*:first-child]:size-full">
{lookbook.image}
{items.map((x, i) => {
const on = active === x.productId;
return (
<div
key={x.productId}
className={cn('absolute transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !pinsIn && 'scale-50 opacity-0')}
style={{ left: `${x.x}%`, top: `${x.y}%`, transitionDelay: pinsIn ? `${200 + i * 120}ms` : '0ms' }}
>
<button
type="button"
onMouseEnter={() => setActive(x.productId)}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive(x.productId)}
onBlur={() => setActive(null)}
onClick={() => {
// On touch there is no hover, a tap marks the piece in the list
setActive(x.productId);
document.getElementById(`look-${x.productId}`)?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}}
aria-label={`${x.n}. ${x.product.name}`}
className={cn(
'relative -ml-[18px] -mt-[18px] grid size-9 place-items-center rounded-full text-[12px] font-semibold shadow-[0_0_0_3px_rgb(255_255_255/0.55),0_6px_16px_-4px_rgb(0_0_0/0.45)] transition-[transform,background-color,color] duration-200 after:absolute after:-inset-1 hover:scale-110 motion-reduce:transition-none',
on ? 'bg-primary text-primary-foreground' : 'bg-foreground text-background',
)}
>
<span className={cn('absolute inset-0 rounded-full bg-[rgb(255_255_255/0.5)]', on ? 'opacity-0' : 'animate-ping motion-reduce:animate-none [animation-duration:2.4s]')} aria-hidden />
<span className="relative">{x.n}</span>
</button>
<div
className={cn(
'pointer-events-none absolute top-6 z-10 w-52 rounded-xl border border-border bg-popover p-3 text-left shadow-[var(--shadow-lg)] transition-[opacity,transform] duration-200 max-sm:hidden',
// Near an edge the card opens inwards, so the frame never cuts it off
x.x < 30 ? '-left-3' : x.x > 70 ? '-right-3' : 'left-1/2 -translate-x-1/2',
on ? 'translate-y-0 opacity-100' : '-translate-y-1 opacity-0',
)}
aria-hidden
>
<p className="text-[13px] font-medium text-foreground">{x.product.name}</p>
<p className="mt-0.5 text-[12px] text-muted-foreground">{x.product.subtitle}</p>
<Price value={x.product.price} compareAt={x.product.compareAt} currency={currency} size="sm" className="mt-1.5" />
</div>
</div>
);
})}
</div>
{lookbook.caption && <figcaption className="mt-3 text-[12px] text-muted-foreground">{lookbook.caption}</figcaption>}
</figure>
</Reveal>
<div>
{props.eyebrow && <p className="mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>
{props.description && <p className="mt-5 text-pretty text-lead text-muted-foreground">{props.description}</p>}
<h3 className="mb-1 mt-10 text-[13px] font-medium text-muted-foreground">{t.shopTheLook}</h3>
<ol className="border-t border-border">
{items.map((x) => {
const on = active === x.productId;
const added = addedId === x.productId;
return (
<li
key={x.productId}
id={`look-${x.productId}`}
onMouseEnter={() => setActive(x.productId)}
onMouseLeave={() => setActive(null)}
className={cn('relative -mx-3 flex items-center gap-4 rounded-xl border-b border-transparent px-3 py-3.5 transition-colors', on && 'bg-surface-2')}
>
<span className={cn('absolute inset-x-3 bottom-0 h-px bg-border', on && 'opacity-0')} aria-hidden />
<span className={cn('grid size-7 shrink-0 place-items-center rounded-full border text-[12px] font-semibold tabular-nums transition-colors', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-muted-foreground')}>{x.n}</span>
<ProductMedia className="size-14 shrink-0 rounded-lg">{x.product.image}</ProductMedia>
<div className="min-w-0 flex-1">
<a href={x.product.href ?? '#'} className="block truncate text-[14px] font-medium text-foreground hover:underline hover:underline-offset-4">
{x.product.name}
</a>
<Price value={x.product.price} compareAt={x.product.compareAt} currency={currency} size="sm" className="mt-0.5 [&>span]:font-normal [&>span]:text-muted-foreground" />
</div>
<button
type="button"
onClick={() => { flash(x.productId); onAddToCart?.(x.product, { quantity: 1 }); }}
aria-label={`${t.add}: ${x.product.name}`}
className={cn('relative grid size-11 shrink-0 place-items-center rounded-full border transition-colors duration-300', added ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:bg-accent')}
>
<Swap value={added}>
<Icon name={added ? 'check' : 'plus'} size={16} />
</Swap>
<FlyUp count={added ? flyCount : 0} className="-top-3" />
</button>
</li>
);
})}
</ol>
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
<Magnetic className="relative">
<Button size="lg" className="overflow-hidden" onClick={() => { flash('look'); items.forEach((x) => onAddToCart?.(x.product, { quantity: 1 })); }}>
<Swap value={addedId === 'look'} className="gap-2">
<Icon name={addedId === 'look' ? 'check' : 'bag'} size={17} />
{addedId === 'look' ? t.added : `${t.addLook} · ${formatPrice(locale, total, currency)}`}
</Swap>
</Button>
<FlyUp count={addedId === 'look' ? flyCount : 0} label={`+${items.length}`} />
</Magnetic>
<TaxNote className="sm:ml-2" />
</div>
</div>
</div>
</Section>
);
}
/**
* Products in a grid, with filters, as a list, featured, as category tiles,
* with a quick view dialog, in a sideways row or placed on a lookbook image.
*/
export function ProductGrid(props: ProductGridProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'grid' } = props;
if (variant === 'with-filters') return <FiltersVariant {...props} t={t} />;
if (variant === 'list') return <ListVariant {...props} t={t} />;
if (variant === 'featured') return <FeaturedVariant {...props} t={t} />;
if (variant === 'category-tiles') return <CategoryVariant {...props} t={t} />;
if (variant === 'quick-view') return <QuickViewVariant {...props} t={t} />;
if (variant === 'carousel-row') return <CarouselVariant {...props} t={t} />;
if (variant === 'lookbook') return <LookbookVariant {...props} t={t} />;
return <GridVariant {...props} t={t} />;
}Frequent questions
How do I install Product grid?
With aiw add @ai-website/product-grid. The command puts components/ui/product-grid.tsx into your project, the code is yours from then on. It brings along button, icon, section, reveal and product-image. It needs no further npm packages.
Which variants does Product grid have?
8 variants, Grid, With filters, List, Featured, Category tiles, Quick view, Carousel row and Lookbook. You pick one with the variant prop, for example variant="with-filters".
Can I translate the texts of Product grid?
Yes. Product grid carries 34 texts of its own in English and German, such as “Add to cart”, “Added” and “Quick add”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Product grid?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Product grid with aiw_components and install it with aiw_add @ai-website/product-grid. Name the variant in your prompt, for example variant="grid", and the assistant writes your content straight into the props.