Zum Inhalt springen
UI Kit
Accent

Kategorien

Der Weg in einen Katalog. Shop-Abteilungen, Blog-Themen, Leistungsbereiche oder ein Verzeichnis, als Icon-Kacheln, Bildkarten, runde Vorschaubilder, Linkspalten oder große Namen auf einem Foto.

Eine von 2 Components in Kategorien und Kollektionen

5 Variants

Variants

Icon-Raster

Acht Abteilungen in einem Haarlinien-Raster mit Anzahl und Markierung. Ein Licht folgt dem Zeiger, das Icon nimmt beim Hover den Akzent an, die Kacheln füllen sich beim Scrollen nacheinander.

Im Studio öffnen

Bildkarten

Vier Bildkarten mit Name und Anzahl. Das Foto treibt beim Scrollen in seinem Rahmen und zoomt beim Hover. Unter Desktop eine Wischreihe mit Pfeilen, Ziehen und Fortschrittslinie.

Im Studio öffnen

Kreise

Runde Vorschaubilder in einer Reihe, die mit Pfeilen, Ziehen und Wischen scrollt, darunter eine Fortschrittslinie. Beim Hover wächst ein Ring heran, die Kreise erscheinen nacheinander.

Im Studio öffnen

Vollflächiges Bild

Große Namen über einem Foto. Zeigt der Zeiger auf eine Zeile, blendet das Foto zu dieser Kategorie über und eine Karte zeigt ihre Beschreibung. Auf dem Telefon wählt die Zeile in der Bildschirmmitte beim Scrollen das Foto.

Im Studio öffnen

Details

Was Kategorien mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
5
Version
1.0.1 · seit 1.0.0
Datei
components/ui/categories.tsx
Bringt mit
utilskiticonrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte
In Sammlungen
Online-Shop

Props

Die Schnittstelle CategoriesProps aus components/ui/categories.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CategoriesVariant

    icon-gridimage-cardscircleslink-columnsfull-background

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • actionoptional
    CategoryLink

    A link to all categories, top right on wide screens

  • itemsPflicht
    CategoryItem[]

Eingebaute Texte

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

Alle 7 Texte anzeigen
countEnglisch {n} itemsDeutsch {n} Artikel
countOneEnglisch 1 itemDeutsch 1 Artikel
allEnglisch All in {name}Deutsch Alles in {name}
previousEnglisch Scroll backDeutsch Zurückblättern
nextEnglisch Scroll onDeutsch Weiterblättern
toggleEnglisch Show {name}Deutsch {name} zeigen
rowEnglisch CategoriesDeutsch Kategorien

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-current, aria-expanded und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • 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. 12 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 516 Zeilen
components/ui/categories.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * The way into a catalog: shop departments, blog topics, service areas or a
 * directory. Images come in as nodes (`image`), icons by name.
 *
 *   icon-grid        hairline grid of icon tiles with counts, light under the pointer
 *   image-cards      image cards with name and count, a swipe row on phones and tablets
 *   circles          round thumbnails in a row that scrolls, drags and shows its progress
 *   link-columns     departments with their sub links, an accordion on phones
 *   full-background  big names on a photo that changes with the row you point at
 */

export type CategoriesVariant = 'icon-grid' | 'image-cards' | 'circles' | 'link-columns' | 'full-background';

export interface CategoryLink {
  label: string;
  href: string;
}

export interface CategoryItem {
  name: string;
  href: string;
  /** Number of products, articles or entries */
  count?: number;
  description?: string;
  icon?: IconName;
  /** image-cards, circles, full-background: a photo or drawn motif */
  image?: React.ReactNode;
  /** A short marker, e.g. "New" or "Sale" */
  tag?: string;
  /** link-columns: the sub categories */
  links?: CategoryLink[];
}

export interface CategoriesProps {
  variant?: CategoriesVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** A link to all categories, top right on wide screens */
  action?: CategoryLink;
  items: CategoryItem[];
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    count: '{n} items',
    countOne: '1 item',
    all: 'All in {name}',
    previous: 'Scroll back',
    next: 'Scroll on',
    toggle: 'Show {name}',
    row: 'Categories',
  },
  de: {
    count: '{n} Artikel',
    countOne: '1 Artikel',
    all: 'Alles in {name}',
    previous: 'Zurückblättern',
    next: 'Weiterblättern',
    toggle: '{name} zeigen',
    row: 'Kategorien',
  },
};

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

const num = (n: number, locale: Locale) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
const countText = (n: number | undefined, t: Texts, locale: Locale) => (n === undefined ? null : n === 1 ? t.countOne : t.count.replace('{n}', num(n, locale)));

const FOCUS = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function Head({ eyebrow, title, description, action, aside, className }: Pick<CategoriesProps, 'eyebrow' | 'title' | 'description' | 'action'> & { aside?: React.ReactNode; className?: string }) {
  return (
    <div className={cn('mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between sm:gap-10', className)}>
      <div className="max-w-[38rem]">
        {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
        <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
        {description && (
          <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
            {description}
          </p>
        )}
      </div>
      {(action || aside) && (
        <div className="flex shrink-0 items-center gap-4">
          {action && <AllLink action={action} />}
          {aside}
        </div>
      )}
    </div>
  );
}

function AllLink({ action, className }: { action: CategoryLink; className?: string }) {
  return (
    <a href={action.href} className={cn('group/all inline-flex min-h-11 items-center gap-2 rounded-md text-[14.5px] font-medium text-foreground', FOCUS, className)}>
      <span className="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-0.5 transition-[background-size] duration-500 ease-[var(--ease-kit)] group-hover/all:bg-[length:100%_1px]">{action.label}</span>
      <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
    </a>
  );
}

function TagMark({ children, className }: { children: React.ReactNode; className?: string }) {
  return <span className={cn('inline-flex h-5 items-center rounded-full bg-primary px-2 text-[11px] font-semibold text-primary-foreground', className)}>{children}</span>;
}

/**
 * A horizontal scroll row: arrow buttons, mouse drag, a progress line and
 * fades only on the side where more follows. Snapping pauses while dragging,
 * and a drag never counts as a click.
 */
function useRail() {
  const ref = useRef<HTMLUListElement>(null);
  const [pos, setPos] = useState({ start: true, end: true, progress: 0, view: 1 });
  const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
  const [dragging, setDragging] = useState(false);
  const measure = () => {
    const el = ref.current;
    if (!el) return;
    const max = el.scrollWidth - el.clientWidth;
    setPos({ start: el.scrollLeft <= 2, end: el.scrollLeft >= max - 2, progress: max > 0 ? el.scrollLeft / max : 0, view: el.scrollWidth ? el.clientWidth / el.scrollWidth : 1 });
  };
  useEffect(() => {
    measure();
    const ro = new ResizeObserver(measure);
    if (ref.current) ro.observe(ref.current);
    return () => ro.disconnect();
  }, []);
  const go = (dir: 1 | -1) => {
    const el = ref.current;
    if (!el) return;
    el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
  };
  const handlers = {
    onScroll: measure,
    onPointerDown: (e: React.PointerEvent<HTMLUListElement>) => {
      if (e.pointerType !== 'mouse' || e.button !== 0 || !ref.current) return;
      drag.current = { x: e.clientX, left: ref.current.scrollLeft, moved: false };
    },
    onPointerMove: (e: React.PointerEvent<HTMLUListElement>) => {
      const d = drag.current;
      const el = ref.current;
      if (!d || !el) return;
      const dx = e.clientX - d.x;
      if (!d.moved && Math.abs(dx) > 5) {
        d.moved = true;
        setDragging(true);
        el.setPointerCapture(e.pointerId);
      }
      if (d.moved) el.scrollLeft = d.left - dx;
    },
    onPointerUp: () => {
      const moved = drag.current?.moved;
      drag.current = null;
      if (moved) requestAnimationFrame(() => setDragging(false));
    },
    onClickCapture: (e: React.MouseEvent) => {
      if (dragging) e.preventDefault();
    },
  };
  const overflow = !(pos.start && pos.end);
  const fade = !overflow ? '' : pos.start ? 'kit-fade-end' : pos.end ? 'kit-fade-start' : 'kit-fade-x';
  return { ref, pos, go, handlers, dragging, overflow, fade };
}

function RailArrows({ rail, t, className }: { rail: ReturnType<typeof useRail>; t: Texts; className?: string }) {
  const btn = (dir: 1 | -1) => (
    <button
      type="button"
      onClick={() => rail.go(dir)}
      disabled={dir < 0 ? rail.pos.start : rail.pos.end}
      aria-label={dir < 0 ? t.previous : t.next}
      className={cn('grid size-11 place-items-center rounded-full border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-[background-color,opacity,transform] duration-300 hover:bg-accent active:scale-95 disabled:pointer-events-none disabled:opacity-35', FOCUS)}
    >
      <Icon name={dir < 0 ? 'arrow-left' : 'arrow-right'} size={17} />
    </button>
  );
  return (
    <div className={cn('flex gap-2 transition-opacity duration-300', !rail.overflow && 'pointer-events-none opacity-0', className)} aria-hidden={!rail.overflow}>
      {btn(-1)}
      {btn(1)}
    </div>
  );
}

function RailProgress({ rail, className }: { rail: ReturnType<typeof useRail>; className?: string }) {
  const thumb = Math.max(0.14, Math.min(1, rail.pos.view));
  return (
    <div aria-hidden className={cn('relative h-0.5 overflow-hidden rounded-full bg-border transition-opacity duration-300', !rail.overflow && 'opacity-0', className)}>
      <span className="absolute inset-y-0 left-0 rounded-full bg-foreground transition-transform duration-150" style={{ width: `${Math.round(thumb * 1000) / 10}%`, transform: `translateX(${Math.round(((rail.pos.progress * (1 - thumb)) / thumb) * 1000) / 10}%)` }} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* icon-grid                                                           */
/* ------------------------------------------------------------------ */

/** Empty cells that close the last row of a hairline grid at each width */
function Fillers({ count }: { count: number }) {
  const cells: React.ReactNode[] = [];
  const add = (cols: number, className: string) => {
    const rest = count % cols ? cols - (count % cols) : 0;
    for (let i = 0; i < rest; i++) cells.push(<li key={`${cols}-${i}`} aria-hidden className={cn('bg-card', className)} />);
  };
  add(2, 'sm:hidden');
  add(4, 'hidden sm:block');
  return <>{cells}</>;
}

function IconGrid(p: CategoriesProps & { t: Texts }) {
  const locale = useLocale();
  const spot = useSpotlight<HTMLAnchorElement>();
  return (
    <Section className={p.className}>
      <Head {...p} />
      <ul className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-border bg-border shadow-[var(--shadow-sm)] sm:grid-cols-4">
        {p.items.map((c, i) => (
          <li key={i} className="bg-card">
            <a href={c.href} onPointerMove={spot} className={cn('kit-spotlight group/cat relative flex h-full min-h-[9.5rem] flex-col p-4 sm:min-h-[11rem] sm:p-6', FOCUS, 'focus-visible:-outline-offset-2')}>
              <Reveal delay={(i % 4) * 70} className="flex h-full flex-col">
                <span className="flex items-start justify-between gap-3">
                  <span className="grid size-11 place-items-center rounded-xl bg-muted text-foreground transition-[background-color,color,transform] duration-500 ease-[var(--ease-kit)] group-hover/cat:-translate-y-0.5 group-hover/cat:bg-primary group-hover/cat:text-primary-foreground motion-reduce:transition-none sm:size-12">
                    <Icon name={c.icon ?? 'tag'} size={20} />
                  </span>
                  {c.tag ? (
                    <TagMark>{c.tag}</TagMark>
                  ) : (
                    <Icon name="arrow-up-right" size={17} className="mt-1 text-muted-foreground opacity-0 transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] group-hover/cat:translate-x-0.5 group-hover/cat:-translate-y-0.5 group-hover/cat:opacity-100 group-focus-visible/cat:opacity-100" />
                  )}
                </span>
                <span className="mt-auto pt-6">
                  <span className="block text-[15px] font-semibold tracking-[-0.01em] text-foreground sm:text-[16px]">{c.name}</span>
                  {c.count !== undefined && <span className="mt-0.5 block text-[13px] tabular-nums text-muted-foreground">{countText(c.count, p.t, locale)}</span>}
                </span>
              </Reveal>
            </a>
          </li>
        ))}
        <Fillers count={p.items.length} />
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* image-cards                                                         */
/* ------------------------------------------------------------------ */

/** The image drifts a little inside its frame while the page scrolls */
function DriftImage({ children, className }: { children: React.ReactNode; className?: string }) {
  const ref = useScrollProgress<HTMLDivElement>();
  return (
    <div ref={ref} data-clip-ok className={cn('relative overflow-hidden', className)}>
      <div className="absolute inset-x-0 -inset-y-[6%] motion-reduce:[transform:none]!" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 8%), 0)' }}>
        <div className="size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{children}</div>
      </div>
    </div>
  );
}

function ImageCards(p: CategoriesProps & { t: Texts }) {
  const locale = useLocale();
  const rail = useRail();
  return (
    <Section className={cn('overflow-x-clip', p.className)}>
      <Head {...p} aside={<RailArrows rail={rail} t={p.t} className="max-sm:hidden lg:hidden" />} />
      <ul
        ref={rail.ref}
        {...rail.handlers}
        aria-label={p.t.row}
        className={cn(
          'kit-no-scrollbar -mx-6 flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-6 px-6 pb-1 sm:gap-5 lg:mx-0 lg:grid lg:snap-none lg:grid-cols-4 lg:overflow-visible lg:px-0 lg:[mask-image:none]',
          rail.dragging ? 'cursor-grabbing snap-none' : 'lg:cursor-auto',
          rail.fade,
        )}
      >
        {p.items.map((c, i) => (
          <Reveal as="li" key={i} effect="rise" delay={(i % 4) * 90} className="w-[72%] shrink-0 snap-start sm:w-[42%] lg:w-auto">
            <a href={c.href} draggable={false} className={cn('group/card block rounded-2xl', FOCUS)}>
              <span className="relative block">
                <DriftImage className="aspect-[4/5] rounded-2xl border border-border bg-surface-2 shadow-[var(--highlight),var(--shadow-sm)] transition-shadow duration-500 group-hover/card:shadow-[var(--highlight),var(--shadow-lg)]">{c.image}</DriftImage>
                {c.tag && <TagMark className="absolute left-3 top-3">{c.tag}</TagMark>}
                <span aria-hidden className="absolute bottom-3 right-3 grid size-10 place-items-center rounded-full bg-background text-foreground shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] group-hover/card:-rotate-45 motion-reduce:transition-none">
                  <Icon name="arrow-right" size={16} />
                </span>
              </span>
              <span className="mt-4 flex items-baseline justify-between gap-3">
                <span className="text-[16px] font-semibold tracking-[-0.01em] text-foreground">{c.name}</span>
                {c.count !== undefined && <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">{countText(c.count, p.t, locale)}</span>}
              </span>
              {c.description && <span className="mt-1 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{c.description}</span>}
            </a>
          </Reveal>
        ))}
      </ul>
      <RailProgress rail={rail} className="mt-6 lg:hidden" />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* circles                                                             */
/* ------------------------------------------------------------------ */

function Circles(p: CategoriesProps & { t: Texts }) {
  const locale = useLocale();
  const rail = useRail();
  return (
    <Section className={cn('overflow-x-clip', p.className)}>
      <Head {...p} aside={<RailArrows rail={rail} t={p.t} className="max-sm:hidden" />} />
      <ul
        ref={rail.ref}
        {...rail.handlers}
        aria-label={p.t.row}
        className={cn('kit-no-scrollbar -mx-6 flex snap-x gap-5 overflow-x-auto scroll-px-6 px-6 py-2 sm:gap-7 lg:-mx-2 lg:justify-between lg:gap-5 lg:scroll-px-2 lg:px-2', rail.dragging ? 'cursor-grabbing snap-none' : '', rail.fade)}
      >
        {p.items.map((c, i) => (
          <Reveal as="li" key={i} effect="scale" delay={Math.min(i, 7) * 60} className="shrink-0 snap-start">
            <a href={c.href} draggable={false} className={cn('group/circle flex w-[5.75rem] flex-col items-center rounded-xl text-center sm:w-28 lg:w-32', FOCUS)}>
              <span className="relative block size-[5.75rem] sm:size-28 lg:size-32">
                {/* The ring grows in on hover, the photo zooms inside its circle */}
                <span aria-hidden className="absolute -inset-1 rounded-full border-2 border-primary opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] [transform:scale(0.92)] group-hover/circle:opacity-100 group-hover/circle:[transform:scale(1)] group-focus-visible/circle:opacity-100 motion-reduce:transition-none" />
                <span className="block size-full overflow-hidden rounded-full border border-border bg-surface-2 shadow-[var(--highlight),var(--shadow-sm)]">
                  <span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/circle:scale-[1.08] motion-reduce:transition-none [&>*]:size-full">{c.image}</span>
                </span>
                {c.tag && <TagMark className="absolute -right-1 top-0 shadow-[var(--shadow-sm)]">{c.tag}</TagMark>}
              </span>
              <span className="mt-3 block w-full text-[14px] font-semibold leading-tight tracking-[-0.01em] text-foreground">{c.name}</span>
              {c.count !== undefined && <span className="mt-0.5 block text-[12.5px] tabular-nums text-muted-foreground">{countText(c.count, p.t, locale)}</span>}
            </a>
          </Reveal>
        ))}
      </ul>
      <RailProgress rail={rail} className="mt-6 sm:hidden" />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* link-columns                                                        */
/* ------------------------------------------------------------------ */

function LinkColumns(p: CategoriesProps & { t: Texts }) {
  const locale = useLocale();
  const uid = useId();
  const [open, setOpen] = useState<number[]>([0]);
  const toggle = (i: number) => setOpen((o) => (o.includes(i) ? o.filter((x) => x !== i) : [...o, i]));
  return (
    <Section className={p.className}>
      <Head {...p} />
      <Stagger as="ul" step={80} className="grid grid-cols-[minmax(0,1fr)] border-t border-border sm:grid-cols-2 sm:gap-x-8 sm:border-t-0 lg:grid-cols-4 lg:gap-x-10">
        {p.items.map((c, i) => {
          const isOpen = open.includes(i);
          const panel = `${uid}-panel-${i}`;
          return (
            <li key={i} className="kit-rise border-b border-border sm:border-b-0 sm:pb-10">
              <div className="flex items-center gap-3 sm:border-b sm:border-border sm:pb-4">
                <a href={c.href} className={cn('group/head flex min-h-14 min-w-0 flex-1 items-center gap-3 rounded-md sm:min-h-0', FOCUS)}>
                  <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-foreground transition-[background-color,color] duration-300 group-hover/head:bg-primary group-hover/head:text-primary-foreground">
                    <Icon name={c.icon ?? 'tag'} size={18} />
                  </span>
                  <span className="min-w-0">
                    <span className="block truncate text-[15.5px] font-semibold tracking-[-0.01em] text-foreground">{c.name}</span>
                    {c.count !== undefined && <span className="block text-[12.5px] tabular-nums text-muted-foreground">{countText(c.count, p.t, locale)}</span>}
                  </span>
                </a>
                <button
                  type="button"
                  onClick={() => toggle(i)}
                  aria-expanded={isOpen}
                  aria-controls={panel}
                  aria-label={p.t.toggle.replace('{name}', c.name)}
                  className={cn('grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:hidden', FOCUS)}
                >
                  <Icon name="chevron-down" size={18} className={cn('transition-transform duration-500 ease-[var(--ease-kit)]', isOpen && 'rotate-180')} />
                </button>
              </div>
              {/* Phones: soft height, the first one open. From tablet on all columns stand open. */}
              <div id={panel} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:grid-rows-[1fr] sm:opacity-100', isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                <div className="min-h-0 overflow-hidden">
                  <ul className="pb-5 pt-1 sm:pb-0 sm:pt-4">
                    {(c.links ?? []).map((l, j) => (
                      <li key={j}>
                        <a href={l.href} className={cn('group/link relative flex min-h-10 items-center rounded-md text-[14.5px] text-muted-foreground transition-colors hover:text-foreground sm:min-h-9', FOCUS)}>
                          <span aria-hidden className="absolute left-0 top-1/2 h-px w-3 origin-left scale-x-0 bg-primary transition-transform duration-300 ease-[var(--ease-kit)] group-hover/link:scale-x-100 group-focus-visible/link:scale-x-100 motion-reduce:transition-none" />
                          <span className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/link:translate-x-5 group-focus-visible/link:translate-x-5 motion-reduce:transition-none">{l.label}</span>
                        </a>
                      </li>
                    ))}
                    <li className="pt-2">
                      <AllLink action={{ label: p.t.all.replace('{name}', c.name), href: c.href }} className="text-[14px]" />
                    </li>
                  </ul>
                </div>
              </div>
            </li>
          );
        })}
      </Stagger>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* full-background                                                     */
/* ------------------------------------------------------------------ */

function FullBackground(p: CategoriesProps & { t: Texts }) {
  const locale = useLocale();
  const [active, setActive] = useState(0);
  const scroll = useScrollProgress<HTMLDivElement>();
  const list = useRef<HTMLUListElement>(null);
  // Touch screens have no hover: the row in the middle of the screen picks the photo
  useEffect(() => {
    const el = list.current;
    if (!el || !window.matchMedia('(hover: none)').matches) return;
    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) if (e.isIntersecting) setActive(Number((e.target as HTMLElement).dataset.index));
      },
      { rootMargin: '-45% 0px -45% 0px' },
    );
    el.querySelectorAll('[data-index]').forEach((li) => io.observe(li));
    return () => io.disconnect();
  }, [p.items.length]);
  const current = p.items[active] ?? p.items[0];
  return (
    <section className={cn('dark relative isolate overflow-hidden bg-background py-20 text-foreground sm:py-24 lg:py-32', p.className)}>
      <div ref={scroll} aria-hidden data-clip-ok className="absolute inset-0 -z-20 overflow-hidden">
        {p.items.map((c, i) => (
          <div key={i} className={cn('absolute inset-0 transition-[opacity,transform] duration-[900ms] ease-[var(--ease-kit)] motion-reduce:transition-none', i === active ? 'scale-100 opacity-100' : 'scale-[1.04] opacity-0')}>
            <div className="absolute inset-x-0 -inset-y-[5%] motion-reduce:[transform:none]! [&>*]:size-full" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 6%), 0)' }}>
              {c.image}
            </div>
          </div>
        ))}
      </div>
      <div aria-hidden className="absolute inset-0 -z-10 bg-[linear-gradient(to_right,color-mix(in_oklab,var(--background)_92%,transparent)_0%,color-mix(in_oklab,var(--background)_80%,transparent)_55%,color-mix(in_oklab,var(--background)_40%,transparent))] max-lg:bg-[color-mix(in_oklab,var(--background)_80%,transparent)]" />
      <div className="kit-container">
        <Head {...p} className="sm:mb-14" />
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-end lg:gap-16">
          <ul ref={list} className="border-t border-foreground/15">
            {p.items.map((c, i) => {
              const on = i === active;
              return (
                <li key={i} data-index={i} className="border-b border-foreground/15">
                  <a
                    href={c.href}
                    onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)}
                    onFocus={() => setActive(i)}
                    aria-current={on ? 'true' : undefined}
                    className={cn('group/row flex min-h-16 items-center gap-4 py-3 sm:min-h-20 sm:gap-6 lg:py-4', FOCUS, 'focus-visible:outline-offset-0')}
                  >
                    <span className={cn('min-w-0 flex-1 truncate text-[clamp(1.75rem,1.1rem+2.6vw,3.25rem)] font-semibold leading-[1.1] tracking-[-0.035em] transition-[color,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'translate-x-0 text-foreground lg:translate-x-2' : 'text-foreground/55 group-hover/row:text-foreground/85')}>
                      {c.name}
                    </span>
                    {c.count !== undefined && <span className={cn('shrink-0 text-[13px] tabular-nums transition-colors duration-300', on ? 'text-foreground/85' : 'text-foreground/60')}>{countText(c.count, p.t, locale)}</span>}
                    <span aria-hidden className={cn('grid size-10 shrink-0 place-items-center rounded-full border transition-[background-color,border-color,color,transform] duration-500 ease-[var(--ease-kit)] sm:size-11', on ? 'border-foreground bg-foreground text-background' : 'border-foreground/25 text-foreground/70 -rotate-45')}>
                      <Icon name="arrow-right" size={16} />
                    </span>
                  </a>
                </li>
              );
            })}
          </ul>
          {current && (
            <div className="max-lg:hidden">
              <div key={active} className="kit-rise rounded-2xl border border-foreground/15 bg-background/55 p-6 shadow-[var(--shadow-lg)] backdrop-blur-md">
                {current.tag && <TagMark className="mb-4">{current.tag}</TagMark>}
                <p className="text-[19px] font-semibold tracking-[-0.015em] text-foreground">{current.name}</p>
                {current.description && <p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-foreground/75">{current.description}</p>}
                <AllLink action={{ label: p.t.all.replace('{name}', current.name), href: current.href }} className="mt-4" />
              </div>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

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

/**
 * The way into a catalog in five layouts. icon-grid: icon tiles in a hairline
 * grid. image-cards: image cards, a swipe row below desktop. circles: round
 * thumbnails in a row that scrolls. link-columns: departments with sub links.
 * full-background: big names over a photo that follows the pointer.
 */
export function Categories(props: CategoriesProps) {
  const { variant = 'icon-grid' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'image-cards') return <ImageCards {...props} t={t} />;
  if (variant === 'circles') return <Circles {...props} t={t} />;
  if (variant === 'link-columns') return <LinkColumns {...props} t={t} />;
  if (variant === 'full-background') return <FullBackground {...props} t={t} />;
  return <IconGrid {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Kategorien?

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

Welche Varianten hat Kategorien?

5 Varianten, Icon-Raster, Bildkarten, Kreise, Linkspalten und Vollflächiges Bild. Du wählst sie mit der Prop variant, zum Beispiel variant="image-cards".

Kann ich die Texte von Kategorien übersetzen?

Ja. Kategorien bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „1 Artikel“, „Zurückblättern“ und „Weiterblättern“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kategorien einbauen?

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

Kann ich Kategorien ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert