Categories
The way into a catalog. Shop departments, blog topics, service areas or a directory, as icon tiles, image cards, round thumbnails, link columns or big names on a photo.
One of 2 components in Categories and collections
Variants
Details
What Categories brings along, read from the registry and the source.
- Category
- Categories and collections
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/categories.tsx
- Brings along
- utilskiticonrevealmotionsection
- npm packages
- None
- Languages
- English and German, 7 texts of its own
- Studio
- All 5 variants editable
- In collections
- Online shop
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CategoriesProps interface from components/ui/categories.tsx, with the comments from the code.
variantoptionalCategoriesVarianticon-gridimage-cardscircleslink-columnsfull-background
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeactionoptionalCategoryLinkA link to all categories, top right on wide screens
itemsrequiredCategoryItem[]
Built-in texts
Categories writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 7 texts
| Key | English | German |
|---|---|---|
| count | English {n} items | German {n} Artikel |
| countOne | English 1 item | German 1 Artikel |
| all | English All in {name} | German Alles in {name} |
| previous | English Scroll back | German Zurückblättern |
| next | English Scroll on | German Weiterblättern |
| toggle | English Show {name} | German {name} zeigen |
| row | English Categories | German Kategorien |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-current, aria-expanded and aria-label, so screen readers announce purpose and state.
- 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. 12 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, 516 lines
'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} />;
}Frequent questions
How do I install Categories?
With aiw add @ai-website/categories. The command puts components/ui/categories.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion and section. It needs no further npm packages.
Which variants does Categories have?
5 variants, Icon grid, Image cards, Circles, Link columns and Full background. You pick one with the variant prop, for example variant="image-cards".
Can I translate the texts of Categories?
Yes. Categories carries 7 texts of its own in English and German, such as “1 item”, “Scroll back” and “Scroll on”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Categories?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Categories with aiw_components and install it with aiw_add @ai-website/categories. Name the variant in your prompt, for example variant="icon-grid", and the assistant writes your content straight into the props.
Can I adjust Categories without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Categories code to copy.