| Status | Title | Assignee | Estimated Hours | Start Date | End Date | Labels | |
|---|---|---|---|---|---|---|---|
Done | Improve workspace settings | 3h | Mar 19 | Mar 22 | |||
Done | Add issue modal | MS | 3h | Jan 30 | Mar 07 | ||
Todo | Implement task sidebar for SSO users | MS | 8h | Database | |||
Done | Revert API integration in mobile view | 13h | Feb 13 | Mar 15 | Data Quality | ||
Backlog | Remove auth flow | 11h | Enhancement | ||||
Done | Remove task sidebar | 3h | Feb 19 | Mar 21 | Database | ||
In Progress | Update API integration when duplicating issues | AY | 7h | Mar 11 | Retry Logic | ||
Todo | Refactor mobile responsiveness on user onboarding | 13h | Migration | ||||
Todo | Fix auth flow on slow connections | AY | 4h | ||||
Done | Remove auth flow | 6h | Feb 15 | Mar 07 | Database |
Introduction
This library is an add-on to your existing data table for filtering your data, providing key building blocks for building a powerful filtering experience:
- A React hook,
useDataTableFilters(), which exposes your data table filters state. - A
<Filter />component, built with shadcn/ui and inspired by Linear's design. - A composable API with compound components for custom filter layouts.
- Integrations for key libraries, such as TanStack Table and nuqs.
Some answers to the most common questions:
- Can I use this with X library? In theory, yes!
- Can I use this with client-side filtering? Yes!
- Can I use this with server-side filtering? Yes!
Installation
The data table filter is distributed in two parts to keep things modular and flexible:
- The filtering logic is provided as an npm package,
@bazza-ui/filters, which you can add directly to your project to handle all the backend filtering operations. - The UI components are distributed separately via the
shadcnregistry, allowing you to integrate the visual filter interface into your app as needed.
This separation lets you use the filtering logic independently of the UI, or combine both for a complete solution.
Kitchen sink (UI + logic)
From the command line, install the component into your project:
npx shadcn@latest add https://ui.bazza.dev/r/filtersLogic only
If you want to use your own filtering UI, you can skip installing our components:
npm install @bazza-ui/filtersThere is a known issue with shadcn CLI that prevents the JSON locale files from being installed correctly in JavaScript projects. The cause is unknown and being investigated.
The temporary workaround is to run the installation command above (it will throw an error), then:
- Create the
localesdirectory in the component root directory. - Copy the
locales/en.jsonfile into the directory.
Quick Start
Examples
We have examples that you can use as a reference to build your own applications.
We still recommend reading through the Concepts and Guides sections to get a deep understanding of how to configure and use this component.
Client-side filtering
- TanStack Table, static (i.e. no data fetching, mainly for demo purposes)
Server-side filtering
- TanStack Table, TanStack Query,
nuqs
Concepts
Let's take a look at the most important concepts for using this component.
Strategy
The FilterStrategy decides where filtering happens: on the client or on the server.
With the client strategy, the client receives all the table data and filters it locally in the browser.
With the server strategy, the client sends filter requests to the server. The server applies the filters to the data and sends back only the filtered data. The client never sees the entire dataset.
Column data types
When you want to make a column filterable, you first need to define what type of data it contains.
ColumnDataType identifies the types of data we currently support filtering for:
type ColumnDataType =
| 'text' /* Text data */
| 'number' /* Numerical data */
| 'date' /* Dates */
| 'boolean' /* Boolean data */
| 'option' /* Single-valued option (e.g. status) */
| 'multiOption' /* Multi-valued option (e.g. labels) */Filters
The state of the applied filters on a table is represented as FiltersState, which is a FilterModel[]:
type FilterModel<TType extends ColumnDataType = any> = {
columnId: string
type: TType // option, multiOption, text, date, boolean, number
operator: FilterOperators[TType] // i.e. 'is', 'is not', 'is any of', etc.
values: FilterValues<TType>
}Each FilterModel represents a single filter for a specific column.
Column options
For option and multiOption columns (we'll refer to these as option-based columns), there exists a set of possible options for each column - we call these column options.
For example, an issues table could have a
statuscolumn with the options "Backlog", "To Do", "In Progress", and "Done".
We represent each option as a ColumnOption:
interface ColumnOption {
/* The label to display for the option. */
label: string
/* The internal value of the option. */
value: string
/* An optional icon to display next to the label. */
icon?: React.ReactElement | React.ElementType
/* Occurrences of this option in the data (automatically populated). */
count?: number
}Column configuration
We describe each column in our data table as a ColumnConfig.
We create a ColumnConfig using a builder instance:
/* Create the configuration builder instance. */
const dtf = createColumnConfigHelper<Issue>()
/* Create the column configurations. */
export const columnsConfig = [
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
.icon(Heading1Icon)
.build(),
dtf
.option()
.accessor((row) => row.status.id)
.id('status')
.displayName('Status')
.icon(CircleDotDashedIcon)
.build(),
/* ... */
] as constInstance
We use the useDataTableFilters() hook to create our data table filters instance.
This hooks handles the logic for filtering the data (if using the client strategy) and updating the filters state.
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
entityName: 'Issue',
columnsConfig,
})Given those inputs, the hook creates your data table filters instance.
The instance has the following properties:
columns: TheColumn[]for your data table filters. AColumnis a superset of aColumnConfig, with additional properties & methods.filters: The filters state, represented as aFilterStateobject.actions: A collection of mutators for the filters state.strategy: The strategy used for filtering (clientorserverside filtering).entityName: The name of the entity being filtered (e.g. "Issue", "User", etc.).
Component
The visual component for the data table filter is the <Filter /> component.
It takes the columns, filters, actions and strategy from the hook as input.
import { Filter } from '@/components/data-table-filter'
export function IssuesTable() {
return (
<div>
<Filter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
entityName={entityName}
/>
<DataTable />
</div>
)
}The <Filter /> component also supports a composable API for custom layouts using compound components:
<Filter.Provider value={{ columns, filters, actions, strategy, locale, entityName }}>
<Filter.Root>
<div className="flex gap-2">
<Filter.Menu />
<Filter.List />
</div>
<Filter.Actions />
</Filter.Root>
</Filter.Provider>Available compound components:
Filter.Provider- Context provider for filter stateFilter.Root- Layout container with responsive handlingFilter.Menu- Filter selection menuFilter.Menu.Trigger- Customizable trigger button (supportsasChildpattern)
Filter.List- Active filters listFilter.Actions- Clear/reset actionsFilter.Block- Individual filter block containerFilter.Block.Subject- Filter subject/column name displayFilter.Block.Operator- Filter operator display/editorFilter.Block.Value- Filter value display/editorFilter.Block.Remove- Remove button for filter blocks
Custom Menu Trigger
The Filter.Menu component provides a customizable trigger using the Radix UI asChild pattern. The trigger automatically matches the height (h-7) of filter blocks:
Default trigger:
<Filter.Menu />Custom trigger with asChild:
<Filter.Menu>
<Filter.Menu.Trigger asChild>
<Button className="gap-2">
<FilterIcon className="size-4" />
<span>Add filter</span>
</Button>
</Filter.Menu.Trigger>
</Filter.Menu>Custom ActionMenu props:
<Filter.Menu actionMenuProps={{ align: 'start', side: 'bottom' }}>
<Filter.Menu.Trigger asChild>
<CustomButton />
</Filter.Menu.Trigger>
</Filter.Menu>The trigger applies default styling (h-7, variant="outline") which you can override using className or completely replace using asChild.
Styling Composable Components
All composable components accept styling props while providing sensible defaults:
Filter.Root
- Accepts
classNameand all standard div props - Default:
flex w-full items-start justify-between gap-2
Filter.Block
- Accepts
classNameand all div props - Default:
flex h-7 items-center rounded-2xl border border-border bg-background shadow-xs text-xs
Filter.Block.Subject
- Accepts
classNameand all span props - Default:
flex select-none items-center gap-1 whitespace-nowrap px-2 font-medium
Filter.Block.Operator
- Accepts
classNameand all Button props (excludingonClick,variant,children) - Default:
m-0 h-full w-fit whitespace-nowrap rounded-none p-0 px-2 text-xs,variant="ghost"
Filter.Block.Value
- Accepts
classNameand all div props
Filter.Block.Remove
- Accepts
className,variant, and all Button props (excludingonClick) - Default:
rounded-none rounded-r-2xl text-xs w-7 h-full text-muted-foreground hover:text-primary,variant="ghost"
Filter.Actions
- Accepts
className,variant, and all Button props (excludingonClick,children) - Default:
h-7 !px-2,variant="destructive"
Example - Custom styling:
<Filter.Provider value={{ columns, filters, actions, strategy }}>
<Filter.Root className="flex-col gap-4">
<Filter.Block className="bg-muted h-8">
<Filter.Block.Subject className="text-primary" />
<Separator orientation="vertical" />
<Filter.Block.Operator className="text-sm" />
<Separator orientation="vertical" />
<Filter.Block.Value />
<Separator orientation="vertical" />
<Filter.Block.Remove variant="destructive" />
</Filter.Block>
<Filter.Actions variant="outline" className="ml-auto" />
</Filter.Root>
</Filter.Provider>Styling with Data Attributes
All composable components include data-slot attributes for CSS targeting via attribute selectors. This provides a powerful and scoped way to style components without relying on class names.
Why use data attributes?
Data attributes offer several advantages:
- Scoped styling: Target specific components without class name conflicts
- Contextual styling: Apply styles based on component state or type
- Framework-agnostic: Works with any CSS solution (vanilla CSS, Tailwind, CSS-in-JS)
- Type-safe targeting: Less prone to breaking changes than class names
Available data-slot attributes:
[data-slot="filter-root"]- Main filter container- Additional:
data-mobile- Present on mobile devices
- Additional:
[data-slot="filter-menu-trigger"]- Filter menu trigger button- Additional:
data-state-"has-filters"or"empty"
- Additional:
[data-slot="filter-list"]- Active filters container[data-slot="filter-list-mobile-container"]- Mobile scrollable container[data-slot="filter-block"]- Individual filter block- Additional:
data-column-id- The column ID - Additional:
data-column-type- The column data type (text,number,date, etc.)
- Additional:
[data-slot="filter-subject"]- Filter subject/column name- Additional:
data-column-type- The column data type
- Additional:
[data-slot="filter-operator"]- Filter operator button- Additional:
data-column-type- The column data type - Additional:
data-operator- The current operator (is,is-not, etc.)
- Additional:
[data-slot="filter-value"]- Filter value button- Additional:
data-column-type- The column data type
- Additional:
[data-slot="filter-block-remove"]- Remove filter button[data-slot="filter-actions"]- Clear all filters button- Additional:
data-state-"visible"or"hidden"
- Additional:
Example - Vanilla CSS:
/* Target all filter blocks */
[data-slot="filter-block"] {
border-radius: 0.5rem;
padding: 0.25rem;
}
/* Target filter blocks for text columns */
[data-slot="filter-block"][data-column-type="text"] {
background-color: hsl(var(--blue-100));
}
/* Target filter blocks for date columns */
[data-slot="filter-block"][data-column-type="date"] {
background-color: hsl(var(--green-100));
}
/* Target the menu trigger when filters are active */
[data-slot="filter-menu-trigger"][data-state="has-filters"] {
border-color: hsl(var(--primary));
}Example - Tailwind CSS:
<Filter.Provider value={{ columns, filters, actions, strategy }}>
<div className="
[&_[data-slot='filter-block']]:rounded-lg
[&_[data-slot='filter-block'][data-column-type='text']]:bg-blue-100
[&_[data-slot='filter-block'][data-column-type='date']]:bg-green-100
[&_[data-slot='filter-menu-trigger'][data-state='has-filters']]:border-primary
">
<Filter.Menu />
<Filter.List />
</div>
</Filter.Provider>Example - Type-specific styling:
/* Style text column operators differently */
[data-slot="filter-operator"][data-column-type="text"] {
font-style: italic;
}
/* Add visual indicator for "is not" operators */
[data-slot="filter-operator"][data-operator="is-not"] {
color: hsl(var(--destructive));
}
/* Style filter values based on column type */
[data-slot="filter-value"][data-column-type="option"] {
font-weight: 600;
}
[data-slot="filter-value"][data-column-type="number"] {
font-family: monospace;
}Example - Mobile-specific styling:
/* Adjust layout on mobile */
[data-slot="filter-root"][data-mobile] {
flex-direction: column;
gap: 1rem;
}
/* Hide certain elements on mobile */
[data-slot="filter-root"][data-mobile] [data-slot="filter-subject"] {
display: none;
}This approach provides maximum flexibility for customizing component appearance while maintaining a clean separation between structure and styling.
Custom Filter Blocks
For even more control, you can build custom filter blocks using the granular components:
<Filter.Provider value={{ columns, filters, actions, strategy, locale, entityName }}>
<div className="flex gap-2">
<Filter.Menu />
{filters.map((filter) => {
const column = getColumn(columns, filter.columnId)
return (
<Filter.Block key={filter.columnId} filter={filter} column={column}>
<Filter.Block.Subject />
<Separator orientation="vertical" />
<Filter.Block.Operator />
<Separator orientation="vertical" />
<Filter.Block.Value />
</Filter.Block>
)
})}
</div>
</Filter.Provider>This allows you to customize the layout, styling, and composition of each filter block individually.
Guides
This section contains guides for using the data table filter component.
These are much more detailed than the Concepts section, and are recommended when you actually go to implement the component for your project.
Before we dive in, let's take a look at a basic scenario — an issue tracker (e.g. Linear) — that will be referenced throughout the guides.
export type Issue = {
id: string
title: string
description?: string
status: IssueStatus
labels?: IssueLabel[]
assignee?: User
startDate?: Date
endDate?: Date
estimatedHours?: number
isUrgent: boolean
}
export type User = {
id: string
name: string
picture: string
}
export type IssueLabel = {
id: string
name: string
color: string
}
export type IssueStatus = {
id: 'backlog' | 'todo' | 'in-progress' | 'done'
name: string
order: number
icon: LucideIcon
}Columns
Why do I need a column configuration?
For this component/library to work effectively, we need to describe each column in our data table.
If you're using a table library, you may be thinking:
"I've already done this for X library. Why do I need to do it again?"
This component requires it's own set of column configurations, which are tailored to the task at hand - column filtering.
We need to know how to access the data for each column, how to display it, what shape the data comes in, what shape it should end up in, and much more.
Column builders
We need to describe each column in our data table. Ideally, in a type-safe way. This is done using our column configuration builder.
It has a fluent API (similar to Zod) that allows us to define a column's properties in a concise, readable, and type-safe manner.
First, you use the createColumnConfigHelper() function to create a column configuration builder:
import { createColumnConfigHelper } from '@/components/data-table-filter/core/filters'
import type { Issue } from './types'
// dtf = down to... filter? (sorry, couldn't resist)
const dtf = createColumnConfigHelper<Issue>()Notice how we pass in our data model (Issue) as a generic parameter. This is required for the column configuration builder to be type safe.
ColumnConfig[] - otherwise, you'll lose out on all the type-safety benefits we've baked in.From here, we can use our builder instance to create configurations for each column.
Data types
The first call to the column builder is text(), number(), date(), boolean(), option(), or multiOption().
This defines the data type of the column.
dtf
.text()Column ID
The second call should be to the id() method, to give our column a unique ID.
dtf
.text()
.id('title')Accessor
The accessor() method is used to define how we extract a column's value for each row in your data table.
dtf
.text()
.id('title')
.accessor((row) => row.title)- For
textcolumns, your accessor should return astring. - For
numbercolumns, your accessor should return anumber. - For
datecolumns, your accessor should return aDate. - For
booleancolumns, your accessor should return aboolean. - For
optioncolumns, if you're using...- client-side filtering...
- with static
options, your accessor should return astring. - with inferred options, your accessor can return
any, provided you have enough information later on to map it to aColumnOptionusingtransformValueToOptionFn.
- with static
- server-side filtering, your accessor should return a
string.
- client-side filtering...
- For
multiOptioncolumns, if you're using...- client-side filtering...
- with static
options, your accessor should return astring[]. - with inferred options, your accessor can return
any[], provided you have enough information later on to map each value to aColumnOptionusingtransformValueToOptionFn.
- with static
- server-side filtering, your accessor should return a
string[].
- client-side filtering...
Display Name
We can use the displayName() method to set the display name for the column.
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')Icon
We can use the icon() method to set the icon for the column.
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
.icon(Heading1Icon)Hidden
We can use the hidden() method to hide a column from the filters interfaces - that is, the filter selector menu and the active filters list.
Note that this does not prevent filtering the column - it only hides it from the UI. The column is still filterable via actions or by manipulating the filters state directly.
A good use case for this is when you want a search input separate from the filters interface, but to keep its state with the rest of your column filters.
dtf
.text()
.id("title")
.accessor((row) => row.title)
.displayName("Title")
.hidden()Options
option and multiOption columns.We can determine a column's options in two ways: declared and inferred.
Declared options
This is useful for columns that have a fixed set of options.
We may pass these in...
- at build time (i.e. static)
- at run time by fetching them from a data source (i.e. remote).
Regardless, they are directly known to the column.
For declaring static options, we can use the options() method from the builder.
const ISSUE_STATUSES: IssueStatus[] = [
{ id: 'backlog', name: 'Backlog', icon: CircleDashedIcon },
{ id: 'todo', name: 'Todo', icon: CircleIcon },
{ id: 'in-progress', name: 'In Progress', icon: CircleDotIcon },
{ id: 'done', name: 'Done', icon: CircleCheckIcon },
] as const
dtf
.option()
.accessor((row) => row.status.id)
.id('status')
.displayName('Status')
.icon(CircleDotDashedIcon)
.options(
ISSUE_STATUSES.map((s) => ({
value: s.id,
label: s.name,
icon: s.icon
}))
)For declaring remote options, it is best to do this when we instantiate the table filters instance (covered later on).
Inferred options
If you're using server-side filtering, you must use the declared options approach.
This is because the data available on the client is not representative of the full dataset, in the server-side filtering scenario.
This is useful if you're:
- using client-side filtering;
- options can't be known at build time;
- and you don't have a way to fetch them (e.g., no dedicated endpoint).
We infer the options from the available data at runtime, by looping through the data and extracting unique values.
If the values are already in the ColumnOption shape, you're golden.
If not, we can use the transformValueToOptionFn() method from the builder, which transforms each unique column value to a ColumnOption.
export type User = {
id: string
name: string
picture: string
}
const UserAvatar = ({ user }: { user: User }) => {
return (
<Avatar key={user.id} className="size-4">
<AvatarImage src={user.picture} />
<AvatarFallback>
{user.name
.split('')
.map((x) => x[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
)
}
dtf
.option()
.accessor((row) => row.assignee) // User | undefined
.id('assignee')
.displayName('Assignee')
.icon(UserCheckIcon)
.transformValueToOptionFn((u) => ({
value: u.id,
label: u.name,
icon: <UserAvatar user={u} />
}))Min/max values
number columns.We can use the min() and max() methods to set the faceted minimum and maximum values for a column.
These aren't hard limits, but rather used to set the visual boundaries for the range slider.
dtf
.number()
.accessor((row) => row.estimatedHours)
.id('estimatedHours')
.displayName('Estimated hours')
.icon(ClockIcon)
.min(0)
.max(100)With the client strategy, you could skip this step - the min/max values can be computed from the data.
server strategy.With the server strategy, we can't compute these values for you, since we don't have access to the full dataset.
For this case, we recommend setting the faceted min and max values when we create our instance (covered later on).
server strategy without specifying the values), filtering will still work as expected, but the slider will not be visible.Toggled state
boolean columns.We use this, in combination with the entity name, when displaying the filter value for a boolean column.
For example, if we have a column isUrgent, the entity name is "Issue", and the state name is urgent:
- Issue is urgent
- Issue is not urgent
dtf
.boolean()
.accessor((row) => row.isUrgent)
.id('isUrgent')
.displayName('Urgent issues')
.toggledStateName('urgent')Build
Finally, we finish off our column configuration by calling the build() method.
This returns a ColumnConfig, which we pass onto our table filters instance later on.
dtf
.number()
.accessor((row) => row.estimatedHours)
.id('estimatedHours')
.displayName('Estimated hours')
.icon(ClockIcon)
.min(0)
.max(100)
.build()Putting it all together
ColumnConfig[] with as const to ensure it's immutable and type-safe.Now that we've covered the basics of creating a column configuration, let's put it all together.
We create an array of column configurations, making sure to label it as const to ensure it's (1) immutable and (2) type-safe for later on.
const columnsConfig = [
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
.icon(Heading1Icon)
.build(),
dtf
.number()
.accessor((row) => row.estimatedHours)
.id('estimatedHours')
.displayName('Estimated hours')
.icon(ClockIcon)
.min(0)
.max(100)
.build(),
/* ...rest of the columns... */
] as constInstance
With your column configurations in hand, we can move onto creating our data table filters instance.
This hooks handles the logic for filtering the data (if using the client strategy) and updating the filters state.
Think of it as the "brain" of the filters.
You can use it completely independent of the <Filter /> component, if you wish... but come on, we did all that work for you - might as well use it.
Creating the instance
To create the instance, we use the useDataTableFilters() hook.
import { useDataTableFilters } from '@/components/data-table-filter'
const { columns, filters, actions, strategy, entityName } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
entityName: 'Issue',
})Let's go through each input:
strategy: The strategy used for filtering (clientorserverside filtering).data: The data to be filtered. When using theserverstrategy, this data is not directly used, but it should still be supplied to ensure type safety.columnsConfig: The column configurations (ColumnConfig[]) we created earlier.entityName: The name of the entity being filtered (e.g. "Issue", "User", etc.), used in the UI for boolean column filtering.
The hook returns our table filters instance, of which the most important properties are:
columns: TheColumn[]for your data table filters. AColumnis a superset of aColumnConfig, with additional properties & methods.filters: The filters state, represented as aFilterStateobject.actions: A collection of mutators for the filters state.
The real magic is in the filters state, which is exposed to you. You can pass this around your application as you wish...
- To your table library
- To your state management library
- To your data fetching library
- and so on...
We can take this a step further by using a controlled (external) state, covered in the next section.
Using uncontrolled state
By default, the filters state is uncontrolled; it is managed internally by the instance.
You can specify a default (or initial) value using the defaultFilters property:
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
defaultFilters: [
{
columnId: 'status',
type: 'option',
operator: 'is',
values: ['backlog'],
},
],
})Using controlled state
If you want to use controlled (external) state for your filters, you can use the filters and onFiltersChange properties. You can hook in your state management solution of choice.
defaultFilters property is not used when using controlled state.If you use controlled state and wish to specify a default value, use the mechanism provided by your state management solution.
import { useState } from 'react'
import { FilterState } from '@/components/data-table-filter/core/types'
const [filters, setFilters] = useState<FiltersState>([]) // [!code ++]
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
filters: filters, // [!code ++]
onFiltersChange: setFilters, // [!code ++]
})Passing remote options to the instance
If you're using the declared options approach for any of your option-based columns, you can pass your column options to the instance directly!
We expose an options property on the instance, where we pass in the ColumnOption[] for relevant columns.
Let's take a look at how we can use this in practice:
/* Step 1: Fetch data from the server */
const labels = useQuery(queries.labels.all())
const statuses = useQuery(queries.statuses.all())
const users = useQuery(queries.users.all())
const issues = useQuery(queries.issues.all())
/* Step 2: Create ColumnOption[] for each option-based column */
const labelOptions = createLabelOptions(labels.data)
const statusOptions = createStatusOptions(statuses.data)
const userOptions = createUserOptions(users.data)
/*
* Step 3: Create our data table filters instance
*
* This instance will handle the logic for filtering the data and updating the filters state.
* We expose an `options` prop to provide the options for each column dynamically, after fetching them above.
* It exposes our filters state, for you to pass on as you wish - e.g. to a TanStack Table instance.
*/
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
options: {
status: statusOptions,
assignee: userOptions,
labels: labelOptions,
},
})Passing faceted values to the instance
We can also pass in faceted column values to the instance for the relevant columns.
- Faceted unique values: For
optionandmultiOptioncolumns, we pass in aMap<string, number>which maps each column option ID to the number of times it appears in the dataset. - Faceted min/max values: For
numbercolumns, we pass in a tuple[number, number]representing the minimum and maximum values for the column data.
/* Step 1: Fetch data from the server */
const labels = useQuery(queries.labels.all())
const statuses = useQuery(queries.statuses.all())
const users = useQuery(queries.users.all())
const facetedLabels = useQuery(queries.labels.faceted())
const facetedStatuses = useQuery(queries.statuses.faceted())
const facetedUsers = useQuery(queries.users.faceted())
const facetedEstimatedHours = useQuery(queries.estimatedHours.faceted())
const issues = useQuery(queries.issues.all())
/* Step 2: Create ColumnOption[] for each option-based column */
const labelOptions = createLabelOptions(labels.data)
const statusOptions = createStatusOptions(statuses.data)
const userOptions = createUserOptions(users.data)
/*
* Step 3: Create our data table filters instance
*
* This instance will handle the logic for filtering the data and updating the filters state.
* We expose an `options` prop to provide the options for each column dynamically, after fetching them above.
* Same goes for `faceted` unique values and min/max values.
* It exposes our filters state, for you to pass on as you wish - e.g. to a TanStack Table instance.
*/
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
options: {
status: statusOptions,
assignee: userOptions,
labels: labelOptions,
},
faceted: {
status: facetedStatuses.data,
assignee: facetedUsers.data,
labels: facetedLabels.data,
estimatedHours: facetedEstimatedHours.data,
},
})Internationalization
The standard installation for the component provides English (en) localization via the lib/i18n.ts and locales/en.json files.
We provide an add-on to add support for additional locales:
en(English) - defaultfr(French)zh_CN(Simplified Chinese)zh_TW(Traditional Chinese)nl(Dutch)de(German)
Installation
y/N), explicitly overwrite the lib/i18n.ts file.There is a known issue with shadcn CLI that prevents the JSON locale files from being installed correctly in JavaScript projects. The cause is unknown and being investigated.
The temporary workaround is to run the installation command (it will throw an error), then copy the missing locale files into the directory.
npx shadcn@latest add https://ui.bazza.dev/r/filters/i18nThis add-on:
- Overwrites the
lib/i18n.tsfile to add all supported locales. - Adds all supported locales to your project, under
locales/[locale].json.
Usage
You can specify the chosen locale for the <Filter /> component and useDataTableFilters() hook:
<Filter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
locale="fr"
/>useDataTableFilters({
/* ... */
locale: 'fr',
})If no locale is provided, the component defaults to en (English).
Adding a custom locale
You can add a new locale by create a new file in the locales/ directory. The filename should match the locale code (e.g. fr.json).
Use the existing locales/en.json file as a reference for the required keys. Add your translations as values for the keys.
Then, extend the Locale type in lib/i18n.ts to include your new locale:
import en from '../locales/en.json'
import fr from '../locales/fr.json'
import xx from '../locales/xx.json' // [!code ++]
export type Locale = 'en' | 'fr' // [!code --]
export type Locale = 'en' | 'fr' | 'xx' // [!code ++]
type Translations = Record<string, string>
const translations: Record<Locale, Translations> = {
en,
fr,
xx, // [!code ++]
}
export function t(key: string, locale: Locale): string {
return translations[locale][key] ?? key
}Integrations
TanStack Table
This is how to integrate data table filters with the TanStack Table (TST) library.
When should I use this?
If you're using TanStack Table and client-side filtering, you should use this integration.
If you're using server-side filtering, you don't need this integration. Feed your data into your TST table instance and you're good to go.
How it works
createTSTColumns
TanStack Table allows you to define custom filter functions for each column. This is useful if you want to implement custom filtering logic.
We have our own filter functions for each column type, which you can use to filter your data via TST.
The createTSTColumns function handles this for you. It overrides the filterFn property for each filterable TST column with the appropriate filter function.
createTSTFilters
You also need to provide the filter state to TST. TST represents the filter state slightly differently than the filters state provided by this component.
The createTSTFilters function takes in the filters state from useDataTableFilters() and returns a TST-compatible filter state (ColumnFiltersState).
Installation
npx shadcn@latest add https://ui.bazza.dev/r/filters/tstUsage
id for each TST column definition, matching the id of the corresponding column filter configuration.const { columns, filters, actions, strategy } = useDataTableFilters({ /* ... */ })
const tstColumns = useMemo( // [!code ++]
() => // [!code ++]
createTSTColumns({ // [!code ++]
columns: tstColumnDefs, // your TanStack Table column definitions // [!code ++]
configs: columns, // Your column configurations // [!code ++]
}), // [!code ++]
[columns], // [!code ++]
) // [!code ++]
const tstFilters = useMemo(() => createTSTFilters(filters), [filters]) // [!code ++]
const table = useReactTable({
data: issues.data ?? [],
columns: tstColumns, // [!code ++]
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), // [!code ++]
state: {
columnFilters: tstFilters // [!code ++]
}
})nuqs
You can use nuqs to persist the filter state in the URL.
- Install the
nuqsandzodpackages:
npm install nuqs zod-
Use the appropriate
nuqsadapter for your framework from the nuqs docs. -
Create your Zod schema for the query filter state:
import { z } from 'zod'
import type { FiltersState } from '@/components/data-table-filter/core/types'
const filtersSchema = z.custom<FiltersState>()- Create your query state:
const [filters, setFilters] = useQueryState<FiltersState>(
'filters',
parseAsJson(filtersSchema.parse).withDefault([]),
)- Pass it to your table filters instance:
const { columns, filters, actions, strategy } = useDataTableFilters({
/* ... */
filters,
onFiltersChange: setFilters,
})Changelog
Version 2.0 - Composable API
Version 2.0 introduces a major refactor that brings:
- Composable API: Build custom filter layouts using compound components
- Customizable triggers:
Filter.Menu.Triggerwith Radix UIasChildpattern and default styling - Granular filter blocks: Full control over individual filter block composition with
Filter.Blockand its sub-components (Filter.Block.Subject,Filter.Block.Operator,Filter.Block.Value,Filter.Block.Remove) - Cleaner naming: Removed all
_v2suffixes - ActionMenu pattern: Full migration to ActionMenu for better UX
- Simplified architecture: Removed duplicate code and legacy patterns
Breaking Changes
1. Component Renamed
The main component has been renamed from DataTableFilter to Filter:
// Before
import { DataTableFilter } from '@/registry/data-table-filter'
<DataTableFilter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
/>
// After
import { Filter } from '@/registry/data-table-filter'
<Filter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
/>2. Type Names Updated
All type names have been simplified:
// Before
import type { DataTableFilterProps } from '@/registry/data-table-filter'
// After
import type { FilterProps } from '@/registry/data-table-filter'Changed types:
DataTableFilterProps<TData>→FilterProps<TData>DataTableFilterRootProps→FilterRootProps
3. Internal Components Renamed
All _v2 suffixes have been removed from internal components:
FilterMenu_v2→FilterMenuOptionItem_v2→OptionItemTextItem_v2→TextItem
Note: These are internal components. If you're not using custom compositions, this won't affect you.
4. Removed Components
The following legacy components have been removed:
- Old Command-based controllers (text, option, multi-option)
- Old prop-drilling
DataTableFilterimplementation - Monolithic
filter-value.tsx(replaced by modular structure) - Old Popover-based
FilterMenu
New Composable API
The new Filter component now supports a compound component pattern for custom layouts.
Default Usage (No Changes Required)
The convenience component works the same as before:
<Filter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
entityName={entityName}
/>Custom Compositions
You can now build custom filter layouts using the compound components:
<Filter.Provider value={{ columns, filters, actions, strategy, locale, entityName }}>
<Filter.Root>
<div className="flex gap-2">
<Filter.Menu />
<Filter.List />
</div>
<Filter.Actions />
</Filter.Root>
</Filter.Provider>Available Compound Components:
Filter.Provider- Context provider for filter stateFilter.Root- Layout container with mobile/desktop handlingFilter.Menu- Filter selection menuFilter.Menu.Trigger- Customizable trigger (supportsasChildpattern)
Filter.List- Active filters listFilter.Actions- Clear/reset actionsFilter.Block- Individual filter block containerFilter.Block.Subject- Filter subject/column name displayFilter.Block.Operator- Filter operator display/editorFilter.Block.Value- Filter value display/editorFilter.Block.Remove- Remove button for filter blocks
Example: Custom Menu Trigger
import { Filter } from '@/registry/data-table-filter'
import { FilterIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
<Filter.Provider value={{ columns, filters, actions, strategy }}>
<div className="flex gap-2">
<Filter.Menu>
<Filter.Menu.Trigger asChild>
<Button className="gap-2">
<FilterIcon className="size-4" />
<span>Add filter</span>
</Button>
</Filter.Menu.Trigger>
</Filter.Menu>
<Filter.List />
</div>
</Filter.Provider>The trigger uses Radix UI's asChild pattern and applies default styling (h-7 height to match filter blocks, variant="outline").
Example: Custom Layout
import { Filter } from '@/registry/data-table-filter'
export function CustomFilterLayout() {
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
})
return (
<Filter.Provider
value={{
columns,
filters,
actions,
strategy,
locale: 'en',
entityName: 'Issue'
}}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2>Filters</h2>
<Filter.Actions />
</div>
<div className="flex gap-2">
<Filter.Menu />
<Filter.List />
</div>
</div>
</Filter.Provider>
)
}Example: Granular Filter Block Composition
For maximum control over each filter block, use the granular components:
import { Filter, getColumn } from '@/registry/data-table-filter'
export function CustomFilterBlocks() {
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
})
return (
<Filter.Provider
value={{
columns,
filters,
actions,
strategy,
locale: 'en',
entityName: 'Issue'
}}
>
<div className="flex gap-2">
<Filter.Menu />
{filters.map((filter) => {
const column = getColumn(columns, filter.columnId)
return (
<Filter.Block key={filter.columnId} filter={filter} column={column}>
<Filter.Subject />
<Separator orientation="vertical" />
<Filter.Operator />
<Separator orientation="vertical" />
<Filter.Value />
</Filter.Block>
)
})}
</div>
</Filter.Provider>
)
}This granular approach allows you to:
- Customize the layout of each filter block
- Add custom components between filter parts
- Apply conditional styling based on filter properties
- Implement custom interactions for each filter part
Migration Checklist
- Replace
DataTableFilterwithFilterin imports - Update
DataTableFiltercomponent usage toFilter - Update type references from
DataTableFilterPropstoFilterProps - If using TypeScript, update type imports
- Test your filter functionality
- (Optional) Consider using the new composable API for custom layouts
Why This Change?
Before: Prop Drilling
The old architecture relied on prop drilling, making it difficult to customize:
// Hard to customize without modifying internals
<DataTableFilter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
/>After: Composable Components
The new architecture uses React context and compound components:
// Full control over layout and composition
<Filter.Provider value={{ columns, filters, actions, strategy }}>
<CustomLayout>
<Filter.Menu />
<Filter.List />
<Filter.Actions />
</CustomLayout>
</Filter.Provider>Need Help?
If you encounter any issues during migration:
- Review the examples section above
- Check the Component section for usage patterns
- Open an issue on GitHub
Rollback
If you need to rollback, you can pin to the previous version:
# Pin to pre-v2.0 version
npx shadcn@latest add https://ui.bazza.dev/r/filters@1.xVersion 1.x - Initial Release
The initial release of the data table filter component with support for:
- Client and server-side filtering
- Multiple column data types (text, number, date, boolean, option, multiOption)
- TanStack Table integration
- Internationalization support
- shadcn/ui design system
