Filter Bar / Filter Menu
Preview: Default
"Add filter" menu (field → options) plus removable chips for active filters, like Linear/Notion filter rows.
Import
import { FilterBar } from '@marbleui/ui'Props
<FilterBar>
| Prop | Type | Default | Description |
|---|---|---|---|
| fields *required | FilterField[] | — | |
| onChange *required | (value: FilterValue) => void | — | |
| value *required | FilterValue | — | |
| className | string | — |
Accessibility
Keyboard interaction, focus management and ARIA semantics come from the underlying primitive: React Aria Button. Follow the primitive's docs for the full keyboard map.
Usage notes
Row of filter chips with an “Add filter” menu: pick a field, then one or more of its options. For free-text search use SearchField; it pairs well with DataGrid’s toolbar.
const [filters, setFilters] = useState<FilterValue>({ status: ['todo'] })
<FilterBar fields={fields} value={filters} onChange={setFilters} />
const visible = applyFilters(issues, filters, (issue, field) => String(issue[field as keyof Issue]))
- Controlled:
valuemaps field ids to selected option ids, e.g.{ status: ['todo', 'doing'] }. Removing a chip deletes its key; Clear sets{}. applyFilterskeeps rows where every active field equals one of its selected options (AND across fields, OR within a field). For server data, send theFilterValueto your API instead.- Only “is” / “is any of” over fixed options: no text, date, range or “is not”.
- Give an option a
textValuewhen itslabelisn’t a plain string; it’s used for typeahead and the chip text. Each chip’s button is named by field, operator and value (“Status is any of Todo, Doing”).
Example source · show· hide
import { useState } from 'react'
import { Badge } from '../badge'
import { applyFilters, FilterBar, type FilterField, type FilterValue } from './filter-bar'
const I = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d={d} /></svg>
)
const fields: FilterField[] = [
{ id: 'status', label: 'Status', icon: <I d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20" />, options: [{ id: 'todo', label: 'Todo' }, { id: 'doing', label: 'In progress' }, { id: 'done', label: 'Done' }] },
{ id: 'priority', label: 'Priority', icon: <I d="M3 3v18h18M7 14l4-4 4 4 5-5" />, options: [{ id: 'high', label: 'High' }, { id: 'medium', label: 'Medium' }, { id: 'low', label: 'Low' }] },
{ id: 'label', label: 'Label', icon: <I d="M12 2H2v10l9.3 9.3a1 1 0 0 0 1.4 0l8.6-8.6a1 1 0 0 0 0-1.4z" />, options: [{ id: 'bug', label: 'Bug' }, { id: 'feature', label: 'Feature' }, { id: 'design', label: 'Design' }] },
]
const issues = [
{ id: 'ACME-140', title: 'Login redirect', status: 'doing', priority: 'high', label: 'bug' },
{ id: 'ACME-141', title: 'Pricing table', status: 'todo', priority: 'medium', label: 'design' },
{ id: 'ACME-142', title: 'Data grid resizing', status: 'doing', priority: 'medium', label: 'feature' },
{ id: 'ACME-143', title: 'Dark mode toggle', status: 'done', priority: 'low', label: 'feature' },
{ id: 'ACME-144', title: 'Crash on paste', status: 'todo', priority: 'high', label: 'bug' },
]
function Demo() {
const [value, setValue] = useState<FilterValue>({ status: ['todo', 'doing'] })
const rows = applyFilters(issues, value, (r, f) => r[f as 'status'])
return (
<div className="flex w-[34rem] flex-col gap-3">
<FilterBar fields={fields} value={value} onChange={setValue} />
<ul className="divide-y divide-border rounded-lg border border-border bg-surface text-sm">
{rows.map((r) => (
<li key={r.id} className="flex items-center gap-3 px-3 py-2">
<span className="w-20 text-fg-subtle tabular-nums">{r.id}</span>
<span className="flex-1 text-fg">{r.title}</span>
<Badge>{r.label}</Badge>
</li>
))}
{!rows.length && <li className="px-3 py-6 text-center text-fg-muted">No issues match.</li>}
</ul>
</div>
)
}
export const styles = { Default: Demo }Source: packages/ui/src/filter-bar/filter-bar.tsx
Catalog
- Category
- data-grid
- Priority
- Pro
- Primitive
- composed
- React Aria
- Menu + TagGroup