FilterBar
The search box and filter menu above a table. Each filter added becomes its own picker.
<template> <FilterBar v-model="activeFilters" v-model:search="search" :fields="filterFields" /></template>Props
| Name | Type | Default |
|---|---|---|
| fields | FilterFieldDef[] | - |
| searchPlaceholder | string | - |
| modelValue | Record<string, string[]> | {} |
| search | string | "" |
| clearSearchLabel | string | "Clear search" |
| filterLabel | string | "Filter" |
| clearLabel | string | "Clear" |
| showSearch | boolean | true |
| clearIncludesSearch | boolean | false |
| loading | boolean | false |
Slots
| Name | Description |
|---|---|
| trailing | Right-aligned content next to the search/filter row - e.g. a result count. |
| option-leading | Scoped `{ field, option }`. Replaces each option row's leading icon in every select field's picker (e.g. a status dot). Once filled it applies to every field, so render `option.icon` yourself for the others. |
| field-<key> | Scoped `{ field, value, update }`. The control for a `type: 'custom'` field (e.g. a server-searched picker or a date range); call `update(string[])` to write its value. |
| option-label | Scoped `{ field, option }`. Replaces each option row's label in every select field's picker (e.g. a status badge that carries its own text). Once filled it applies to every field, so render `option.label` in a `v-else` for the others. |
Guidelines
Structure
Two rows. The top row is the search Input, the "+ Filter" DropdownMenu that adds one field at a time, and an optional #trailing slot pushed to the right (a result count, an export button). The second row, the active-fields row, holds one control per visible field followed by a Clear button. It renders only while at least one field is visible: a field the user added from "+ Filter", or a pinned field. Clear itself shows only while it has something to do: a picked value, a non-pinned field still showing, or (with clearIncludesSearch) search text.
fields is a list of FilterFieldDef, a union on type. Every type shares key, label, icon? and pinned?:
const fields: FilterFieldDef[] = [
// type omitted = 'select'. Multi-select by default; `searchable` swaps Select for Combobox.
{ key: 'severity', label: 'Severity', icon: 'i-lucide-triangle-alert', options: severityOptions },
{ key: 'owner', label: 'Owner', searchable: true, options: ownerOptions },
// Single select: still stored as an array, with 0 or 1 entries.
{ key: 'branch', label: 'Branch', searchable: true, multiple: false, options: branchOptions },
// On/off switch with its label inline. No options.
{ key: 'assignedToMe', label: 'Assigned to me', type: 'toggle', pinned: true },
// Your own control, rendered from the #field-org slot.
{ key: 'org', label: 'Organization', type: 'custom' },
]
select(the default):optionsis{ value, label, icon?, description? }[].descriptionrenders as a second line under the label, and asearchablefield's Combobox matches search text against both label and description.multiple: falserenders the picker in single mode. Asearchablefield also takessearchPlaceholder(its panel's search box) andvirtualize(render only the visible rows, for hundreds of options).toggle: aSwitchwith the field's label next to it.custom: renders your#field-<key>slot in the active-fields row, for a control FilterBar can't own (a server-searched org picker, a date range). The slot receives{ field, value, update }; callupdate(next)with the newstring[]so Clear and the rest of the model stay consistent. FilterBar doesn't render a custom field'slabel, so the slot's control owns its accessible name (passfield.labelas itsaria-labelor placeholder).pinned(any type): always in the active-fields row, never listed in "+ Filter", and Clear resets it to[]instead of removing it. When every field is pinned, the "+ Filter" button is hidden.defaultValue(any type): the value Clear resets the field to, for a list that always filters by something (a job state). Holding the default doesn't count as something to clear, so Clear only appears once the field holds anything else.
#option-leading="{ field, option }" replaces the leading icon of every option row in every select field's picker, for a status dot or badge. Branch on field.key when only some fields need it. #option-label="{ field, option }" replaces the label itself, for a badge that carries its own text. Either slot, once filled, replaces that part of every option in every select field's picker, so render the default yourself for the fields you don't decorate (<UIcon v-if="option.icon" :name="option.icon" /> or {{ option.label }} in a v-else).
loading renders Skeleton bars in place of the search input, the "+ Filter" button and each visible field's control during a first load. It only applies until loading first turns false, so you can pass the query's plain pending flag: a later reload (a filter change starting a fresh query) leaves the controls live and a picker the user is in never unmounts.
showSearch: false hides the search input for a list whose endpoint has no text search. clearIncludesSearch makes Clear also empty the search text; by default it only resets the filter fields, and the search box keeps its own clear button.
Value model
v-model is FilterValues, a Record<string, string[]> keyed by field key, for every field type, so the model never needs per-type handling:
| Field | Stored value |
|---|---|
select, multiple | the selected option values, [] when none |
select, multiple: false | [value] when picked, [] when not |
toggle | ['true'] when on, [] when off |
custom | whatever string[] your slot passes to update |
A non-pinned key's presence (even as []) is what keeps its control in the row. Adding a field from "+ Filter" sets its key to []; Clear deletes every non-pinned key and sets every pinned key to []. Read values with filters.status?.length checks rather than 'status' in filters, since a present key can still be empty.
v-model:search updates on every keystroke, with no debounce. Debounce it yourself when the search drives a server request (for example refDebounced(search, 300) from VueUse); a client-side filter over data already loaded usually needs none.
Do
- Pair it with
Tableas the search-and-filter half of the Tables UI pattern - see the Tables UI pattern page for the full FilterBar → Table → footer composition. - Mark a field
searchablewhen itsoptionslist is long enough to need typing to narrow it (renders asCombobox); leave it off for a short, fixed list (renders asSelect). - Use
multiple: falsewhen only one value makes sense at a time (a branch, a single time window). Prefersearchablefor a single-select field that users need to unset on its own: picking the selected option again in a Combobox clears it, while a single-mode Select can only swap its value, so Clear is the only way back to none.
- Pin a field when users filter on it on almost every visit (status on a findings list, "Assigned to me"), so it's one click away instead of two. Keep pinned fields to the one or two that matter most; everything else stays in "+ Filter".
- Use
type: 'toggle'for a yes/no filter instead of a select with a single option. - Use
type: 'custom'for anything that loads or searches options from the server, or isn't a list of options at all (dates). Keep its value in theFilterValuesmodel throughupdateso Clear resets it. - Give options a
descriptionwhen the label alone is ambiguous (an org's display name plus its slug), since searchable fields match on it. - Use
#option-labelinstead of#option-leadingwhen the decoration already includes the option's text (a status badge with its label), so the label isn't printed twice. - Set
showSearch: falserather than rendering a search box that does nothing. - Own
search/modelValuefiltering yourself - this component only tracks which fields are active and their values, the same postureTabletakes toward its own data.
Don't
- Don't expect a per-field remove affordance - an active field's chip stays until "Clear" removes every active field at once, or you clear it yourself; deselecting every value inside a field's own picker leaves that field's chip idle (0 selected) rather than auto-removing it.
- Don't expect the "+ Filter" menu to shrink as fields are added - every addable field stays listed, with an already-active one rendering checked and disabled instead of disappearing, so the full filterable set is always visible at a glance.
- Don't store a toggle or single-select value as a boolean or bare string in your own model - FilterBar always reads and writes
string[].
- Don't pin every field. A row of always-visible idle controls is the clutter "+ Filter" exists to avoid.
- Don't use a
customfield for a static option list - aselectfield already handles it, with search, the count badge, and Clear.
Examples
Variants
Pinned field
A field the user filters on almost every visit: always in the row, not in the + Filter menu, and reset rather than removed by Clear.
<template> <FilterBar v-model="filters" v-model:search="search" :fields="[ { key: 'status', label: 'Status', icon: 'i-lucide-circle-dot', pinned: true, options: statusOptions }, { key: 'severity', label: 'Severity', icon: 'i-lucide-triangle-alert', options: severityOptions }, ]" /></template>Single select
A field where only one value makes sense. Picking the selected option again in a searchable field clears it.
<template> <FilterBar v-model="filters" v-model:search="search" :fields="[ { key: 'status', label: 'Status', multiple: false, options: statusOptions }, { key: 'branch', label: 'Branch', searchable: true, multiple: false, options: branchOptions }, ]" /></template>Toggle
An on/off filter with no options, stored as ['true'] when on and [] when off.
<template> <FilterBar v-model="filters" v-model:search="search" :fields="[ { key: 'assignedToMe', label: 'Assigned to me', type: 'toggle', pinned: true }, { key: 'severity', label: 'Severity', options: severityOptions }, ]" /></template>With icons and slots
Custom field
A caller-owned control, such as a date or a server-searched picker. FilterBar only manages its presence and value.
<template> <FilterBar v-model="filters" v-model:search="search" :fields="[ { key: 'updatedSince', label: 'Updated since', type: 'custom', pinned: true }, { key: 'status', label: 'Status', options: statusOptions }, ]" > <template #field-updatedSince="{ field, value, update }"> <Input type="date" :aria-label="field.label" :model-value="value[0] ?? ''" @update:model-value="(date: string) => update(date ? [date] : [])" /> </template> </FilterBar></template>Custom option leading
A status dot or badge in front of each option label.
<template> <FilterBar v-model="filters" v-model:search="search" :fields="fields"> <template #option-leading="{ option }"> <span class="size-2 shrink-0 rounded-full" :class="severityDot[option.value]" /> </template> </FilterBar></template>