Zum Inhalt springen
UI Kit
Accent

Produktraster

Produkte als Raster, Liste, Reihe und Lookbook, mit Filtern, Schnellansicht und Kategorien.

8 VariantsEine von 4 Components in Products

Variants

Raster

Vier Spalten mit Kategorie-Pillen, zweitem Bild beim Überfahren und Schnellkauf.

Mit Filtern

Filterspalte mit Zählern, Chips und Sortierung. Auf dem Telefon öffnen die Filter als Sheet.

Liste

Zeilen mit Beschreibung, Bewertung, Preis und direktem Kaufknopf.

Kategorie-Kacheln

Eine große und vier kleine Kacheln, die in die Abteilungen führen.

Schnellansicht

Ein Dialog mit Galerie, Farbe, Größe und Menge, ohne das Raster zu verlassen.

Lookbook

Ein redaktionelles Bild mit nummerierten Punkten und den passenden Produkten daneben.

Details

Was Produktraster mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Products
Bereich
E-Commerce
Variants
8
Version
1.0.1 · seit 1.0.0
Datei
components/ui/product-grid.tsx
Bringt mit
utilskitbuttoniconsectionrevealproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 34 eigene Texte
Studio
Im Code anpassen
In Sammlungen
Online-Shop

Props

Die Schnittstelle ProductGridProps aus components/ui/product-grid.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Produktraster selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 34 Texte anzeigen
addEnglisch Add to cartDeutsch In den Warenkorb
addedEnglisch AddedDeutsch Hinzugefügt
quickAddEnglisch Quick addDeutsch Schnell hinzufügen
wishlistEnglisch Save to wishlistDeutsch Auf die Wunschliste
savedEnglisch Saved to wishlistDeutsch Auf der Wunschliste
quickViewEnglisch Quick viewDeutsch Schnellansicht
closeEnglisch CloseDeutsch Schließen
allEnglisch AllDeutsch Alle
filtersEnglisch FiltersDeutsch Filter
sortEnglisch Sort byDeutsch Sortieren nach
sortFeaturedEnglisch FeaturedDeutsch Empfohlen
sortPriceAscEnglisch Price, low to highDeutsch Preis aufsteigend
sortPriceDescEnglisch Price, high to lowDeutsch Preis absteigend
sortRatingEnglisch Best ratedDeutsch Beste Bewertung
productEnglisch productDeutsch Produkt
productsEnglisch productsDeutsch Produkte
clearAllEnglisch Clear allDeutsch Alle entfernen
showEnglisch ShowDeutsch Zeige
removeEnglisch Remove filterDeutsch Filter entfernen
noResultsEnglisch Nothing matches these filters.Deutsch Zu diesen Filtern passt nichts.
resetFiltersEnglisch Reset filtersDeutsch Filter zurücksetzen
previousEnglisch Previous productsDeutsch Vorherige Produkte
nextEnglisch Next productsDeutsch Nächste Produkte
shopTheLookEnglisch Shop the lookDeutsch Den Look kaufen
addLookEnglisch Add the whole lookDeutsch Ganzen Look hinzufügen
viewDetailsEnglisch View full detailsDeutsch Alle Details ansehen
loadMoreEnglisch Load moreDeutsch Mehr laden
showingEnglisch ShowingDeutsch Zeige
ofEnglisch ofDeutsch von
soldOutEnglisch Sold outDeutsch Ausverkauft
sizeEnglisch SizeDeutsch Größe
shopEnglisch ShopDeutsch Ansehen
moreEnglisch moreDeutsch weitere
carouselEnglisch Products, scroll sidewaysDeutsch Produkte, seitlich scrollen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Produktraster reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
  • Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
  • Setzt aria-current, aria-haspopup, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen tab und tablist.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 16 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.234 Zeilen
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} />;
}

Häufige Fragen

Wie installiere ich Produktraster?

Mit aiw add @ai-website/product-grid. Der Befehl legt die Datei components/ui/product-grid.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, section, reveal und product-image. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Produktraster?

8 Varianten, Raster, Mit Filtern, Liste, Hervorgehoben, Kategorie-Kacheln, Schnellansicht, Karussell-Reihe und Lookbook. Du wählst sie mit der Prop variant, zum Beispiel variant="with-filters".

Kann ich die Texte von Produktraster übersetzen?

Ja. Produktraster bringt 34 eigene Texte auf Deutsch und Englisch mit, etwa „In den Warenkorb“, „Hinzugefügt“ und „Schnell hinzufügen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Produktraster einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Produktraster mit aiw_components und installieren sie mit aiw_add @ai-website/product-grid. Nenn im Prompt die Variante, etwa variant="grid", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert