Filter bar
The row of controls above a list: a search, a few facets, a date range, the rarer filters behind a button, and Clear all.
6 of 6 customers
| Customer | Type | Status | Country | Created |
|---|---|---|---|---|
| Mariam Keita | Individual | Succeeded | Senegal | Jun 2, 2026 |
| Okeke Trading | Business | Processing | Cameroon | May 21, 2026 |
| Lina Mansouri | Individual | Pending | Ivory Coast | May 9, 2026 |
| Ateba & Sons | Business | Succeeded | Cameroon | Apr 17, 2026 |
| Salma Bennani | Individual | Failed | Senegal | Mar 30, 2026 |
| Mensah Foods | Business | Succeeded | Ivory Coast | Feb 11, 2026 |
'use client'
import { DateRangePicker } from '@/components/date-range-picker'
import { FilterBar } from '@/components/filter-bar'
import { Input, InputGroup } from '@/components/input'
import { MultiSelect } from '@/components/multi-select'
import { MagnifyingGlassIcon } from '@heroicons/react/16/solid'
import { useState } from 'react'
const typeOptions = [
{ value: 'individual', label: 'Individual' },
{ value: 'business', label: 'Business' },
]
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
]
export default function Example() {
let [search, setSearch] = useState('')
let [types, setTypes] = useState<string[]>([])
let [statuses, setStatuses] = useState<string[]>([])
let [range, setRange] = useState<{ from: string; to: string } | null>(null)
let hasFilters = search.trim() !== '' || types.length > 0 || statuses.length > 0 || range !== null
function clear() {
setSearch('')
setTypes([])
setStatuses([])
setRange(null)
}
return (
<FilterBar hasFilters={hasFilters} onClear={clear}>
<InputGroup className="col-span-2 lg:w-72">
<MagnifyingGlassIcon />
<Input
type="search"
name="search"
placeholder="Search by name, email, ID…"
aria-label="Search customers"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</InputGroup>
<MultiSelect value={types} onChange={setTypes} placeholder="Type" options={typeOptions} className="lg:w-36" />
<MultiSelect value={statuses} onChange={setStatuses} placeholder="Status" options={statusOptions} className="lg:w-36" />
<DateRangePicker value={range} onChange={setRange} placeholder="Created" className="col-span-2 lg:w-56" />
</FilterBar>
)
}<script setup lang="ts">
import DateRangePicker from '@/components/date-range-picker.vue'
import FilterBar from '@/components/filter-bar.vue'
import Input from '@/components/input.vue'
import InputGroup from '@/components/input-group.vue'
import MultiSelect from '@/components/multi-select.vue'
import { MagnifyingGlassIcon } from '@heroicons/vue/16/solid'
import { computed, ref } from 'vue'
const typeOptions = [
{ value: 'individual', label: 'Individual' },
{ value: 'business', label: 'Business' },
]
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
]
const search = ref('')
const types = ref<string[]>([])
const statuses = ref<string[]>([])
const range = ref<{ from: string; to: string } | null>(null)
const hasFilters = computed(
() => search.value.trim() !== '' || types.value.length > 0 || statuses.value.length > 0 || range.value !== null
)
function clear() {
search.value = ''
types.value = []
statuses.value = []
range.value = null
}
</script>
<template>
<FilterBar :has-filters="hasFilters" @clear="clear">
<InputGroup class="col-span-2 lg:w-72">
<MagnifyingGlassIcon />
<Input v-model="search" type="search" name="search" placeholder="Search by name, email, ID…" aria-label="Search customers" />
</InputGroup>
<MultiSelect v-model="types" placeholder="Type" :options="typeOptions" class="lg:w-36" />
<MultiSelect v-model="statuses" placeholder="Status" :options="statusOptions" class="lg:w-36" />
<DateRangePicker v-model="range" placeholder="Created" class="col-span-2 lg:w-56" />
</FilterBar>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { AdjustmentsHorizontalIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useRef } from 'react'
import { Button } from './button'
export function FilterBar({
hasFilters = false,
onClear,
clearLabel = 'Clear all',
actions,
className,
children,
...props
}: {
hasFilters?: boolean
onClear?: () => void
clearLabel?: React.ReactNode
actions?: React.ReactNode
} & React.ComponentPropsWithoutRef<'div'>) {
let bar = useRef<HTMLDivElement>(null)
function clear() {
onClear?.()
bar.current?.querySelector<HTMLElement>('input, button')?.focus()
}
return (
<div
ref={bar}
{...props}
className={clsx(className, 'grid grid-cols-2 gap-3 lg:flex lg:flex-wrap lg:items-center')}
>
{children}
{hasFilters && onClear ? (
<Button plain className="col-span-2 lg:col-auto" onClick={clear}>
{clearLabel}
</Button>
) : null}
{actions ? <div className="col-span-2 flex justify-end lg:col-auto lg:ml-auto">{actions}</div> : null}
</div>
)
}
export function FilterPopover({
label = 'More',
count = 0,
className,
children,
...props
}: {
label?: React.ReactNode
count?: number
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> & { children: React.ReactNode }) {
return (
<Popover {...props} className={clsx(className, 'max-lg:col-span-1')}>
<PopoverButton as={Button} outline>
<AdjustmentsHorizontalIcon data-slot="icon" />
{label}
{count ? <span className="text-zinc-500 tabular-nums">{count}</span> : null}
</PopoverButton>
<PopoverPanel
anchor={{ to: 'bottom start', gap: 8, padding: 8 }}
className="z-20 flex w-72 flex-col gap-4 rounded-xl border border-zinc-950/10 bg-white p-4 shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
>
{children}
</PopoverPanel>
</Popover>
)
}Also copy Button.
<script setup lang="ts">
import { ref } from 'vue'
import Button from './button.vue'
const props = withDefaults(
defineProps<{
hasFilters?: boolean
clearLabel?: string
onClear?: () => void
}>(),
{ hasFilters: false, clearLabel: 'Clear all', onClear: undefined }
)
const bar = ref<HTMLDivElement | null>(null)
function clear() {
props.onClear?.()
bar.value?.querySelector<HTMLElement>('input, button')?.focus()
}
</script>
<template>
<div ref="bar" class="grid grid-cols-2 gap-3 lg:flex lg:flex-wrap lg:items-center">
<slot />
<Button v-if="hasFilters && onClear" plain class="col-span-2 lg:col-auto" @click="clear">
{{ clearLabel }}
</Button>
<div v-if="$slots.actions" class="col-span-2 flex justify-end lg:col-auto lg:ml-auto">
<slot name="actions" />
</div>
</div>
</template>Also copy Button.
Component API
| Prop | Default | Description |
|---|---|---|
FilterBarrenders a <div> element | ||
hasFilters | false | Shows the Clear all button. |
onClear | - | Called by Clear all. Reset every filter there. |
clearLabel | Clear all | Text of that button. |
actions | - | Pushed to the right: a column picker, an export menu. |
FilterPopoverbuilt on the Headless UI <Headless.Popover> component | ||
label | More | Text of the button. |
count | 0 | Number of filters set inside, shown after the label. |
| Prop | Default | Description |
|---|---|---|
FilterBarrenders a <div> element | ||
hasFilters | false | Shows the Clear all button. |
@clear | - | Emitted by Clear all. Reset every filter there. Without a listener, the button never shows. |
clearLabel | Clear all | Text of that button. |
#actions | - | Pushed to the right: a column picker, an export menu. |
FilterPopoverbuilt on the Reka UI <PopoverRoot> component | ||
label | More | Text of the button. |
count | 0 | Number of filters set inside, shown after the label. |
Examples
Basic example
The bar is a two-column grid under lg and a wrapping row from lg. Size each control yourself: col-span-2 for the search and the date range, lg:w-* for the width on a desktop. Set a filter and Clear all appears:
6 of 6 customers
| Customer | Type | Status | Country | Created |
|---|---|---|---|---|
| Mariam Keita | Individual | Succeeded | Senegal | Jun 2, 2026 |
| Okeke Trading | Business | Processing | Cameroon | May 21, 2026 |
| Lina Mansouri | Individual | Pending | Ivory Coast | May 9, 2026 |
| Ateba & Sons | Business | Succeeded | Cameroon | Apr 17, 2026 |
| Salma Bennani | Individual | Failed | Senegal | Mar 30, 2026 |
| Mensah Foods | Business | Succeeded | Ivory Coast | Feb 11, 2026 |
'use client'
import { DateRangePicker } from '@/components/date-range-picker'
import { FilterBar } from '@/components/filter-bar'
import { Input, InputGroup } from '@/components/input'
import { MultiSelect } from '@/components/multi-select'
import { MagnifyingGlassIcon } from '@heroicons/react/16/solid'
import { useState } from 'react'
const typeOptions = [
{ value: 'individual', label: 'Individual' },
{ value: 'business', label: 'Business' },
]
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
]
export default function Example() {
let [search, setSearch] = useState('')
let [types, setTypes] = useState<string[]>([])
let [statuses, setStatuses] = useState<string[]>([])
let [range, setRange] = useState<{ from: string; to: string } | null>(null)
let hasFilters = search.trim() !== '' || types.length > 0 || statuses.length > 0 || range !== null
function clear() {
setSearch('')
setTypes([])
setStatuses([])
setRange(null)
}
return (
<FilterBar hasFilters={hasFilters} onClear={clear}>
<InputGroup className="col-span-2 lg:w-72">
<MagnifyingGlassIcon />
<Input
type="search"
name="search"
placeholder="Search by name, email, ID…"
aria-label="Search customers"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</InputGroup>
<MultiSelect value={types} onChange={setTypes} placeholder="Type" options={typeOptions} className="lg:w-36" />
<MultiSelect value={statuses} onChange={setStatuses} placeholder="Status" options={statusOptions} className="lg:w-36" />
<DateRangePicker value={range} onChange={setRange} placeholder="Created" className="col-span-2 lg:w-56" />
</FilterBar>
)
}<script setup lang="ts">
import DateRangePicker from '@/components/date-range-picker.vue'
import FilterBar from '@/components/filter-bar.vue'
import Input from '@/components/input.vue'
import InputGroup from '@/components/input-group.vue'
import MultiSelect from '@/components/multi-select.vue'
import { MagnifyingGlassIcon } from '@heroicons/vue/16/solid'
import { computed, ref } from 'vue'
const typeOptions = [
{ value: 'individual', label: 'Individual' },
{ value: 'business', label: 'Business' },
]
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
]
const search = ref('')
const types = ref<string[]>([])
const statuses = ref<string[]>([])
const range = ref<{ from: string; to: string } | null>(null)
const hasFilters = computed(
() => search.value.trim() !== '' || types.value.length > 0 || statuses.value.length > 0 || range.value !== null
)
function clear() {
search.value = ''
types.value = []
statuses.value = []
range.value = null
}
</script>
<template>
<FilterBar :has-filters="hasFilters" @clear="clear">
<InputGroup class="col-span-2 lg:w-72">
<MagnifyingGlassIcon />
<Input v-model="search" type="search" name="search" placeholder="Search by name, email, ID…" aria-label="Search customers" />
</InputGroup>
<MultiSelect v-model="types" placeholder="Type" :options="typeOptions" class="lg:w-36" />
<MultiSelect v-model="statuses" placeholder="Status" :options="statusOptions" class="lg:w-36" />
<DateRangePicker v-model="range" placeholder="Created" class="col-span-2 lg:w-56" />
</FilterBar>
</template>With a popover
Facets used less often go behind a FilterPopover. Give it the number of filters set inside, it shows after the label:
6 of 6 customers
| Customer | Type | Status | Country | Created |
|---|---|---|---|---|
| Mariam Keita | Individual | Succeeded | Senegal | Jun 2, 2026 |
| Okeke Trading | Business | Processing | Cameroon | May 21, 2026 |
| Lina Mansouri | Individual | Pending | Ivory Coast | May 9, 2026 |
| Ateba & Sons | Business | Succeeded | Cameroon | Apr 17, 2026 |
| Salma Bennani | Individual | Failed | Senegal | Mar 30, 2026 |
| Mensah Foods | Business | Succeeded | Ivory Coast | Feb 11, 2026 |
<FilterBar hasFilters={hasFilters} onClear={clear}>
…
<FilterPopover count={countries.length + groups.length}>
<Field>
<Label>Country</Label>
<MultiSelect value={countries} onChange={setCountries} placeholder="Any country" options={countryOptions} />
</Field>
<Field>
<Label>Group</Label>
<MultiSelect value={groups} onChange={setGroups} placeholder="Any group" options={groupOptions} />
</Field>
</FilterPopover>
</FilterBar><FilterBar :has-filters="hasFilters" @clear="clear">
…
<FilterPopover :count="countries.length + groups.length">
<Field>
<Label>Country</Label>
<MultiSelect v-model="countries" placeholder="Any country" :options="countryOptions" />
</Field>
<Field>
<Label>Group</Label>
<MultiSelect v-model="groups" placeholder="Any group" :options="groupOptions" />
</Field>
</FilterPopover>
</FilterBar>With actions on the right
Anything that belongs on the far right, a column picker or an export menu, goes in actions:
6 of 6 customers
| Mariam Keita | Individual | Senegal | Jun 2, 2026 |
| Okeke Trading | Business | Cameroon | May 21, 2026 |
| Lina Mansouri | Individual | Ivory Coast | May 9, 2026 |
| Ateba & Sons | Business | Cameroon | Apr 17, 2026 |
| Salma Bennani | Individual | Senegal | Mar 30, 2026 |
| Mensah Foods | Business | Ivory Coast | Feb 11, 2026 |
<FilterBar
hasFilters={hasFilters}
onClear={clear}
actions={<DataTableColumns table={table} customized={isCustomized} onReset={resetColumns} />}
>
…
</FilterBar><FilterBar :has-filters="hasFilters" @clear="clear">
…
<template #actions>
<Button outline>
<TableCellsIcon data-slot="icon" />
Columns
</Button>
</template>
</FilterBar>Filtering a list
Behind the bar, useListFilters keeps the state for you: the search, keyed facets, a date range, and the rows that pass them. hasFilters and clearFilters plug straight into the bar. It filters locally; in production the same state goes out as query parameters:
6 of 6 invoices
| Customer | Status | Amount | Date |
|---|---|---|---|
| Mariam Keita | paid | $80.00 | 2026-05-09 |
| Chidi Okeke | paid | $142.50 | 2026-05-08 |
| Lina Mansouri | pending | $32.00 | 2026-05-08 |
| Serge Ateba | overdue | $276.00 | 2026-05-07 |
| Salma Bennani | paid | $54.00 | 2026-05-07 |
| Kofi Mensah | pending | $99.00 | 2026-05-06 |
'use client'
import { Button } from '@/components/button'
import { DateRangePicker } from '@/components/date-range-picker'
import { FilterBar } from '@/components/filter-bar'
import { MultiSelect } from '@/components/multi-select'
import { useListFilters } from '@/components/use-list-filters'
type Invoice = { id: string; customer: string; status: string; tags: string[]; date: string }
const statuses = [
{ value: 'paid', label: 'Paid' },
{ value: 'pending', label: 'Pending' },
{ value: 'overdue', label: 'Overdue' },
]
export default function Invoices({ invoices }: { invoices: Invoice[] }) {
let filters = useListFilters({
data: invoices,
search: (invoice) => `${invoice.id} ${invoice.customer}`,
facets: {
status: (invoice) => invoice.status,
tags: { predicate: (invoice, selected) => selected.some((tag) => invoice.tags.includes(tag)) },
},
date: (invoice) => invoice.date,
groupBy: (invoice) => invoice.date.slice(0, 7),
pageSize: 25,
})
return (
<>
<FilterBar hasFilters={filters.hasFilters} onClear={filters.clearFilters}>
<MultiSelect
placeholder="Status"
options={statuses}
value={filters.values.status ?? []}
onChange={(next) => filters.setValues('status', next)}
/>
<DateRangePicker value={filters.range} onChange={filters.setRange} />
</FilterBar>
<p role="status" className="sr-only">
{filters.filtered.length} invoices
</p>
{filters.sections.map((section) => (
<section key={section.key} aria-label={section.key}>
<h2>{section.key}</h2>
<ul>
{section.items.map((invoice) => (
<li key={invoice.id}>
{invoice.id} {invoice.customer}
</li>
))}
</ul>
</section>
))}
{filters.remaining > 0 && (
<Button plain onClick={filters.showMore}>
Show {filters.remaining} more
</Button>
)}
</>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import DateRangePicker from '@/components/date-range-picker.vue'
import FilterBar from '@/components/filter-bar.vue'
import MultiSelect from '@/components/multi-select.vue'
import { useListFilters } from '@/components/use-list-filters'
type Invoice = { id: string; customer: string; status: string; tags: string[]; date: string }
const statuses = [
{ value: 'paid', label: 'Paid' },
{ value: 'pending', label: 'Pending' },
{ value: 'overdue', label: 'Overdue' },
]
const props = defineProps<{ invoices: Invoice[] }>()
const { values, range, hasFilters, clearFilters, filtered, sections, remaining, showMore } = useListFilters({
data: () => props.invoices,
search: (invoice) => `${invoice.id} ${invoice.customer}`,
facets: {
status: (invoice) => invoice.status,
tags: { predicate: (invoice, selected) => selected.some((tag) => invoice.tags.includes(tag)) },
},
date: (invoice) => invoice.date,
groupBy: (invoice) => invoice.date.slice(0, 7),
pageSize: 25,
})
</script>
<template>
<FilterBar :has-filters="hasFilters" @clear="clearFilters">
<MultiSelect v-model="values.status" placeholder="Status" :options="statuses" />
<DateRangePicker v-model="range" />
</FilterBar>
<p role="status" class="sr-only">{{ filtered.length }} invoices</p>
<section v-for="section in sections" :key="section.key" :aria-label="section.key">
<h2>{{ section.key }}</h2>
<ul>
<li v-for="invoice in section.items" :key="invoice.id">{{ invoice.id }} {{ invoice.customer }}</li>
</ul>
</section>
<Button v-if="remaining > 0" plain @click="showMore">Show {{ remaining }} more</Button>
</template>useListFilters
One entry per facet, keyed the way the API names them. A plain function reads one value per row; give a `predicate` when a row holds several, tags for instance. `groupBy` splits the visible rows into sections, by day or by month, and `pageSize` caps what shows at first.
| Prop | Default | Description |
|---|---|---|
| useListFilters(options) | ||
data | - | The rows. |
search | - | `(row) => string`, the text the query must appear in. |
facets | - | Keyed facets: `(row) => value`, or `{ predicate(row, selected) }` for a row holding several values. |
date | - | `(row) => iso`, for the range. Compared on its first ten characters, so pass `YYYY-MM-DD` in the zone you display. |
groupBy | - | `(row) => key`, to split the visible rows into sections: days, months. |
pageSize | - | Rows shown at first; `showMore` adds as many. |
| returns | ||
search, setSearch | - | The query. |
values, setValues(key, selected) | - | The selected values of each facet. |
range, setRange | - | The `{ from, to }` days, or null. |
hasFilters, activeFilters | - | Whether anything is set, and how many facets are. |
clearFilters | - | Back to nothing. |
filtered, visible, remaining, showMore | - | The rows that pass, the slice shown, and the paging. |
sections | - | The visible rows grouped by `groupBy`, as `{ key, items }`. |
| Prop | Default | Description |
|---|---|---|
| useListFilters(options) | ||
data | - | The rows: ref, getter or plain array. |
search | - | `(row) => string`, the text the query must appear in. |
facets | - | Keyed facets: `(row) => value`, or `{ predicate(row, selected) }` for a row holding several values. |
date | - | `(row) => iso`, for the range. Compared on the day. |
groupBy | - | `(row) => key`, to split the visible rows into sections: days, months. |
pageSize | - | Rows shown at first; `showMore` adds as many. |
| returns | ||
search | - | Ref, the query. |
values | - | Reactive record of the selected values of each facet: `values.status`. |
range | - | Ref, the `{ from, to }` days, or null. |
hasFilters, activeFilters | - | Whether anything is set, and how many facets are. |
clearFilters | - | Back to nothing. |
filtered, visible, remaining, showMore | - | The rows that pass, the slice shown, and the paging. |
sections | - | The visible rows grouped by `groupBy`, as `{ key, items }`. |