Skip to content
  1. Tables
  2. Filter bar

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

CustomerTypeStatusCountryCreated
Mariam KeitaIndividualSucceededSenegal

Jun 2, 2026

Okeke TradingBusinessProcessingCameroon

May 21, 2026

Lina MansouriIndividualPendingIvory Coast

May 9, 2026

Ateba & SonsBusinessSucceededCameroon

Apr 17, 2026

Salma BennaniIndividualFailedSenegal

Mar 30, 2026

Mensah FoodsBusinessSucceededIvory 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

PropDefaultDescription
FilterBarrenders a <div> element
hasFiltersfalseShows the Clear all button.
onClear-Called by Clear all. Reset every filter there.
clearLabelClear allText of that button.
actions-Pushed to the right: a column picker, an export menu.
FilterPopoverbuilt on the Headless UI <Headless.Popover> component
labelMoreText of the button.
count0Number of filters set inside, shown after the label.
PropDefaultDescription
FilterBarrenders a <div> element
hasFiltersfalseShows the Clear all button.
@clear-Emitted by Clear all. Reset every filter there. Without a listener, the button never shows.
clearLabelClear allText of that button.
#actions-Pushed to the right: a column picker, an export menu.
FilterPopoverbuilt on the Reka UI <PopoverRoot> component
labelMoreText of the button.
count0Number 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

CustomerTypeStatusCountryCreated
Mariam KeitaIndividualSucceededSenegal

Jun 2, 2026

Okeke TradingBusinessProcessingCameroon

May 21, 2026

Lina MansouriIndividualPendingIvory Coast

May 9, 2026

Ateba & SonsBusinessSucceededCameroon

Apr 17, 2026

Salma BennaniIndividualFailedSenegal

Mar 30, 2026

Mensah FoodsBusinessSucceededIvory 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

CustomerTypeStatusCountryCreated
Mariam KeitaIndividualSucceededSenegal

Jun 2, 2026

Okeke TradingBusinessProcessingCameroon

May 21, 2026

Lina MansouriIndividualPendingIvory Coast

May 9, 2026

Ateba & SonsBusinessSucceededCameroon

Apr 17, 2026

Salma BennaniIndividualFailedSenegal

Mar 30, 2026

Mensah FoodsBusinessSucceededIvory 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 KeitaIndividualSenegalJun 2, 2026
Okeke TradingBusinessCameroonMay 21, 2026
Lina MansouriIndividualIvory CoastMay 9, 2026
Ateba & SonsBusinessCameroonApr 17, 2026
Salma BennaniIndividualSenegalMar 30, 2026
Mensah FoodsBusinessIvory CoastFeb 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

CustomerStatusAmountDate
Mariam Keitapaid

$80.00

2026-05-09

Chidi Okekepaid

$142.50

2026-05-08

Lina Mansouripending

$32.00

2026-05-08

Serge Atebaoverdue

$276.00

2026-05-07

Salma Bennanipaid

$54.00

2026-05-07

Kofi Mensahpending

$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.

PropDefaultDescription
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 }`.
PropDefaultDescription
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 }`.