Zum Inhalt springen
UI Kit
Accent

Kollektionskopf

Der Kopf einer Kollektions- oder Kategorieseite. Ein Titel auf einem Foto, Text mit Weiterlesen und Unterkategorien, Tabs mit klebender Leiste für Sortierung und Ansicht, eine Kollektion als Magazingeschichte und die Seite einer Manufaktur.

Eine von 2 Components in Kategorien und Kollektionen

5 Variants

Variants

Fotobanner

Der Titel auf einem breiten Foto, das beim Scrollen treibt, mit einer Schattierung von unten, die ihn in beiden Modi lesbar hält. Brotkrumen darüber, Anzahl und Link auf dem Foto, darunter Unterkategorie-Chips, die nacheinander aufsteigen und auf dem Telefon scrollen.

Im Studio öffnen

Text und Foto

Titel, Anzahl und ein kurzer Text neben einem Foto. „Weiterlesen“ öffnet den langen Text mit weicher Höhe, gemacht für Menschen wie für Suchmaschinen, darunter steigen Unterkategorien mit Vorschaubild auf und heben sich beim Hover.

Im Studio öffnen

Tabs und klebende Leiste

Unterkategorien als Tabs mit gleitender Unterstreichung und einem Titel, der dem Tab folgt. Darunter klebt eine Leiste mit Anzahl, Filtern, Sortierung und Raster- oder Listenschalter oben, während die Produkte scrollen, und bekommt dann einen weichen Schatten.

Im Studio öffnen

Kollektion als Geschichte

Eine Kollektion wie im Magazin erzählt. Ein sehr großer Titel, Einleitung und Link, ein breites Foto, das beim Hereinscrollen wächst und treibt, darunter Fakten in einem Haarlinienraster, die nacheinander aufsteigen, und ein Zitat der Macherin.

Im Studio öffnen

Manufakturseite

Die Seite einer Marke oder Manufaktur auf einem Marktplatz. Ein Titelbild, das treibt, das Zeichen darüber, Name mit Verifiziert-Abzeichen, Ort und Jahr, Folgen mit steigender Zahl und die Zahlen zu Produkten, Folgenden und Bewertung.

Im Studio öffnen

Details

Was Kollektionskopf mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/collection-header.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionbreadcrumbsselectproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 17 eigene Texte

Props

Die Schnittstelle CollectionHeaderProps aus components/ui/collection-header.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CollectionHeaderVariant

    bannersplittoolbareditorialbrand

  • eyebrowoptional
    string
  • titlePflicht
    string
  • descriptionoptional
    string
  • moreoptional
    string

    split: the long text under "Read more", for search engines and the curious

  • crumbsoptional
    Crumb[]
  • countoptional
    number

    Number of products in the collection

  • imageoptional
    React.ReactNode
  • linksoptional
    CollectionLink[]
  • sortsoptional
    { value: string; label: string }[]

    toolbar: the sort options

  • factsoptional
    { value: string; label: string }[]

    editorial: facts as value and label

  • quoteoptional
    { text: string; author: string; role?: string; avatar?: React.ReactNode }

    editorial: a quote with its author

  • brandoptional
    { name: string; place?: string; since?: string; mark?: React.ReactNode; followers?: number; rating?: number; verified?: boolean }

    brand

  • actionoptional
    { label: string; href: string }

    banner and brand: a call to action

  • childrenoptional
    React.ReactNode

    toolbar: the products under the bar, e.g. a product grid. Gets the view as a data attribute.

  • onSortoptional
    (value: string) => void
  • onViewoptional
    (view: 'grid' | 'list') => void
  • onFiltersoptional
    () => void

Eingebaute Texte

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

Alle 17 Texte anzeigen
productsEnglisch productsDeutsch Produkte
productEnglisch productDeutsch Produkt
readMoreEnglisch Read moreDeutsch Weiterlesen
readLessEnglisch Show lessDeutsch Weniger anzeigen
sortEnglisch Sort byDeutsch Sortieren nach
viewEnglisch ViewDeutsch Ansicht
gridEnglisch GridDeutsch Raster
listEnglisch ListDeutsch Liste
filtersEnglisch FiltersDeutsch Filter
allEnglisch AllDeutsch Alle
followEnglisch FollowDeutsch Folgen
followingEnglisch FollowingDeutsch Du folgst
followersEnglisch followersDeutsch Folgende
sinceEnglisch SinceDeutsch Seit
verifiedEnglisch Verified makerDeutsch Verifizierte Manufaktur
ratingEnglisch ratingDeutsch Bewertung
subcategoriesEnglisch SubcategoriesDeutsch Unterkategorien

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-checked, aria-controls, aria-expanded, aria-label, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup, tab und tablist.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • 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. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 517 Zeilen
components/ui/collection-header.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { Breadcrumbs, type Crumb } from '@/components/ui/breadcrumbs';
import { NativeSelect } from '@/components/ui/select';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';

/*
 * The top of a collection or category page in a shop.
 *
 *   banner     a photo band with the title on it, breadcrumbs and the count, the photo drifts while scrolling
 *   split      title, a description that opens to its full length and subcategory links beside a photo
 *   toolbar    title with subcategory tabs and a bar for count, sort, view and filters that sticks
 *   editorial  a collection told like a magazine: big title, intro, facts and a quote from the maker
 *   brand      the page of a brand or maker: cover, mark, place, follow, and their numbers
 */

export type CollectionHeaderVariant = 'banner' | 'split' | 'toolbar' | 'editorial' | 'brand';

export interface CollectionLink {
  label: string;
  href: string;
  count?: number;
  image?: React.ReactNode;
}

export interface CollectionHeaderProps {
  variant?: CollectionHeaderVariant;
  eyebrow?: string;
  title: string;
  description?: string;
  /** split: the long text under "Read more", for search engines and the curious */
  more?: string;
  crumbs?: Crumb[];
  /** Number of products in the collection */
  count?: number;
  image?: React.ReactNode;
  links?: CollectionLink[];
  /** toolbar: the sort options */
  sorts?: { value: string; label: string }[];
  /** editorial: facts as value and label */
  facts?: { value: string; label: string }[];
  /** editorial: a quote with its author */
  quote?: { text: string; author: string; role?: string; avatar?: React.ReactNode };
  /** brand */
  brand?: { name: string; place?: string; since?: string; mark?: React.ReactNode; followers?: number; rating?: number; verified?: boolean };
  /** banner and brand: a call to action */
  action?: { label: string; href: string };
  /** toolbar: the products under the bar, e.g. a product grid. Gets the view as a data attribute. */
  children?: React.ReactNode;
  onSort?: (value: string) => void;
  onView?: (view: 'grid' | 'list') => void;
  onFilters?: () => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    products: 'products',
    product: 'product',
    readMore: 'Read more',
    readLess: 'Show less',
    sort: 'Sort by',
    view: 'View',
    grid: 'Grid',
    list: 'List',
    filters: 'Filters',
    all: 'All',
    follow: 'Follow',
    following: 'Following',
    followers: 'followers',
    since: 'Since',
    verified: 'Verified maker',
    rating: 'rating',
    subcategories: 'Subcategories',
  },
  de: {
    products: 'Produkte',
    product: 'Produkt',
    readMore: 'Weiterlesen',
    readLess: 'Weniger anzeigen',
    sort: 'Sortieren nach',
    view: 'Ansicht',
    grid: 'Raster',
    list: 'Liste',
    filters: 'Filter',
    all: 'Alle',
    follow: 'Folgen',
    following: 'Du folgst',
    followers: 'Folgende',
    since: 'Seit',
    verified: 'Verifizierte Manufaktur',
    rating: 'Bewertung',
    subcategories: 'Unterkategorien',
  },
};

type Texts = (typeof TEXTS)['en'];

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';

function Count({ n, t, className }: { n?: number; t: Texts; className?: string }) {
  const locale = useLocale();
  if (n === undefined) return null;
  return (
    <span className={cn('tabular-nums', className)}>
      {formatNumber(locale, n)} {n === 1 ? t.product : t.products}
    </span>
  );
}

/** A photo that drifts a little slower than the page */
function Drift({ children, amount = 10, className }: { children: React.ReactNode; amount?: number; className?: string }) {
  const ref = useScrollProgress<HTMLDivElement>();
  return (
    <div ref={ref} data-clip-ok className={cn('absolute inset-0 overflow-hidden', className)}>
      <div className="absolute inset-x-0 motion-reduce:!transform-none [&>*]:size-full [&>*]:object-cover" style={{ top: `-${amount}%`, bottom: `-${amount}%`, transform: `translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * ${amount * 2}%), 0)` }}>
        {children}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* banner                                                              */
/* ------------------------------------------------------------------ */

function Banner(p: CollectionHeaderProps & { t: Texts }) {
  const { t } = p;
  return (
    <Section spacing="sm" className={p.className}>
      <ShopMotion />
      {p.crumbs && <Breadcrumbs items={p.crumbs} className="mb-5" />}
      <div className="relative isolate flex min-h-[32rem] items-end overflow-hidden rounded-3xl bg-surface-2 sm:min-h-[28rem] lg:min-h-[30rem]">
        <Drift>{p.image}</Drift>
        {/* A dark shade from below keeps the title readable on any photo, in both modes */}
        <div aria-hidden className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.85),rgb(0_0_0/0.6)_45%,rgb(0_0_0/0.12)_80%,transparent)] lg:bg-[linear-gradient(to_top,rgb(0_0_0/0.8),rgb(0_0_0/0.45)_40%,transparent_72%)]" />
        <div className="relative w-full p-6 text-white [text-shadow:0_1px_12px_rgb(0_0_0/0.25)] sm:p-10 lg:p-12">
          {p.eyebrow && <p className="kit-rise text-[13px] font-medium uppercase tracking-[0.14em] text-white/80">{p.eyebrow}</p>}
          <div className="mt-2 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
            <div className="max-w-[40rem]">
              <h1 className="kit-rise text-balance text-[clamp(2.25rem,5vw,4rem)] font-semibold leading-[1.02] tracking-[-0.035em]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
                {p.title}
              </h1>
              {p.description && (
                <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-[16px] leading-relaxed text-white/85 sm:text-[17px]" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>
                  {p.description}
                </p>
              )}
            </div>
            <div className="kit-rise flex flex-wrap items-center gap-4" style={{ '--kit-delay': '180ms' } as React.CSSProperties}>
              <Count n={p.count} t={t} className="text-[14px] text-white/80" />
              {p.action && (
                <a href={p.action.href} className={cn('group/a inline-flex h-11 items-center gap-2 rounded-lg bg-white px-4 text-[14.5px] font-medium text-[oklch(0.2_0.01_260)] shadow-[var(--shadow-md)] transition-transform duration-300 hover:-translate-y-0.5', FOCUS)}>
                  {p.action.label}
                  <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/a:translate-x-1" />
                </a>
              )}
            </div>
          </div>
        </div>
      </div>
      {p.links?.length ? (
        <nav aria-label={t.subcategories} className="mt-6">
          <Stagger step={50} as="ul" className="kit-no-scrollbar kit-fade-end -mx-6 flex gap-2 overflow-x-auto px-6 py-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:[mask-image:none]">
            {p.links.map((l) => (
              <li key={l.label} className="shrink-0">
                <a href={l.href} className={cn('inline-flex h-11 items-center gap-2 rounded-full border border-border-strong px-4 text-[14px] font-medium text-foreground transition-colors hover:bg-accent sm:h-10', FOCUS)}>
                  {l.label}
                  {l.count !== undefined && <span className="text-[12.5px] tabular-nums text-subtle-foreground">{l.count}</span>}
                </a>
              </li>
            ))}
          </Stagger>
        </nav>
      ) : null}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* split                                                               */
/* ------------------------------------------------------------------ */

function Split(p: CollectionHeaderProps & { t: Texts }) {
  const { t } = p;
  const [open, setOpen] = useState(false);
  const id = useId();
  return (
    <Section spacing="sm" className={p.className}>
      <ShopMotion />
      {p.crumbs && <Breadcrumbs items={p.crumbs} className="mb-6" />}
      <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center lg:gap-14">
        <div className="lg:order-2">
          <Reveal effect="scale" className="relative aspect-[16/10] overflow-hidden rounded-2xl bg-surface-2 lg:aspect-[5/4]">
            <Drift amount={6}>{p.image}</Drift>
          </Reveal>
        </div>
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h1 className="kit-rise text-balance text-h1 font-semibold text-foreground">{p.title}</h1>
          <Count n={p.count} t={t} className="kit-rise mt-2 block text-[14px] text-muted-foreground" />
          {p.description && <p className="kit-rise mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground">{p.description}</p>}
          {p.more && (
            <>
              <div id={id} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                <div className="overflow-hidden" inert={!open}>
                  <p className="max-w-[34rem] pt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{p.more}</p>
                </div>
              </div>
              <button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)} className={cn('mt-2 inline-flex min-h-11 items-center gap-1.5 rounded-md text-[14px] font-medium text-foreground', FOCUS)}>
                <Swap value={open}>{open ? t.readLess : t.readMore}</Swap>
                <Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', open && 'rotate-180')} />
              </button>
            </>
          )}
          {p.links?.length ? (
            <nav aria-label={t.subcategories} className="mt-6 border-t border-border pt-6">
              <Stagger step={60} as="ul" className="grid grid-cols-2 gap-2 sm:grid-cols-3">
                {p.links.map((l) => (
                  <li key={l.label}>
                    <a href={l.href} className={cn('group/l flex h-full items-center gap-3 rounded-xl border border-border bg-card p-2 pe-3 transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-border-strong', FOCUS)}>
                      {l.image && <span className="size-11 shrink-0 overflow-hidden rounded-lg bg-surface-2 [&>*]:size-full">{l.image}</span>}
                      <span className="min-w-0">
                        <span className="block truncate text-[14px] font-medium text-foreground">{l.label}</span>
                        {l.count !== undefined && <span className="block text-[12px] tabular-nums text-muted-foreground">{l.count}</span>}
                      </span>
                    </a>
                  </li>
                ))}
              </Stagger>
            </nav>
          ) : null}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* toolbar                                                             */
/* ------------------------------------------------------------------ */

function Toolbar(p: CollectionHeaderProps & { t: Texts }) {
  const { t } = p;
  const links = p.links ?? [];
  const [active, setActive] = useState(0);
  const [view, setView] = useState<'grid' | 'list'>('grid');
  const [sort, setSort] = useState(p.sorts?.[0]?.value ?? '');
  const [stuck, setStuck] = useState(false);
  const sentinel = useRef<HTMLDivElement>(null);
  const row = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ x: number; w: number; ready: boolean } | null>(null);
  useEffect(() => {
    const el = sentinel.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setStuck(!e.isIntersecting && e.boundingClientRect.top < 0));
    io.observe(el);
    return () => io.disconnect();
  }, []);
  useEffect(() => {
    const update = () => {
      const b = row.current?.querySelectorAll<HTMLElement>('[data-tab]')[active];
      if (b) setMark((m) => ({ x: b.offsetLeft, w: b.offsetWidth, ready: !!m }));
    };
    update();
    const ro = new ResizeObserver(update);
    if (row.current) ro.observe(row.current);
    return () => ro.disconnect();
  }, [active]);
  const count = active === 0 ? p.count : links[active - 1]?.count;
  return (
    <Section spacing="sm" className={p.className}>
      <ShopMotion />
      {p.crumbs && <Breadcrumbs items={p.crumbs} className="mb-5" />}
      <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h1 className="kit-rise text-balance text-h1 font-semibold text-foreground">{active === 0 ? p.title : links[active - 1]?.label}</h1>
        </div>
        {p.description && <p className="kit-rise max-w-[26rem] text-pretty text-[15px] leading-relaxed text-muted-foreground sm:text-right">{p.description}</p>}
      </div>
      {/* Subcategories as tabs with an underline that slides */}
      <div ref={row} role="tablist" aria-label={t.subcategories} className="kit-no-scrollbar kit-fade-end relative -mx-6 mt-8 flex gap-6 overflow-x-auto border-b border-border px-6 sm:mx-0 sm:px-0 sm:[mask-image:none]">
        {mark && <span aria-hidden className={cn('absolute bottom-0 left-0 h-0.5 rounded-full bg-foreground', mark.ready && 'transition-[transform,width] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ width: mark.w, transform: `translateX(${mark.x}px)` }} />}
        {[{ label: t.all, href: '#', count: p.count }, ...links].map((l, i) => (
          <button
            key={l.label}
            data-tab
            type="button"
            role="tab"
            aria-selected={i === active}
            onClick={() => setActive(i)}
            className={cn('relative inline-flex h-12 shrink-0 items-center gap-1.5 whitespace-nowrap text-[14.5px] font-medium transition-colors', i === active ? (mark ? 'text-foreground' : 'text-foreground shadow-[inset_0_-2px_0_var(--foreground)]') : 'text-muted-foreground hover:text-foreground', FOCUS)}
          >
            {l.label}
            {l.count !== undefined && <span className="text-[12.5px] tabular-nums text-subtle-foreground">{l.count}</span>}
          </button>
        ))}
      </div>
      <div ref={sentinel} aria-hidden className="h-px" />
      {/* The bar sticks to the top while the products scroll, with a soft shadow once it does */}
      <div className={cn('sticky top-0 z-20 -mx-6 bg-background/90 px-6 py-3 backdrop-blur-md transition-shadow duration-300 sm:mx-0 sm:rounded-b-xl sm:px-0', stuck && 'shadow-[0_8px_16px_-12px_rgb(0_0_0/0.35)] sm:px-3')}>
        <div className="flex items-center gap-2">
          <p className="me-auto text-[13.5px] text-muted-foreground" aria-live="polite">
            <Swap value={count ?? 0}>
              <Count n={count} t={t} className="font-medium text-foreground" />
            </Swap>
          </p>
          <Button variant="outline" size="sm" className="h-11 sm:h-10" iconLeft="sliders" onClick={p.onFilters}>
            {t.filters}
          </Button>
          {p.sorts?.length ? (
            <NativeSelect
              aria-label={t.sort}
              value={sort}
              onChange={(e) => {
                setSort(e.target.value);
                p.onSort?.(e.target.value);
              }}
              options={p.sorts}
              className="w-[11rem] max-sm:hidden"
            />
          ) : null}
          <div role="radiogroup" aria-label={t.view} className="relative grid h-11 grid-cols-2 rounded-lg border border-border-strong bg-surface p-1 sm:h-10">
            <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]" style={{ transform: `translateX(${view === 'list' ? 100 : 0}%)` }} />
            {(['grid', 'list'] as const).map((v) => (
              <button key={v} type="button" role="radio" aria-checked={view === v} aria-label={v === 'grid' ? t.grid : t.list} onClick={() => { setView(v); p.onView?.(v); }} className={cn('relative z-10 grid w-10 place-items-center after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] rounded-md transition-colors', view === v ? 'text-foreground' : 'text-muted-foreground', FOCUS)}>
                <Icon name={v === 'grid' ? 'grid' : 'list'} size={16} />
              </button>
            ))}
          </div>
        </div>
      </div>
      {p.children && (
        <div key={`${active}-${view}`} data-view={view} className="group/view mt-4 animate-[kitshop-in_0.45s_var(--ease-kit)]">
          {p.children}
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* editorial                                                           */
/* ------------------------------------------------------------------ */

function Editorial(p: CollectionHeaderProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  return (
    <Section className={p.className}>
      <ShopMotion />
      {p.crumbs && <Breadcrumbs items={p.crumbs} className="mb-8" />}
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-12 lg:gap-12">
        <div className="lg:col-span-7">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h1 className="kit-rise text-balance text-[clamp(2.5rem,6.5vw,5.5rem)] font-semibold leading-[0.98] tracking-[-0.045em] text-foreground">{p.title}</h1>
        </div>
        <div className="flex flex-col justify-end lg:col-span-5">
          {p.description && (
            <p className="kit-rise text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
              {p.description}
            </p>
          )}
          {p.action && (
            <div className="kit-rise mt-6" style={{ '--kit-delay': '160ms' } as React.CSSProperties}>
              <Button href={p.action.href} icon="arrow-right">
                {p.action.label}
              </Button>
            </div>
          )}
        </div>
      </div>
      <Reveal effect="scale" className="relative mt-12 aspect-[16/9] overflow-hidden rounded-3xl bg-surface-2 sm:aspect-[21/9]">
        <Drift amount={8}>{p.image}</Drift>
      </Reveal>
      <div className="mt-10 grid grid-cols-1 gap-10 lg:grid-cols-12 lg:gap-12">
        {p.facts?.length ? (
          <Stagger step={80} as="ul" className="grid grid-cols-3 gap-px self-start overflow-hidden rounded-2xl border border-border bg-border lg:col-span-7">
            {p.facts.map((f) => (
              <li key={f.label} className="bg-card p-4 sm:p-6">
                <span className="block text-[26px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground sm:text-[34px]">{f.value}</span>
                <span className="mt-2 block text-[12.5px] leading-snug text-muted-foreground sm:text-[13.5px]">{f.label}</span>
              </li>
            ))}
          </Stagger>
        ) : null}
        {p.quote && (
          <Reveal as="div" effect="rise" className="lg:col-span-5">
            <figure className="border-s-2 border-primary ps-5">
              <blockquote className="text-pretty text-[18px] leading-relaxed tracking-[-0.01em] text-foreground sm:text-[20px]">{locale === 'de' ? `„${p.quote.text}“` : `“${p.quote.text}”`}</blockquote>
              <figcaption className="mt-4 flex items-center gap-3">
                {p.quote.avatar && <span className="size-10 shrink-0 overflow-hidden rounded-full bg-muted [&>*]:size-full">{p.quote.avatar}</span>}
                <span>
                  <span className="block text-[14px] font-medium text-foreground">{p.quote.author}</span>
                  {p.quote.role && <span className="block text-[13px] text-muted-foreground">{p.quote.role}</span>}
                </span>
              </figcaption>
            </figure>
          </Reveal>
        )}
      </div>
      <Count n={p.count} t={t} className="sr-only" />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* brand                                                               */
/* ------------------------------------------------------------------ */

function Brand(p: CollectionHeaderProps & { t: Texts }) {
  const { t, brand } = p;
  const locale = useLocale();
  const [following, setFollowing] = useState(false);
  const followers = (brand?.followers ?? 0) + (following ? 1 : 0);
  return (
    <Section spacing="sm" className={p.className}>
      <ShopMotion />
      {p.crumbs && <Breadcrumbs items={p.crumbs} className="mb-5" />}
      <div className="relative aspect-[16/7] overflow-hidden rounded-3xl bg-surface-2 sm:aspect-[16/5]">
        <Drift amount={8}>{p.image}</Drift>
      </div>
      <div className="relative px-4 sm:px-8">
        <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
          <div className="flex items-start gap-4 sm:gap-5">
            <span className="kit-rise -mt-10 grid size-20 shrink-0 place-items-center overflow-hidden rounded-2xl border-4 border-background bg-card shadow-[var(--shadow-md)] sm:-mt-12 sm:size-24 [&>*]:size-full">{brand?.mark}</span>
            <div className="min-w-0 pt-3">
              <h1 className="kit-rise flex flex-wrap items-center gap-2 text-[28px] font-semibold leading-tight tracking-[-0.025em] text-foreground sm:text-[34px]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
                {brand?.name ?? p.title}
                {brand?.verified && (
                  <span title={t.verified} className={cn('inline-grid size-6 place-items-center rounded-full bg-primary text-primary-foreground')}>
                    <Icon name="check" size={14} strokeWidth={2.4} label={t.verified} />
                  </span>
                )}
              </h1>
              <p className="kit-rise mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[14px] text-muted-foreground" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
                {brand?.place && (
                  <span className="inline-flex items-center gap-1">
                    <Icon name="map-pin" size={14} />
                    {brand.place}
                  </span>
                )}
                {brand?.since && (
                  <span>
                    {t.since} {brand.since}
                  </span>
                )}
              </p>
            </div>
          </div>
          <div className="flex gap-2 lg:pt-4">
            <Button variant={following ? 'secondary' : 'primary'} aria-pressed={following} iconLeft={following ? 'check' : 'plus'} onClick={() => setFollowing(!following)} className="flex-1 lg:flex-none">
              <Swap value={following}>{following ? t.following : t.follow}</Swap>
            </Button>
            {p.action && (
              <Button href={p.action.href} variant="outline" className="flex-1 lg:flex-none">
                {p.action.label}
              </Button>
            )}
          </div>
        </div>
        <div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start lg:gap-16">
          {p.description && <p className="max-w-[40rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{p.description}</p>}
          <dl className="grid grid-cols-3 gap-6 sm:gap-10">
            {(
              [
                [formatNumber(locale, p.count ?? 0), t.products, 'bag'],
                [formatNumber(locale, followers), t.followers, 'users'],
                ...(brand?.rating !== undefined ? [[formatNumber(locale, brand.rating, 1), t.rating, 'star']] : []),
              ] as [string, string, IconName][]
            ).map(([v, l, icon]) => (
              <div key={l}>
                <dt className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
                  <Icon name={icon} size={13} className={icon === 'star' ? ACCENT_TEXT : undefined} />
                  <span className="inline-block first-letter:uppercase">{l}</span>
                </dt>
                <dd className="mt-1 text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
                  <Swap value={v}>{v}</Swap>
                </dd>
              </div>
            ))}
          </dl>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * The top of a collection page in five layouts. banner: title on a photo.
 * split: text, read more and subcategories beside a photo. toolbar: tabs and
 * a sticky bar for sort, view and filters. editorial: a collection as a story.
 * brand: the page of a maker.
 */
export function CollectionHeader(props: CollectionHeaderProps) {
  const { variant = 'banner' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'split') return <Split {...props} t={t} />;
  if (variant === 'toolbar') return <Toolbar {...props} t={t} />;
  if (variant === 'editorial') return <Editorial {...props} t={t} />;
  if (variant === 'brand') return <Brand {...props} t={t} />;
  return <Banner {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Kollektionskopf?

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

Welche Varianten hat Kollektionskopf?

5 Varianten, Fotobanner, Text und Foto, Tabs und klebende Leiste, Kollektion als Geschichte und Manufakturseite. Du wählst sie mit der Prop variant, zum Beispiel variant="split".

Kann ich die Texte von Kollektionskopf übersetzen?

Ja. Kollektionskopf bringt 17 eigene Texte auf Deutsch und Englisch mit, etwa „Produkte“, „Produkt“ und „Weiterlesen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kollektionskopf einbauen?

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

Kann ich Kollektionskopf ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden CollectionHeader-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert