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.json

Usage

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

  1. Discriminated union on mode. Single-mode flows string | null (with null meaning "All"); multi-mode flows string[] (with [] meaning "All"). The shapes are mutually exclusive at the type level, so callers can't hand a string[] to a single-select bar.
  2. Controlled + uncontrolled, per-mode. Pass selected to drive from outside, or defaultSelected for an uncontrolled start. Either way, every change fires onSelectedChange with the right type for the mode.
  3. "All" chip is a real chip. Rather than rendering a separate clear-button, the "All" affordance is the first CategoryChip in the row. Its selected state is computed from the absence of a filter. That keeps focus management, keyboard navigation, and visual rhythm uniform across every chip.
  4. Single-mode is a radiogroup. The root is role="radiogroup"; each chip is role="radio" with aria-checked and 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 / End jump to the ends.
  5. Multi-mode is a plain group. No roving tabindex — every chip is independently tabbable. aria-pressed on each CategoryChip reflects its membership in the selection array. Clicking toggles in place.
  6. Composition over configuration. The bar doesn't bake in routing, color tokens, or per-category styling — it just maps a flat categories array into chips and wires selection. Anything richer (sticky positioning, scroll-snap on overflow) is layered on by the caller.

Props

PropTypeDefaultDescription
categoriesreadonly { 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 | nullControlled selected value. null clears the selection.
selected (multiple)readonly string[]Controlled selected values. [] clears the selection.
defaultSelectedstring | null | readonly string[]Uncontrolled initial selection — type matches mode.
onSelectedChange(next) => voidFires on every change. Receives string | null (single) or string[] (multiple).
showAllbooleantrueInclude a leading "All" chip that clears the selection.
allLabelReactNode'All'Override the label on the "All" chip.
aria-labelstring'Filter by category'Accessible label for the group landmark.
classNamestringMerged onto the rendered <div>.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Accessibility

  • Single-mode renders role="radiogroup" with an aria-label; each chip is role="radio" with aria-checked and a roving tabindex. Arrow keys move focus and activate; Home / End jump to the ends.
  • Multi-mode renders role="group"; each chip uses its native aria-pressed semantics (from CategoryChip) — 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's focus-visible:ring-cb-accent; the offset is keyed to --cb-bg.
  • Color contrast: unselected chips use --cb-fg-muted; selected chips paint --cb-accent-fg on --cb-accent. Both pass WCAG AA.

Credits

  • Extracted from: terminal-dreams (src/components/principles/CategoryFilterBar.tsx). The original wired in usePrefersReducedMotion, 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 underlying CategoryChip, and renders the "All" affordance as a real chip so keyboard navigation is uniform.