Skip to content
UI Kit
Accent color

Product grid

Products in grids, lists, rows and editorial layouts, with filters, quick view and categories.

8 variantsOne of 4 components in Products

Variants

Grid

Four columns with category pills, a second image on hover and quick add.

With filters

A filter sidebar with counts, chips and sorting. On phones the filters open as a sheet.

List

Rows with description, rating, price and a direct buy button.

Category tiles

One large and four small tiles that lead into the departments.

Quick view

A dialog with gallery, color, size and quantity, without leaving the grid.

Lookbook

An editorial image with numbered spots and the matching products next to it.

Details

What Product grid brings along, read from the registry and the source.

Category
Products
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

Props

The ProductGridProps interface from components/ui/product-grid.tsx, with the comments from the code.

  • variantoptional
    ProductGridVariant

    gridwith-filterslistfeaturedcategory-tilesquick-viewcarousel-rowlookbook

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • productsoptional
    ShopProduct[]
  • actionoptional
    { label: string; href: string }

    Link next to the heading, e.g. "Shop all"

  • categoriesoptional
    ProductCategory[]

    category-tiles

  • filtersoptional
    FilterGroup[]

    with-filters

  • featuredoptional
    ShopProduct

    featured: the product in the big card, defaults to the first product

  • featuredLabeloptional
    string

    featured: small label on the big card

  • lookbookoptional
    { image: React.ReactNode; spots: LookbookSpot[]; caption?: string }

    lookbook: the editorial image and the products placed on it

  • initialQuickViewoptional
    string

    quick-view: open this product's dialog on load (for previews)

  • currencyoptional
    string
  • onAddToCartoptional
    (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
addEnglish Add to cartGerman In den Warenkorb
addedEnglish AddedGerman Hinzugefügt
quickAddEnglish Quick addGerman Schnell hinzufügen
wishlistEnglish Save to wishlistGerman Auf die Wunschliste
savedEnglish Saved to wishlistGerman Auf der Wunschliste
quickViewEnglish Quick viewGerman Schnellansicht
closeEnglish CloseGerman Schließen
allEnglish AllGerman Alle
filtersEnglish FiltersGerman Filter
sortEnglish Sort byGerman Sortieren nach
sortFeaturedEnglish FeaturedGerman Empfohlen
sortPriceAscEnglish Price, low to highGerman Preis aufsteigend
sortPriceDescEnglish Price, high to lowGerman Preis absteigend
sortRatingEnglish Best ratedGerman Beste Bewertung
productEnglish productGerman Produkt
productsEnglish productsGerman Produkte
clearAllEnglish Clear allGerman Alle entfernen
showEnglish ShowGerman Zeige
removeEnglish Remove filterGerman Filter entfernen
noResultsEnglish Nothing matches these filters.German Zu diesen Filtern passt nichts.
resetFiltersEnglish Reset filtersGerman Filter zurücksetzen
previousEnglish Previous productsGerman Vorherige Produkte
nextEnglish Next productsGerman Nächste Produkte
shopTheLookEnglish Shop the lookGerman Den Look kaufen
addLookEnglish Add the whole lookGerman Ganzen Look hinzufügen
viewDetailsEnglish View full detailsGerman Alle Details ansehen
loadMoreEnglish Load moreGerman Mehr laden
showingEnglish ShowingGerman Zeige
ofEnglish ofGerman von
soldOutEnglish Sold outGerman Ausverkauft
sizeEnglish SizeGerman Größe
shopEnglish ShopGerman Ansehen
moreEnglish moreGerman weitere
carouselEnglish Products, scroll sidewaysGerman 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
components/ui/product-grid.tsx
'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.

Often searched

Jump to

Copied to the clipboard