Category Chip

A small pill-shaped button that represents a category, topic, or label. Selection state is controlled by the call-site — the chip itself is stateless. Used standalone in post metadata, list rows, or composed into a CategoryFilterBar for a horizontal filter row.

Customize
State
0
Tone
default

Installation

npx shadcn@latest add https://craftbits.dev/r/category-chip.json

Usage

import { CategoryChip } from "@craft-bits/core";
 
<CategoryChip selected={category === "rust"} onClick={() => setCategory("rust")}>
  Rust
</CategoryChip>

Pass a count to surface the number of items in the category:

<CategoryChip count={42}>Systems</CategoryChip>

Switch the unselected emphasis to accent for a tone that previews the selected state:

<CategoryChip tone="accent">Featured</CategoryChip>

Understanding the component

  1. Stateless surface. The chip owns no selection state — selected is a prop, onClick is a prop. Callers manage the source of truth (URL, local state, store). This keeps the chip composable inside CategoryFilterBar, lists, and bespoke filter UIs without prop bleed.
  2. CVA-driven states. Selected / unselected styling lives in one categoryChipVariants block. Selected chips paint the accent surface with an accent-fg label; unselected chips show an outlined transparent surface with fg-muted text and a subtle hover lift. The tone="accent" variant only modifies the unselected look — selected always wins.
  3. Motion polish. whileTap uses the canonical TAP_SCALE (0.96), driven by SPRINGS.snap for a crisp press-release feel. Color transitions run at 150ms — well under the 300ms ceiling. Disabled chips skip the tap animation.
  4. A11y baked in. Renders a <button> with aria-pressed reflecting the selected boolean, so screen readers announce "Rust, toggle button, pressed" without any extra wiring. A data-state="selected" attribute hooks CSS-driven styles for consumers that prefer state attributes.
  5. Optional count. When count >= 0, a dimmed mono pill renders after the label. It's aria-hidden because the surrounding label already conveys what the chip filters — the count is supplementary.

Props

PropTypeDefaultDescription
selectedbooleanfalseDrives the filled accent surface and aria-pressed.
disabledbooleanfalseInert state — keeps the chip in the DOM, removes the tap response.
countnumberOptional count, rendered in a dimmed mono pill after the label.
tone'default' | 'accent''default'Unselected emphasis. Selected always uses the accent surface regardless of tone.
childrenReactNodeLabel content.
classNamestringMerged onto the rendered <button>.
...restHTMLMotionProps<'button'>Any other Motion-aware button prop.

Accessibility

  • The chip is a <button> with aria-pressed reflecting the selected prop — screen readers announce "pressed" / "not pressed" state.
  • disabled is the native HTML attribute — keyboard and pointer interactions are blocked, and the chip leaves the tab order.
  • A focus-visible: ring keyed to --cb-accent, offset from --cb-bg, ensures keyboard users can always see the focused chip on every theme surface.
  • Color contrast in the default theme: unselected label uses --cb-fg-muted against --cb-bg; selected uses --cb-accent-fg against --cb-accent. Both pass WCAG AA.

Credits

  • Extracted from: terminal-dreams (src/components/principles/CategoryChip.tsx). The original baked in next/link routing, per-category color tokens, and a hardcoded CATEGORIES lookup. craft-bits lifts the chip into a button primitive: the consumer drives selection, the call-site decides routing, and styling consumes our accent token so it themes.