Skip to content
UI Kit
Accent color

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

5 variants

Variants

Photo banner

The title on a wide photo that drifts while scrolling, with a shade from below that keeps it readable in both modes. Breadcrumbs above, count and a link on the photo, subcategory chips below that rise one after another and scroll on phones.

Open in Studio

Text and photo

Title, count and a short text beside a photo. "Read more" opens the long text with a soft height, made for people and search engines alike, and subcategories with thumbnails rise below and lift on hover.

Open in Studio

Tabs and sticky bar

Subcategories as tabs with an underline that slides and a title that follows the tab. Below, a bar with count, filters, sort and a grid or list switch sticks to the top while the products scroll and gets a soft shadow once it does.

Open in Studio

Editorial collection

A collection told like a magazine. A very large title, the intro and a link, a wide photo that grows into view and drifts, then facts in a hairline grid that rise one after another and a quote from the maker.

Open in Studio

Maker page

The page of a brand or maker in a marketplace. A cover that drifts, the mark overlapping it, name with a verified badge, place and year, follow with a count that rises, and the numbers of products, followers and rating.

Open in Studio

Details

What Collection header brings along, read from the registry and the source.

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

Props

The CollectionHeaderProps interface from components/ui/collection-header.tsx, with the comments from the code.

  • variantoptional
    CollectionHeaderVariant

    bannersplittoolbareditorialbrand

  • eyebrowoptional
    string
  • titlerequired
    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

Built-in texts

Collection header writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 17 texts
productsEnglish productsGerman Produkte
productEnglish productGerman Produkt
readMoreEnglish Read moreGerman Weiterlesen
readLessEnglish Show lessGerman Weniger anzeigen
sortEnglish Sort byGerman Sortieren nach
viewEnglish ViewGerman Ansicht
gridEnglish GridGerman Raster
listEnglish ListGerman Liste
filtersEnglish FiltersGerman Filter
allEnglish AllGerman Alle
followEnglish FollowGerman Folgen
followingEnglish FollowingGerman Du folgst
followersEnglish followersGerman Folgende
sinceEnglish SinceGerman Seit
verifiedEnglish Verified makerGerman Verifizierte Manufaktur
ratingEnglish ratingGerman Bewertung
subcategoriesEnglish SubcategoriesGerman 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
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} />;
}

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.

Often searched

Jump to

Copied to the clipboard