Data table filter
A powerful data table filter component. Library-agnostic. Supports client and server-side filtering.
StatusTitleAssigneeEstimated HoursStart DateEnd DateLabels
Done
Improve workspace settings
3hMar 19Mar 22
Done
Add issue modal
MS3hJan 30Mar 07
Todo
Implement task sidebar for SSO users
MS8h
Database
Done
Revert API integration in mobile view
13hFeb 13Mar 15
Data Quality
Backlog
Remove auth flow
11h
Enhancement
Done
Remove task sidebar
3hFeb 19Mar 21
Database
In Progress
Update API integration when duplicating issues
AY7hMar 11
Retry Logic
Todo
Refactor mobile responsiveness on user onboarding
13h
Migration
Todo
Fix auth flow on slow connections
AY4h
Done
Remove auth flow
6hFeb 15Mar 07
Database
0 of 100,000 row(s) selected. Total row count: 100,000

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 shadcn registry, 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/filters

Logic only

If you want to use your own filtering UI, you can skip installing our components:

npm install @bazza-ui/filters
JavaScript projects should add JSON locales manually after installation.

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 above (it will throw an error), then:

  1. Create the locales directory in the component root directory.
  2. Copy the locales/en.json file 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

Server-side filtering

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 status column 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 const

Instance

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: The Column[] for your data table filters. A Column is a superset of a ColumnConfig, with additional properties & methods.
  • filters: The filters state, represented as a FilterState object.
  • actions: A collection of mutators for the filters state.
  • strategy: The strategy used for filtering (client or server side 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 state
  • Filter.Root - Layout container with responsive handling
  • Filter.Menu - Filter selection menu
    • Filter.Menu.Trigger - Customizable trigger button (supports asChild pattern)
  • Filter.List - Active filters list
  • Filter.Actions - Clear/reset actions
  • Filter.Block - Individual filter block container
    • Filter.Block.Subject - Filter subject/column name display
    • Filter.Block.Operator - Filter operator display/editor
    • Filter.Block.Value - Filter value display/editor
    • Filter.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 className and all standard div props
  • Default: flex w-full items-start justify-between gap-2

Filter.Block

  • Accepts className and all div props
  • Default: flex h-7 items-center rounded-2xl border border-border bg-background shadow-xs text-xs

Filter.Block.Subject

  • Accepts className and all span props
  • Default: flex select-none items-center gap-1 whitespace-nowrap px-2 font-medium

Filter.Block.Operator

  • Accepts className and all Button props (excluding onClick, 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 className and all div props

Filter.Block.Remove

  • Accepts className, variant, and all Button props (excluding onClick)
  • 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 (excluding onClick, 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
  • [data-slot="filter-menu-trigger"] - Filter menu trigger button
    • Additional: data-state - "has-filters" or "empty"
  • [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.)
  • [data-slot="filter-subject"] - Filter subject/column name
    • Additional: data-column-type - The column data type
  • [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.)
  • [data-slot="filter-value"] - Filter value button
    • Additional: data-column-type - The column data type
  • [data-slot="filter-block-remove"] - Remove filter button
  • [data-slot="filter-actions"] - Clear all filters button
    • Additional: data-state - "visible" or "hidden"

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.

types.ts
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:

columns.ts
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.

We strongly advise using the column builder instead of directly creating a 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.

If you're using our TanStack Table integration, this column ID should be the same one you used in your table column definitions.
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 text columns, your accessor should return a string.
  • For number columns, your accessor should return a number.
  • For date columns, your accessor should return a Date.
  • For boolean columns, your accessor should return a boolean.
  • For option columns, if you're using...
    • client-side filtering...
      • with static options, your accessor should return a string.
      • with inferred options, your accessor can return any, provided you have enough information later on to map it to a ColumnOption using transformValueToOptionFn.
    • server-side filtering, your accessor should return a string.
  • For multiOption columns, if you're using...
    • client-side filtering...
      • with static options, your accessor should return a string[].
      • with inferred options, your accessor can return any[], provided you have enough information later on to map each value to a ColumnOption using transformValueToOptionFn.
    • server-side filtering, your accessor should return a string[].

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

This applies to 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
Inferred options are only available for client-side filtering.

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

This applies to 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.

You should specify the min/max values if you're using the 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).

If we cannot determine the min/max values (i.e. if you use server strategy without specifying the values), filtering will still work as expected, but the slider will not be visible.

Toggled state

This only applies to 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

Make sure to declare your 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 const

Instance

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 (client or server side filtering).
  • data: The data to be filtered. When using the server strategy, 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: The Column[] for your data table filters. A Column is a superset of a ColumnConfig, with additional properties & methods.
  • filters: The filters state, represented as a FilterState object.
  • 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.

The 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 option and multiOption columns, we pass in a Map<string, number> which maps each column option ID to the number of times it appears in the dataset.
  • Faceted min/max values: For number columns, 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) - default
  • fr (French)
  • zh_CN (Simplified Chinese)
  • zh_TW (Traditional Chinese)
  • nl (Dutch)
  • de (German)

Installation

When prompted (y/N), explicitly overwrite the lib/i18n.ts file.
Feel free to remove any unused locales after the installation.
JavaScript projects should add JSON locales manually after installation.

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/i18n

This add-on:

  • Overwrites the lib/i18n.ts file 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

If you spend the time to add a locale, please consider contributing it back to the project!

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:

lib/i18n.ts
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/tst

Usage

You must specify an 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

This section is under development.

You can use nuqs to persist the filter state in the URL.

  1. Install the nuqs and zod packages:
npm install nuqs zod
  1. Use the appropriate nuqs adapter for your framework from the nuqs docs.

  2. 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>()
  1. Create your query state:
const [filters, setFilters] = useQueryState<FiltersState>(
  'filters',
  parseAsJson(filtersSchema.parse).withDefault([]),
)
  1. 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.Trigger with Radix UI asChild pattern and default styling
  • Granular filter blocks: Full control over individual filter block composition with Filter.Block and its sub-components (Filter.Block.Subject, Filter.Block.Operator, Filter.Block.Value, Filter.Block.Remove)
  • Cleaner naming: Removed all _v2 suffixes
  • 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>
  • DataTableFilterRootPropsFilterRootProps
3. Internal Components Renamed

All _v2 suffixes have been removed from internal components:

  • FilterMenu_v2FilterMenu
  • OptionItem_v2OptionItem
  • TextItem_v2TextItem

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 DataTableFilter implementation
  • 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 state
  • Filter.Root - Layout container with mobile/desktop handling
  • Filter.Menu - Filter selection menu
    • Filter.Menu.Trigger - Customizable trigger (supports asChild pattern)
  • Filter.List - Active filters list
  • Filter.Actions - Clear/reset actions
  • Filter.Block - Individual filter block container
    • Filter.Block.Subject - Filter subject/column name display
    • Filter.Block.Operator - Filter operator display/editor
    • Filter.Block.Value - Filter value display/editor
    • Filter.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 DataTableFilter with Filter in imports
  • Update DataTableFilter component usage to Filter
  • Update type references from DataTableFilterProps to FilterProps
  • 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:

  1. Review the examples section above
  2. Check the Component section for usage patterns
  3. 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.x

Version 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