Super-AI-Components

Filter Bar

Category chips, add-filter chip, and filters button.

What it is

The chip-scale end of filtering: a wrapping row of facet chips, an optional add-a-facet chip, and a button that opens the full panel. A chip is a pressable toggle that reports `aria-pressed`, and giving it `onRemove` adds a second, sibling button inside the same pill so an applied facet can be dropped without going through the toggle. The bar itself is layout and nothing else — it holds no filter state, computes no overflow, and enforces no single-select rule.

Why it matters

Filtering has a scale ladder, and this is its first rung. CapCut, Claude Artifacts and Canva all put a handful of facets in a chip row above the content rather than behind a menu, because applied filters have to stay visible — the most expensive moment in any library is a user staring at an empty grid without seeing the facet that emptied it. Midjourney Organize is what the top of the ladder looks like: once facets need counts, groups and their own overflow, the row stops being enough and J2 `filter-panel` takes over. Choosing the rung is the design decision this component exists to make explicit.

Observed in: CapCut · Claude Artifacts · Canva · Midjourney Organize

Anatomy

  1. 1filter-barThe row. A wrapping flex container with a gap — no scroll, no overflow logic.
  2. 2filter-chipThe pill wrapper around one facet. Carries data-state on/off and takes the chip's className.
  3. 3filter-chip-toggleThe pressable half of a chip. Gets aria-pressed and every prop you pass to FilterChip.
  4. 4filter-chip-removeThe X, present only when FilterChip gets onRemove. A sibling of the toggle, never nested inside it.
  5. 5add-filter-chipThe dashed add-a-facet chip. Names itself 'Add <children>' for assistive tech.
  6. 6filters-buttonThe escape hatch to the full filter panel. Labelled 'Filters' by default.

How to use it

Reach for it above a library, grid or list that has a handful of facets — roughly six is the ceiling, past which J2 `filter-panel` is the right answer. Compose the row yourself: place one `FilterChip` per facet, pass `active` from your own state, and treat `onClick` and `onRemove` as intent rather than as the value changing. Add `onRemove` to the facets that are currently applied and leave it off the ones that are merely on offer — that is the difference between a row of toggles and a row of applied filters, and it also decides how many tab stops the row has. Finish with an `AddFilterChip` for facets that are not on screen, and a `FiltersButton` wired to the panel.

Do

  • Drive the bar from your own state — pass active in, apply the change in your handler, and let the re-render move the chip.

    Applied: Image

  • Give every chip a plain string label, so each remove button gets its own accessible name instead of a row of identical X controls.

Don't

  • Don't wrap a chip's label in an element to style it — style the pill through className and keep the children a string, or every remove button in the row collapses to the same name.

  • Don't disable a chip and assume the whole pill is inert — disabled reaches the toggle only, so withhold onRemove in the same breath or the X stays live.

  • Don't keep adding facets to the row and expect it to collapse — it wraps, growing downwards over the content it filters. Cap the list and escalate to filter-panel.

Accessibility

Keyboard
  • A chip with `onRemove` is two tab stops, not one: the toggle, then the X. Six applied facets is twelve stops before the reader reaches the Filters button.
  • Both halves are real buttons, so Space and Enter activate them. There is no Delete or Backspace shortcut — removing a facet from the keyboard means tabbing to the X.
  • `disabled` spreads onto the toggle only. A disabled chip that still has `onRemove` keeps a tabbable, activatable X, so withhold `onRemove` in the same breath.
  • The row has no roving tabindex and no arrow-key navigation. Left and Right do nothing; it is a plain flex row of independent buttons.
Screen reader
  • The toggle carries `aria-pressed`, so it announces as a toggle button that is pressed or not pressed — not as a selected option. A single-select category row will still announce as a set of independent toggles.
  • The remove button's name is built as "Remove <label> filter" from `typeof children === "string"`. Any non-string child drops the label and every X in the row announces identically as "Remove filter".
  • `AddFilterChip` names itself "Add <children>", falling back to the generic "Add filter" when its child is not a string.
  • Both icons are `aria-hidden`, so neither the X nor the sliders glyph contributes to an announced name.
  • Nothing here announces the result of a filter change. The bar owns no live region, so put one on the content it filters or the new result count is silent.
Focus
  • Removing a chip unmounts the button that had focus and nothing restores it, so focus falls to `<body>` and the next Tab restarts from the top of the page. Move focus to the neighbouring chip inside your `onRemove`.
  • Only the remove button ships a `focus-visible` ring. The toggle, `AddFilterChip` and `FiltersButton` inherit your global focus style, so three of the four controls are invisible on focus if you have none.

Watch out for

  • FilterChip splits your props two ways: className styles the outer pill, and everything else — id, onClick, disabled, aria-* — spreads onto the inner toggle button. A ref or a data attribute you expect on the pill lands on the toggle instead.
  • The remove button's accessible name is built from typeof children === "string". Any non-string child — a wrapped span, an icon beside the text, a number — silently degrades it to the generic "Remove filter", which is the one bug in this component a visual review cannot catch.
  • disabled spreads onto the toggle button but never reaches the remove button, which is built from onRemove alone. A disabled applied chip is therefore still deletable by mouse and by keyboard.
  • There is no overflow affordance and no maxVisible: the spec's "+3" collapse is not implemented, and the bar has no scroll container either. However many chips you render is how many lines the row takes, which at 375px is the whole screen — count them yourself before you hand them over.
  • Nothing here enforces a selection rule. Each chip is an independent toggle with its own aria-pressed, so a single-select category row (the shape O2 artifact-shell uses) is something your handler enforces, not something the bar provides.

Installation

npx shadcn@latest add https://super-ai-components.vercel.app/r/filter-bar.json

Requires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.