Category Filter Bar
A horizontal row of CategoryChips that filters a list — common above a feed, a tag cloud, or a search result page. Supports two selection modes via a discriminated union: single (radiogroup, one category at a time) and multiple (toggle group, any subset). An optional leading "All" chip clears the selection.
Customize
Shape
single
Installation
npx shadcn@latest add https://craftbits.dev/r/category-filter-bar.jsonUsage
import { CategoryFilterBar } from "@craft-bits/core";
<CategoryFilterBar
categories={[
{ value: "design", label: "Design", count: 24 },
{ value: "systems", label: "Systems", count: 12 },
{ value: "performance", label: "Performance" },
]}
defaultSelected={null}
onSelectedChange={(next) => setActive(next)}
/>Switch to multi-select by passing mode="multiple". The handler then receives an array:
<CategoryFilterBar
mode="multiple"
categories={categories}
defaultSelected={[]}
onSelectedChange={(next) => setFilter(next)}
/>Hide the "All" chip when the surrounding UI already exposes a clear-filters affordance:
<CategoryFilterBar showAll={false} categories={categories} />Understanding the component
- Discriminated union on
mode. Single-mode flowsstring | null(withnullmeaning "All"); multi-mode flowsstring[](with[]meaning "All"). The shapes are mutually exclusive at the type level, so callers can't hand astring[]to a single-select bar. - Controlled + uncontrolled, per-mode. Pass
selectedto drive from outside, ordefaultSelectedfor an uncontrolled start. Either way, every change firesonSelectedChangewith the right type for the mode. - "All" chip is a real chip. Rather than rendering a separate clear-button, the "All" affordance is the first
CategoryChipin the row. Itsselectedstate is computed from the absence of a filter. That keeps focus management, keyboard navigation, and visual rhythm uniform across every chip. - Single-mode is a radiogroup. The root is
role="radiogroup"; each chip isrole="radio"witharia-checkedand a roving tabindex — only the active chip is in the tab order, the rest are reachable with arrow keys. Arrow keys both move focus and activate, per the W3C radiogroup pattern.Home/Endjump to the ends. - Multi-mode is a plain group. No roving tabindex — every chip is independently tabbable.
aria-pressedon eachCategoryChipreflects its membership in the selection array. Clicking toggles in place. - Composition over configuration. The bar doesn't bake in routing, color tokens, or per-category styling — it just maps a flat
categoriesarray into chips and wires selection. Anything richer (sticky positioning, scroll-snap on overflow) is layered on by the caller.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
categories | readonly { value: string; label: ReactNode; count?: number }[] | — | Ordered category list. Each entry renders one chip. |
mode | 'single' | 'multiple' | 'single' | Selection mode. Drives the type of selected / onSelectedChange. |
selected (single) | string | null | — | Controlled selected value. null clears the selection. |
selected (multiple) | readonly string[] | — | Controlled selected values. [] clears the selection. |
defaultSelected | string | null | readonly string[] | — | Uncontrolled initial selection — type matches mode. |
onSelectedChange | (next) => void | — | Fires on every change. Receives string | null (single) or string[] (multiple). |
showAll | boolean | true | Include a leading "All" chip that clears the selection. |
allLabel | ReactNode | 'All' | Override the label on the "All" chip. |
aria-label | string | 'Filter by category' | Accessible label for the group landmark. |
className | string | — | Merged onto the rendered <div>. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accessibility
- Single-mode renders
role="radiogroup"with anaria-label; each chip isrole="radio"witharia-checkedand a roving tabindex. Arrow keys move focus and activate;Home/Endjump to the ends. - Multi-mode renders
role="group"; each chip uses its nativearia-pressedsemantics (fromCategoryChip) — every chip is independently tabbable. - The "All" chip uses the same chip semantics — assistive tech announces "All, radio button, checked" when no filter is active, mirroring the visual state.
- Focus rings come from
CategoryChip'sfocus-visible:ring-cb-accent; the offset is keyed to--cb-bg. - Color contrast: unselected chips use
--cb-fg-muted; selected chips paint--cb-accent-fgon--cb-accent. Both pass WCAG AA.
Credits
- Extracted from:
terminal-dreams(src/components/principles/CategoryFilterBar.tsx). The original wired inusePrefersReducedMotion, hardcoded color tokens per category, and emitted a click-to-clear behavior unique to the blog. craft-bits lifts the API into a single discriminated-union prop set, defers motion to the underlyingCategoryChip, and renders the "All" affordance as a real chip so keyboard navigation is uniform.