Collection header
The top of a collection or category page. A title on a photo, text with read more and subcategories, tabs with a sticky bar for sort and view, a collection told like a magazine and the page of a maker.
One of 2 components in Categories and collections
Variants
Installv1.0.0
aiw add @ai-website/collection-headerBrings along utils, kit, button, icon, reveal, motion, section, breadcrumbs, select, product-image
Usage
<CollectionHeader variant="toolbar" title="…" count={48} links={[{ label, href, count }]} sorts={[{ value, label }]} onSort={…} onView={…} onFilters={…} />Installv1.0.0
aiw add @ai-website/collection-headerBrings along utils, kit, button, icon, reveal, motion, section, breadcrumbs, select, product-image
Usage
<CollectionHeader variant="editorial" title="…" description="…" image={<img … />} facts={[{ value, label }]} quote={{ text, author, role, avatar }} />Installv1.0.0
aiw add @ai-website/collection-headerBrings along utils, kit, button, icon, reveal, motion, section, breadcrumbs, select, product-image
Usage
<CollectionHeader variant="brand" brand={{ name, place, since, mark, followers, rating, verified }} count={48} description="…" image={<img … />} />Details
What Collection header brings along, read from the registry and the source.
- Category
- Categories and collections
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/collection-header.tsx
- Brings along
- utilskitbuttoniconrevealmotionsectionbreadcrumbsselectproduct-image
- npm packages
- None
- Languages
- English and German, 17 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CollectionHeaderProps interface from components/ui/collection-header.tsx, with the comments from the code.
variantoptionalCollectionHeaderVariantbannersplittoolbareditorialbrand
eyebrowoptionalstringtitlerequiredstringdescriptionoptionalstringmoreoptionalstringsplit: the long text under "Read more", for search engines and the curious
crumbsoptionalCrumb[]countoptionalnumberNumber of products in the collection
imageoptionalReact.ReactNodelinksoptionalCollectionLink[]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
childrenoptionalReact.ReactNodetoolbar: the products under the bar, e.g. a product grid. Gets the view as a data attribute.
onSortoptional(value: string) => voidonViewoptional(view: 'grid' | 'list') => voidonFiltersoptional() => void
Built-in texts
Collection header writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 17 texts
| Key | English | German |
|---|---|---|
| products | English products | German Produkte |
| product | English product | German Produkt |
| readMore | English Read more | German Weiterlesen |
| readLess | English Show less | German Weniger anzeigen |
| sort | English Sort by | German Sortieren nach |
| view | English View | German Ansicht |
| grid | English Grid | German Raster |
| list | English List | German Liste |
| filters | English Filters | German Filter |
| all | English All | German Alle |
| follow | English Follow | German Folgen |
| following | English Following | German Du folgst |
| followers | English followers | German Folgende |
| since | English Since | German Seit |
| verified | English Verified maker | German Verifizierte Manufaktur |
| rating | English rating | German Bewertung |
| subcategories | English Subcategories | German Unterkategorien |
Accessibility
Read from the source of the component.
- Sets aria-checked, aria-controls, aria-expanded, aria-label, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles radio, radiogroup, tab and tablist.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 517 lines
'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} />;
}Frequent questions
How do I install Collection header?
With aiw add @ai-website/collection-header. The command puts components/ui/collection-header.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, breadcrumbs, select and product-image. It needs no further npm packages.
Which variants does Collection header have?
5 variants, Photo banner, Text and photo, Tabs and sticky bar, Editorial collection and Maker page. You pick one with the variant prop, for example variant="split".
Can I translate the texts of Collection header?
Yes. Collection header carries 17 texts of its own in English and German, such as “products”, “product” and “Read more”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Collection header?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Collection header with aiw_components and install it with aiw_add @ai-website/collection-header. Name the variant in your prompt, for example variant="banner", and the assistant writes your content straight into the props.
Can I adjust Collection header without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching CollectionHeader code to copy.