# CategoryFilter

> Filter pill row for list pages (today the work index; the blog archive uses BlogCategoryFilter). Multiple visual treatments (pills, underline, minimal) for different surface densities.

- Status: stable
- Group: navigation
- Page: https://www.digitaltableteur.com/design-system/components/category-filter
- Contract: https://www.digitaltableteur.com/contracts/v1/CategoryFilter.contract.json

## When to use

Pill-row filter for list pages: one selectable category set with visual treatments per surface (today the work index; the blog archive uses BlogCategoryFilter). Selection state lives in the parent.

## When not to use

- Do not use as primary navigation; `CategoryFilter` is a UI control, not a nav surface; use `NavLink` for routes.
- Do not stack two filter rows when one composite control would do; readability collapses.

## Look-alikes

- BlogCategoryFilter: Do not use this for generic list pages; that is CategoryFilter.
- NavLink: Do not use as primary navigation; `CategoryFilter` is a UI control, not a nav surface; use `NavLink` for routes.

## Props

| Prop | Type | Default | Description | Required |
| --- | --- | --- | --- | --- |
| `categories` | `CategoryOption[]` | none | Array of category options. | Yes |
| `activeCategory` | `string` | none | Currently active category value (single mode) | No |
| `onCategoryChange` | `(category: string) => void` | none | Callback when category changes (single mode) | No |
| `multiple` | `boolean` | `false` | Lets several chips stay pressed at once. Pressing a chip toggles it, pressing the `allValue` chip clears the selection, and the collapsible row stays open while picking. | No |
| `activeCategories` | `string[]` | `[]` | Pressed values in multiple mode; an empty array presses the `allValue` chip. | No |
| `onCategoriesChange` | `(categories: string[]) => void` | none | Called with the new pressed values in multiple mode. | No |
| `allValue` | `string` | `all` | Value of the chip that clears the selection in multiple mode. Defaults to `"all"`. | No |
| `className` | `string` | none | Custom className. | No |
| `size` | `"sm" \| "md" \| "lg"` | `md` | Size variant. | No |
| `variant` | `"pills" \| "underline" \| "minimal"` | `pills` | Style variant. | No |
| `collapsible` | `boolean` | `false` | Collapse to the active chip and a disclosure button; expanding shows every chip and picking one collapses back to it. | No |
| `disclosureLabel` | `string` | none | Accessible name of the disclosure button; defaults to the translated "All categories". | No |
| `groupLabel` | `string` | none | Accessible name of the filter group; defaults to the translated "Filter by category" (`categoryFilterGroupLabel`). | No |

## Variants

- Available variants: `pills`, `underline`, `minimal` (default `pills`).
- Available sizes: `sm`, `md`, `lg` (default `md`).

## Invalid combinations

The contract records no invalid combinations.

## Keyboard

| Key | Behaviour |
| --- | --- |
| Tab | Moves between filter buttons. |
| Enter / Space | Activates the focused filter. |
| Enter / Space | On the disclosure button (collapsible only): expands or collapses the chip row; picking a chip collapses it again. |

## Rules

Every item under When not to use is a MUST NOT. MUST and SHOULD follow RFC 2119.

| Level | Rule | How it is checked |
| --- | --- | --- |
| MUST | ARIA: Renders a `role=group` named by `groupLabel`, or by the translated 'Filter by category' (`categoryFilterGroupLabel`) when it is not passed. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: The active filter sets `aria-pressed='true'` on a toggle button (not `aria-current`) because the filter is a UI control, not a navigation target; exactly one is pressed at a time (single-select). | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: With `collapsible`, a leading disclosure button named by `disclosureLabel` (default: the translated 'All categories') carries `aria-expanded`. It has no `aria-controls`, because it sits inside the group it would point at. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | Required stories produce no axe violations at the configured severity. | Automated by `npm run test:stories:matrix:ci`. WCAG 2.5.8. |
| MUST | The accessibility tree (role, name, state) matches the committed snapshot for every required story. | Automated by `npm run test:stories:matrix:ci`. WCAG 1.3.1, 4.1.2. |
| MUST | The component remains legible and operable under a real browser forced-colors: active pass. | Automated by `npm run test:stories:hc`. |
| MUST | Every documented keyboard interaction works as specified. | Not yet verified: no evidence record. WCAG 2.1.1, 2.1.2. |
| MUST | A human completed an end-to-end accessibility review of this component. | Manual review, last on 2026-11-04. |

## Install and import

- Install: `npm install @digitaltableteur/react` (version 0.9.1, restricted access on npm).
- Import: `import { CategoryFilter } from "@digitaltableteur/react";`
- Entry point: `@digitaltableteur/react/patterns` exports it too.
- Styles: import `@digitaltableteur/react/style.css` once at the app boundary.
- Also available as the custom element `<dt-category-filter>`; see the custom elements manifest.

## Links

- Machine-readable contract (Design System Contract 1.0): https://www.digitaltableteur.com/contracts/v1/CategoryFilter.contract.json
- Markdown version of this page: https://www.digitaltableteur.com/design-system/components/category-filter.md
- Component index as JSON: https://www.digitaltableteur.com/design-system/components.json
- All components: https://www.digitaltableteur.com/design-system/components
- Source: The implementation lives in a private repository. This page, its Markdown twin and the contract are generated from it at build time and are the public source of truth.
