Inputs
Filter Chips
A wrapping row of toggle chips for narrowing a list — per-facet counts, single or multi select, and a Clear all that only appears when something is on.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/filter-chips.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FilterChips" component using
class-variance-authority (cva) and lucide-react.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>
(minus onChange/defaultValue) plus VariantProps of the chip cva.
- options: { value, label, count?, icon?, disabled? }[] — `count` is the number
of results behind that facet, supplied by the caller (derive it from the data
set, never hardcode it).
- Controlled only: value: string | string[] and
onValueChange: (value: string | string[]) => void — a string ("" means
nothing selected) in single mode, a string[] when multiple is true.
- multiple?: boolean (default false), showClearAll?: boolean (default false),
size?: "sm" | "md" (default "md"), label?: string (the bar's aria-label,
default "Filters"), className merged via cn(), rest spread on the root.
Behavior
- Each chip is a real <button type="button"> with aria-pressed — pressed state,
not a link, not a div; Tab reaches every chip and focus-visible shows a ring.
- Multiple mode: clicking toggles the value in/out of the array, preserving the
order of the rest. Single mode: clicking selects, clicking the active chip
again clears it back to "" (a filter must always be removable).
- A selected chip in multiple mode grows a small × glyph. It is decorative
(aria-hidden), NOT a nested button — nesting interactive elements is invalid
HTML — so the click lands on the chip itself, which is already selected and
therefore deselects: one tap, exactly one toggle, no double-fire.
- "Clear all" renders only while something is selected, and resets to [] or ""
depending on the mode. It is a real button with the same focus treatment.
Because clearing is also what unmounts it, it moves focus to the first enabled
chip on the way out — a button that deletes itself while focused would
otherwise drop the keyboard user on <body> with nothing announced.
- disabled options use the native disabled attribute: unclickable, unfocusable,
dimmed — use it for facets with zero results instead of hiding them.
- The row is flex flex-wrap: chips overflow onto new lines, they never scroll
sideways or shrink their labels.
Rendering & styling
- Chip cva: rounded-full, one `size` axis (sm: h-7 gap-1.5 px-2.5 text-xs /
md: h-8 gap-2 px-3 text-sm), transition-colors, active:scale-[0.97],
focus-visible ring-2 ring-ring with ring-offset-background,
disabled:opacity-50.
- Selected: bg-primary + text-primary-foreground (hover bg-primary/90).
Unselected: bg-muted + text-foreground, hover bg-accent +
text-accent-foreground. Semantic tokens only, no hex.
- The count sits in its own inset pill (rounded-full px-1.5, text-[10px],
tabular-nums) so it reads as metadata rather than part of the label, and its
width never jitters as numbers change; the pill is
bg-primary-foreground/20 when the chip is on and bg-background +
text-muted-foreground when it is off.
- Icons are wrapped in an aria-hidden span sized by the size axis, so consumers
can pass any lucide icon without setting a class.
- prefers-reduced-motion drops the color transition and the press-scale; the
on/off state is carried by background and text color, so nothing is lost.
Customization levers
- Density: the cva `size` axis is the single knob (height, gap, padding, type);
add an "lg" entry plus one line in the icon-size lookup.
- Selected accent: bg-primary/text-primary-foreground — repoint at a chart
token per facet family, or invert to an outline style
(border-primary + text-primary + bg-primary/10) for a lighter bar.
- Count treatment: drop the pill for a plain muted span, or move it in front of
the label; keep tabular-nums either way.
- Removal affordance: hide the × entirely if your chips are small, or show it
on hover only — deselecting still works because it is just the chip's click.
- Overflow policy: swap flex-wrap for a horizontally scrollable row
(overflow-x-auto + shrink-0 chips) on mobile toolbars.
- Counting strategy: pass counts computed against the *unfiltered* set for
stable numbers, or against the currently filtered set for "and-narrowing"
facets — the component only renders what you hand it.Concepts
- Facet filtering — chips stand for a closed set of known buckets; picking one narrows the list, so the bar is a view over your data, not a place to invent new values.
- Toggle chip, not a link — every chip is a
<button>witharia-pressed, which is exactly how a screen reader announces "pressed / not pressed"; norole="checkbox"gymnastics and no fake<div>buttons. - Counts as metadata — the number lives in its own inset pill with
tabular-nums, so it never merges into the label and never jitters as the data changes. - The × is an affordance, not a control — it looks removable but is
aria-hiddenand non-interactive; the click hits the surrounding chip, which is already selected, so one tap removes it — and no invalid nested button is ever rendered. - Clear all on demand — the reset action appears only once something is selected, so the empty state of the bar stays quiet, and it resets to the shape the mode expects (
[]or""). Since its own click is what removes it from the DOM, it hands focus to the first chip before disappearing. - Wrap, never shrink — overflow flows onto a new line instead of compressing labels, which keeps long facet names readable on narrow toolbars.