Hacktron Design System

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
fieldsFilterFieldDef[]-
searchPlaceholderstring-
modelValueRecord<string, string[]>{}
searchstring""
clearSearchLabelstring"Clear search"
filterLabelstring"Filter"
clearLabelstring"Clear"
showSearchbooleantrue
clearIncludesSearchbooleanfalse
loadingbooleanfalse

Slots

Name Description
trailingRight-aligned content next to the search/filter row - e.g. a result count.
option-leadingScoped `{ 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-labelScoped `{ 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): options is { value, label, icon?, description? }[]. description renders as a second line under the label, and a searchable field's Combobox matches search text against both label and description. multiple: false renders the picker in single mode. A searchable field also takes searchPlaceholder (its panel's search box) and virtualize (render only the visible rows, for hundreds of options).
  • toggle: a Switch with 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 }; call update(next) with the new string[] so Clear and the rest of the model stay consistent. FilterBar doesn't render a custom field's label, so the slot's control owns its accessible name (pass field.label as its aria-label or 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:

FieldStored value
select, multiplethe selected option values, [] when none
select, multiple: false[value] when picked, [] when not
toggle['true'] when on, [] when off
customwhatever 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 Table as 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 searchable when its options list is long enough to need typing to narrow it (renders as Combobox); leave it off for a short, fixed list (renders as Select).
  • Use multiple: false when only one value makes sense at a time (a branch, a single time window). Prefer searchable for 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 the FilterValues model through update so Clear resets it.
  • Give options a description when the label alone is ambiguous (an org's display name plus its slug), since searchable fields match on it.
  • Use #option-label instead of #option-leading when the decoration already includes the option's text (a status badge with its label), so the label isn't printed twice.
  • Set showSearch: false rather than rendering a search box that does nothing.
  • Own search/modelValue filtering yourself - this component only tracks which fields are active and their values, the same posture Table takes 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 custom field for a static option list - a select field 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>