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…

"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

export interface FilterChipOption {
  value: string
  label: string
  /** Result count for this facet — rendered tabular so widths never jitter. */
  count?: number
  /** Decorative glyph before the label. */
  icon?: React.ReactNode

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/filter-chips.json

Prompt

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> with aria-pressed, which is exactly how a screen reader announces "pressed / not pressed"; no role="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-hidden and 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.

On This Page