Docs
The Filter Bar is source you own: the CLI copies it into components/filter-bar/, and you can edit anything. This page covers the API and why its details work the way they do. For the story behind the pattern, see Why it works this way.
Install
In a project with Tailwind CSS v4 and shadcn/ui set up with a Radix-based style (for example npx shadcn@latest init --base radix). Base UI styles aren't supported yet: the popovers use Radix-only APIs.
npx shadcn@latest add https://your-deployment.example/r/filter-bar.jsonThis adds the files, the shadcn components they use (button, calendar, command, dialog, input, popover, separator, tooltip), date-fns, lucide-react, and the CSS variables. For client-side filtering with TanStack Table, add the adapter as well:
npx shadcn@latest add https://your-deployment.example/r/filter-bar-tanstack.jsonChip tooltips need shadcn's TooltipProvider somewhere above the bar, usually in your root layout.
Quick start
Describe your filters, hand them to useFilters, render FilterBar. The bar shows the chips; you decide what filtering means.
"use client";
import { useState } from "react";
import { FilterBar } from "@/components/filter-bar/filter-bar";
import type { FilterDefinition, SortState } from "@/components/filter-bar/types";
import { useFilterUrlState } from "@/components/filter-bar/url-state";
import { useFilters } from "@/components/filter-bar/use-filters";
const definitions: FilterDefinition[] = [
{ id: "createdAt", label: "Created Date", type: "dateRange", tier: "quick" },
{
id: "status",
label: "Status",
type: "multiSelect",
tier: "quick",
options: [
{ value: "open", label: "Open" },
{ value: "closed", label: "Closed" },
],
},
{ id: "batchId", label: "Batch ID", type: "text", tier: "more" },
];
export function Documents() {
const url = useFilterUrlState(definitions); // optional: keep filters in the URL
const filters = useFilters({ definitions, ...url });
const [sort, setSort] = useState<SortState>();
// filters.applied is what to filter by: send it to your API, or see the TanStack adapter.
return (
<FilterBar
filters={filters}
title={<h1>Documents</h1>}
sort={sort}
onSortChange={setSort}
/>
);
}FilterBar
The toolbar. Filters on the left wrap within their area; Clear all, the count and your actions stay fixed on the right.
| Prop | Type | Description |
|---|---|---|
| filters | UseFiltersResult | From useFilters(). Required. |
| title | ReactNode | Shown first, e.g. the table's name. |
| search | { value, onChange, placeholder? } | Optional global search. Applies 300 ms after typing stops; no Apply. |
| sort / onSortChange | SortState / (sort?) => void | Shows the sort chip: “Sort: Created Date Newest first”. |
| resultCount | { shown, total } | “Showing 42 of 1,200”, announced politely to screen readers. |
| actions | ReactNode | Right-aligned slot for your own buttons. |
| tooltips | boolean | Hover tooltips on chips. Default true. |
SortState is { columnId, label, direction, directionLabels? }. Pass directionLabels: { asc: "Oldest first", desc: "Newest first" } for wording that fits the column; the default is Ascending / Descending.
The pieces are exported too, for custom layouts: FilterBarChip, FilterChip, MoreFiltersMenu, SortChip and FilterEditorPanel.
Filter definitions
| Prop | Type | Description |
|---|---|---|
| id | string | Unique; also the URL parameter and the TanStack column id. |
| label | string | “Created Date”. |
| type | multiSelect | singleSelect | dateRange | text | Picks the editor. |
| tier | quick | more | Quick filters are always visible; the rest live in More Filters until applied. |
| description | string | Tooltip on the unset chip, e.g. “When the batch arrived”. |
| options | FilterOption[] | () => Promise<FilterOption[]> | For the select types. A loader runs when the editor first opens, then is cached. |
| searchable | boolean | Search input in the editor. Default: more than 7 options. |
| searchPlaceholder | string | Default: the label. |
| presets | DatePresetKey[] | Date presets to offer. Default: 1 day, 3 days, 1 week, 1/3/6 months, 1 year ago. |
| allowCustomRange | boolean | Offer “Custom date…”. Default true. |
Values: multiSelect is string[], singleSelect and text are string, dateRange is a preset key or a custom range of ISO dates (whole days, inclusive). An empty value means “not set”.
useFilters
Headless state. It keeps two things apart: applied filters (what the data is filtered by) and pending edits inside an open editor.
| Option | Type | Description |
|---|---|---|
| definitions | FilterDefinition[] | Required. |
| applyMode | manual | instant | Manual (default): edits wait for Apply. Instant: every change applies. |
| value / defaultValue | FilterState | Controlled or uncontrolled. |
| onChange | (state) => void | Once per applied change; never for pending edits or no-op changes. |
| Returns | Type | Description |
|---|---|---|
| applied | FilterState | Only set values, in the order they were applied. |
| isActive(id) / clear(id) / clearAll() | clear and clearAll apply immediately. | |
| openEditor(id) | FilterEditor | { pending, setPending, apply, reset, canApply, discard }. No side effects. |
| quickFilters / moreFilters / activeMoreFilters | FilterDefinition[] | The tiers, for custom layouts. |
| activeCount | number |
Presets are resolved to dates when you query, never stored as dates: resolvePreset(key, now) or resolveDateRange(value, now) from presets.ts.
URL sync
useFilterUrlState(definitions) returns { value, onChange } to spread into useFilters. One parameter per filter:
?status=open,closed&createdAt=last7d
?createdAt=2026-04-18_2026-04-24Other parameters are left alone; unknown or malformed values are ignored. Changes replace the current history entry, so Back doesn't step through every tick. It uses the History API directly, so it works with any router.
TanStack adapter
For client-side filtering. For server-side, skip it and send filters.applied from onChange to your API.
import {
dateRangeFn, multiSelectFn, singleSelectFn, textFn, toColumnFilters,
} from "@/components/filter-bar-tanstack/adapter";
// Give each column the same id as its filter, and the matching filter function.
const columns = [
{ id: "status", accessorKey: "status", filterFn: multiSelectFn },
{ id: "createdAt", accessorKey: "createdAt", filterFn: dateRangeFn },
{ id: "batchId", accessorKey: "batchId", filterFn: textFn },
];
const columnFilters = useMemo(
() => toColumnFilters(filters.applied, definitions),
[filters.applied],
);
const table = useReactTable({
data,
columns,
state: { columnFilters },
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});Theming
The bar uses your shadcn tokens, plus these variables (installed with the component). Every size scales with --fb-density. The customiser writes overrides for you.
:root {
--fb-chip-height: 1.75rem;
--fb-chip-radius: var(--radius-sm);
--fb-chip-gap: 0.375rem;
--fb-chip-border-style: dashed; /* unset chips: dashed | solid */
--fb-chip-unset-border-color: var(--border); /* transparent for a "ghost" chip */
--fb-chip-min-width: 3.75rem;
--fb-chip-max-width: 25rem; /* longer values end in an ellipsis */
--fb-chip-font-size: 0.8125rem;
--fb-popover-width: 18rem;
--fb-density: 1; /* 0.875 compact · 1 · 1.125 comfortable */
--fb-accent: var(--primary); /* values, Apply, checkmarks, focus borders */
--fb-accent-foreground: var(--primary-foreground);
}--fb-accent colours chip values, Apply buttons, checkmarks and focus borders, so the bar can carry a brand colour without changing your app's --primary. Set it in .dark too if your dark theme needs a lighter shade. Nothing animates under prefers-reduced-motion.
Keyboard
| Where | Key | Does |
|---|---|---|
| Chip | Enter / Space | Opens the editor. Focus comes back to the chip when it closes. |
| Multi-select | ↑ ↓, then Space | Moves, then ticks the highlighted row (typing a space searches instead). |
| Multi-select | Enter | Applies, when something changed. |
| Any editor | Escape / click outside | Closes without applying. |
| More Filters | ↑ ↓, → or Enter | Moves; opens the highlighted filter's editor. |
| More Filters editor | ← or Escape | Back to the list (← once the caret is at the start). |
| More Filters list | Escape | Closes the menu. |
Craft details
Small decisions that add up. Each one is there for a reason.
Applied filters are always visible
Every applied filter is a chip with its value, so nobody has to open a “Filter (2)” menu to remember why rows are missing.
Selected options move to the top only when the editor opens
Re-sorting while ticking would move rows under the cursor. The order is frozen while open, with a line under the group that was selected; it updates next time.
Date presets apply in one click, even in manual mode
“1 week ago” is the most common choice; making it wait for Apply adds a step for nothing. Presets are stored as keys, so a shared link stays relative.
× removes a filter straight away
Removing is unambiguous, so there's nothing to confirm. The + of an unset chip turns into that ×, so setting a filter reads as one continuous change.
Escape and clicking outside throw edits away
Predictable, and it avoids running an expensive query by accident. Apply is the only way an edit counts.
Clear all keeps its place
It clears filters but leaves search and sort, and it holds its spot even while hidden, so the count next to it never jumps.
The sort is a chip
Once a wide table scrolls sideways, the sorted column's header is gone; the chip keeps the sort in view.
More Filters opens on hover, but carefully
A short delay stops a diagonal mouse movement from opening every row it crosses, and hover never swaps away from an editor with unapplied changes.
Chips grow and shrink smoothly
The chip animates to its new width in 150 ms, so neighbours slide rather than jump. Long values end in an ellipsis at 400px; the tooltip has the rest.