Northstar Labs
Enterprise
Active$12,400
Arcwell Health
Enterprise
Active$9,800
Copperline
Growth
Active$4,700
Daybreak Energy
Enterprise
Active$15,100
'use client'
import { Badge, Box, HStack, Stack, Text } from '@chakra-ui/react'
import {
type ConditionQueryForDefinition,
createConditionQuery,
} from '@saas-js/conditions'
import { createFilters } from '#components/ui/filters'
import { LuBuilding2, LuCircleDollarSign, LuLayers } from 'react-icons/lu'
import { z } from 'zod'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
}
const accounts: Account[] = [
{
id: '1',
company: 'Northstar Labs',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
},
{
id: '2',
company: 'Kite & Harbor',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
},
{
id: '3',
company: 'Arcwell Health',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
},
{
id: '4',
company: 'Fieldnote Studio',
plan: 'Starter',
status: 'Paused',
mrr: 890,
},
{
id: '5',
company: 'Copperline',
plan: 'Growth',
status: 'Active',
mrr: 4700,
},
{
id: '6',
company: 'Daybreak Energy',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
},
]
const currencyFormat = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
})
const statusDot = (color: string) => (
<Box boxSize="2" rounded="full" bg={color} />
)
const accountFilters = createFilters({
fields: {
status: {
type: 'enum',
label: 'Status',
schema: z.enum(['Active', 'Trial', 'Paused']),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
meta: { icon: statusDot('border.emphasized'), pluralLabel: 'statuses' },
options: [
{
value: 'Active',
label: 'Active',
meta: { icon: statusDot('green.solid') },
},
{
value: 'Trial',
label: 'Trial',
meta: { icon: statusDot('blue.solid') },
},
{
value: 'Paused',
label: 'Paused',
meta: { icon: statusDot('gray.solid') },
},
],
},
plan: {
type: 'enum',
label: 'Plan',
schema: z.enum(['Enterprise', 'Growth', 'Starter']),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
meta: { icon: <LuLayers />, pluralLabel: 'plans' },
options: [
{ value: 'Enterprise', label: 'Enterprise' },
{ value: 'Growth', label: 'Growth' },
{ value: 'Starter', label: 'Starter' },
],
},
company: {
type: 'string',
label: 'Company',
schema: z.string().min(1),
operators: ['contains', 'equals', 'startsWith'],
defaultOperator: 'contains',
meta: { icon: <LuBuilding2 /> },
},
mrr: {
type: 'number',
label: 'MRR',
schema: z.coerce.number().min(0),
operators: ['equals', 'gte', 'lte', 'between'],
defaultOperator: 'gte',
meta: { icon: <LuCircleDollarSign /> },
},
},
formatValue: ({ fieldId, value }) => {
if (fieldId === 'mrr' && typeof value === 'number') {
return currencyFormat.format(value)
}
return undefined
},
})
function statusColor(status: Account['status']) {
if (status === 'Active') return 'green'
if (status === 'Trial') return 'blue'
return 'gray'
}
const defaultQuery = createConditionQuery({
items: [
{
kind: 'condition',
id: 'status-active',
field: 'status',
operator: 'equals',
value: 'Active',
},
],
}) as ConditionQueryForDefinition<typeof accountFilters.definition>
export function FiltersBasic() {
const conditions = accountFilters.useConditions({
defaultValue: defaultQuery,
})
const matches = conditions.useFilter(accounts)
return (
<conditions.Root>
<Stack gap="3" width="full">
<conditions.FilterBar />
<Stack
gap="0"
borderWidth="1px"
borderColor="border"
rounded="md"
divideY="1px"
divideColor="border"
>
{matches.map((account) => (
<HStack key={account.id} px="4" py="2.5" gap="4">
<Text textStyle="sm" fontWeight="medium" flex="1" truncate>
{account.company}
</Text>
<Text textStyle="sm" color="fg.muted">
{account.plan}
</Text>
<Badge size="sm" colorPalette={statusColor(account.status)}>
{account.status}
</Badge>
<Text textStyle="sm" color="fg.muted" minW="16" textAlign="end">
{currencyFormat.format(account.mrr)}
</Text>
</HStack>
))}
{!matches.length ? (
<Box px="4" py="8">
<Text textStyle="sm" color="fg.muted" textAlign="center">
No accounts match these filters.
</Text>
</Box>
) : null}
</Stack>
</Stack>
</conditions.Root>
)
}
Usage
The Filters component is built on
Saas.js Conditions: a headless
engine for typed condition queries with validation, evaluation and
serialization. You describe your fields once with defineConditions, and the
same definition drives the filter bar, the query state and the filtering
itself.
import { createFilters } from '#components/ui/filters'Define the fields, then create the filters hook at module scope. Every
instance returned by useConditions carries the filter parts alongside the
full conditions API.
import { z } from 'zod'
const accountFilters = createFilters({
fields: {
status: {
type: 'enum',
label: 'Status',
schema: z.enum(['Active', 'Trial', 'Paused']),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
options: [
{ value: 'Active', label: 'Active' },
{ value: 'Trial', label: 'Trial' },
{ value: 'Paused', label: 'Paused' },
],
},
mrr: {
type: 'number',
label: 'MRR',
schema: z.coerce.number().min(0),
operators: ['equals', 'gte', 'lte', 'between'],
},
},
})The built definition is available as accountFilters.definition, for
adapters and server-side evaluation. When the definition is shared with
non-UI code — a Drizzle query on the server, a serialized saved view — define
it separately with defineConditions and pass it as definition instead:
import { defineConditions } from '@saas-js/conditions'
import { accountConditions } from '~/shared/account-conditions'
const accountFilters = createFilters({ definition: accountConditions })Fields with options (like enum and boolean fields) open a searchable
submenu straight from the add-filter menu. Clicking an option commits it
with the field's default operator ("Status is Active") and closes the menu;
when the field supports a multi-select operator like in, each option also
carries a checkbox that toggles values while the menu stays open, and the
operator follows the selection ("is" for one value, "is any of" for
several). Other field types open their value editor after picking the
field.
Add icons through meta on a field or option: a field icon shows in the
add-filter menu and on the chip's label, an option icon shows in the option
lists and on the chip's value. When two or more values are selected the chip
collapses to the value icons plus a count — set pluralLabel on the field's
meta to name it ("2 statuses" instead of "2 selected").
status: {
type: 'enum',
label: 'Status',
meta: { icon: <StatusIcon />, pluralLabel: 'statuses' },
options: [
{ value: 'active', label: 'Active', meta: { icon: <ActiveDot /> } },
],
// ...
}Render the bar inside conditions.Root and filter your rows with
useFilter.
function Accounts() {
const conditions = accountFilters.useConditions()
const matches = conditions.useFilter(accounts)
return (
<conditions.Root>
<conditions.FilterBar />
<AccountList accounts={matches} />
</conditions.Root>
)
}useConditions supports uncontrolled (defaultValue), controlled (value +
onValueChange) and external store usage. The committed query is a plain
serializable tree — see the
Conditions documentation for
validation, serialization and server-side evaluation.Examples
Async Options
Pass a function as a field's options to load them on demand. The value
editor gets a search input, debounced queries and a loading state without
extra wiring.
Northstar Labs
Maya Chen
Kite & Harbor
Jon Bell
Arcwell Health
Priya Shah
Fieldnote Studio
Alex Moreno
Copperline
Noor Aziz
Daybreak Energy
Maya Chen
Plainspoken
Sam Whitfield
Orbit Commerce
Jon Bell
'use client'
import { HStack, Stack, Text } from '@chakra-ui/react'
import { createFilters } from '#components/ui/filters'
import { LuBuilding2, LuUserRound } from 'react-icons/lu'
import { z } from 'zod'
interface Contact {
id: string
name: string
owner: string
}
const owners = [
'Maya Chen',
'Jon Bell',
'Priya Shah',
'Alex Moreno',
'Noor Aziz',
'Sam Whitfield',
]
const contacts: Contact[] = [
{ id: '1', name: 'Northstar Labs', owner: 'Maya Chen' },
{ id: '2', name: 'Kite & Harbor', owner: 'Jon Bell' },
{ id: '3', name: 'Arcwell Health', owner: 'Priya Shah' },
{ id: '4', name: 'Fieldnote Studio', owner: 'Alex Moreno' },
{ id: '5', name: 'Copperline', owner: 'Noor Aziz' },
{ id: '6', name: 'Daybreak Energy', owner: 'Maya Chen' },
{ id: '7', name: 'Plainspoken', owner: 'Sam Whitfield' },
{ id: '8', name: 'Orbit Commerce', owner: 'Jon Bell' },
]
const contactFilters = createFilters({
fields: {
owner: {
type: 'enum',
label: 'Owner',
schema: z.string().min(1),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
meta: { icon: <LuUserRound />, pluralLabel: 'owners' },
// Async option sources get a search input, debounced queries and a
// loading state in the value editor, without extra wiring.
options: async ({ query, signal }) => {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 400)
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(new DOMException('Aborted', 'AbortError'))
})
})
return owners
.filter((owner) => owner.toLowerCase().includes(query.toLowerCase()))
.map((owner) => ({ value: owner, label: owner }))
},
},
name: {
type: 'string',
label: 'Name',
schema: z.string().min(1),
operators: ['contains', 'equals', 'startsWith'],
defaultOperator: 'contains',
meta: { icon: <LuBuilding2 /> },
},
},
})
export function FiltersAsyncOptions() {
const conditions = contactFilters.useConditions()
const matches = conditions.useFilter(contacts)
return (
<conditions.Root>
<Stack gap="3" width="full">
<conditions.FilterBar />
<Stack
gap="0"
borderWidth="1px"
borderColor="border"
rounded="md"
divideY="1px"
divideColor="border"
>
{matches.map((contact) => (
<HStack key={contact.id} px="4" py="2.5" gap="4">
<Text textStyle="sm" fontWeight="medium" flex="1" truncate>
{contact.name}
</Text>
<Text textStyle="sm" color="fg.muted">
{contact.owner}
</Text>
</HStack>
))}
{!matches.length ? (
<Text textStyle="sm" color="fg.muted" textAlign="center" py="8">
No contacts match these filters.
</Text>
) : null}
</Stack>
</Stack>
</conditions.Root>
)
}
Composed Bar
Pass children to FilterBar to compose the bar yourself from
conditions.FilterChips, conditions.AddFilterButton and
conditions.ClearFiltersButton, or your own components. Use
conditions.draft.beginAddCondition to build a fully custom add button.
Northstar Labs
Active
Kite & Harbor
Trial
Arcwell Health
Active
Fieldnote Studio
Paused
Copperline
Active
'use client'
import { Box, HStack, Spacer, Stack, Text } from '@chakra-ui/react'
import { createFilters } from '#components/ui/filters'
import { LuBuilding2, LuListFilter } from 'react-icons/lu'
import { z } from 'zod'
interface Account {
id: string
company: string
status: 'Active' | 'Trial' | 'Paused'
}
const accounts: Account[] = [
{ id: '1', company: 'Northstar Labs', status: 'Active' },
{ id: '2', company: 'Kite & Harbor', status: 'Trial' },
{ id: '3', company: 'Arcwell Health', status: 'Active' },
{ id: '4', company: 'Fieldnote Studio', status: 'Paused' },
{ id: '5', company: 'Copperline', status: 'Active' },
]
const statusDot = (color: string) => (
<Box boxSize="2" rounded="full" bg={color} />
)
const accountFilters = createFilters({
fields: {
status: {
type: 'enum',
label: 'Status',
schema: z.enum(['Active', 'Trial', 'Paused']),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
meta: { icon: statusDot('border.emphasized'), pluralLabel: 'statuses' },
options: [
{
value: 'Active',
label: 'Active',
meta: { icon: statusDot('green.solid') },
},
{
value: 'Trial',
label: 'Trial',
meta: { icon: statusDot('blue.solid') },
},
{
value: 'Paused',
label: 'Paused',
meta: { icon: statusDot('gray.solid') },
},
],
},
company: {
type: 'string',
label: 'Company',
schema: z.string().min(1),
operators: ['contains', 'equals', 'startsWith'],
defaultOperator: 'contains',
meta: { icon: <LuBuilding2 /> },
},
},
})
export function FiltersComposedBar() {
const conditions = accountFilters.useConditions()
const matches = conditions.useFilter(accounts)
return (
<conditions.Root>
<Stack gap="3" width="full">
<conditions.FilterBar>
<conditions.AddFilterButton variant="outline">
<LuListFilter /> Add filter
</conditions.AddFilterButton>
<conditions.FilterChips />
<Spacer />
<conditions.ClearFiltersButton>Clear all</conditions.ClearFiltersButton>
</conditions.FilterBar>
<Stack
gap="0"
borderWidth="1px"
borderColor="border"
rounded="md"
divideY="1px"
divideColor="border"
>
{matches.map((account) => (
<HStack key={account.id} px="4" py="2.5" gap="4">
<Text textStyle="sm" fontWeight="medium" flex="1" truncate>
{account.company}
</Text>
<Text textStyle="sm" color="fg.muted">
{account.status}
</Text>
</HStack>
))}
{!matches.length ? (
<Text textStyle="sm" color="fg.muted" textAlign="center" py="8">
No accounts match these filters.
</Text>
) : null}
</Stack>
</Stack>
</conditions.Root>
)
}
Data Table
Pair the filter bar with the Data Table through
@saas-js/conditions-tanstack-table: the committed query becomes the table's
global filter, and every row is evaluated against it. Pass undefined while
the query is empty so the empty state doesn't count it as an active filter,
and wire table.NoResults to clear the conditions.
Northstar Labs | Enterprise | Active | $12,400 |
Kite & Harbor | Growth | Trial | $3,200 |
Arcwell Health | Enterprise | Active | $9,800 |
Fieldnote Studio | Starter | Paused | $890 |
Copperline | Growth | Active | $4,700 |
Daybreak Energy | Enterprise | Active | $15,100 |
'use client'
import { Box, Stack } from '@chakra-ui/react'
import { conditionsGlobalFilter } from '@saas-js/conditions-tanstack-table'
import {
type DataTableFeatures,
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
import { createFilters } from '#components/ui/filters'
import { LuBuilding2, LuCircleDollarSign, LuLayers } from 'react-icons/lu'
import { z } from 'zod'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
}
const accounts: Account[] = [
{
id: '1',
company: 'Northstar Labs',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
},
{
id: '2',
company: 'Kite & Harbor',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
},
{
id: '3',
company: 'Arcwell Health',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
},
{
id: '4',
company: 'Fieldnote Studio',
plan: 'Starter',
status: 'Paused',
mrr: 890,
},
{
id: '5',
company: 'Copperline',
plan: 'Growth',
status: 'Active',
mrr: 4700,
},
{
id: '6',
company: 'Daybreak Energy',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
},
]
const statusDot = (color: string) => (
<Box boxSize="2" rounded="full" bg={color} />
)
const accountFilters = createFilters({
fields: {
status: {
type: 'enum',
label: 'Status',
schema: z.enum(['Active', 'Trial', 'Paused']),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
meta: { icon: statusDot('border.emphasized'), pluralLabel: 'statuses' },
options: [
{
value: 'Active',
label: 'Active',
meta: { icon: statusDot('green.solid') },
},
{
value: 'Trial',
label: 'Trial',
meta: { icon: statusDot('blue.solid') },
},
{
value: 'Paused',
label: 'Paused',
meta: { icon: statusDot('gray.solid') },
},
],
},
plan: {
type: 'enum',
label: 'Plan',
schema: z.enum(['Enterprise', 'Growth', 'Starter']),
operators: ['equals', 'not', 'in'],
defaultOperator: 'equals',
meta: { icon: <LuLayers />, pluralLabel: 'plans' },
options: [
{ value: 'Enterprise', label: 'Enterprise' },
{ value: 'Growth', label: 'Growth' },
{ value: 'Starter', label: 'Starter' },
],
},
company: {
type: 'string',
label: 'Company',
schema: z.string().min(1),
operators: ['contains', 'equals', 'startsWith'],
defaultOperator: 'contains',
meta: { icon: <LuBuilding2 /> },
},
mrr: {
type: 'number',
label: 'MRR',
schema: z.coerce.number().min(0),
operators: ['equals', 'gte', 'lte', 'between'],
defaultOperator: 'gte',
meta: { icon: <LuCircleDollarSign /> },
},
},
})
// Module scope keeps the filter function stable across renders.
const filterOptions = conditionsGlobalFilter<DataTableFeatures, Account>(
accountFilters.definition,
)
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 140,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.TextCell />,
size: 140,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 140,
}),
])
function AccountsTable() {
const conditions = accountFilters.useConditionsContext()
const query = conditions.useValue()
const isEmpty = conditions.useIsEmpty()
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
...filterOptions,
state: {
// An empty query matches every row; leaving the state undefined also
// keeps the empty state from counting it as an active filter.
globalFilter: isEmpty ? undefined : query,
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea>
<table.Table aria-label="Accounts">
<table.Header />
<table.Body
emptyState={
<table.NoResults
resource="accounts"
onReset={() => conditions.actions.clear()}
/>
}
/>
</table.Table>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
export function FiltersDataTable() {
const conditions = accountFilters.useConditions()
return (
<conditions.Root>
<Stack gap="3" width="full">
<conditions.FilterBar />
<AccountsTable />
</Stack>
</conditions.Root>
)
}
Value Formatting
Use formatValue to control how committed values appear in the chips, and
operatorLabels to override the compact operator labels. Register custom
value editors per field type, field or operator with the editor registries.
const accountFilters = createFilters({
fields: accountFields,
formatValue: ({ fieldId, value }) => {
if (fieldId === 'mrr' && typeof value === 'number') {
return currencyFormat.format(value)
}
return undefined
},
operatorLabels: {
gte: 'at least',
lte: 'at most',
},
})Props
createFilters
| Prop | Default | Type |
|---|---|---|
operators | 'defaultOperators' | ConditionOperatorsCustom operator registry for the inline `fields` form. |
fields | ConditionFieldsThe filterable fields: type, label, validation schema, operators and options. Builds the conditions definition inline; it is exposed as `filters.definition`. Provide either `fields` or `definition`. | |
definition | ConditionsDefinitionA conditions definition created with `defineConditions`, as an alternative to `fields` — use this when the definition is shared with non-UI code. | |
valueEditors | Record<string, ValueEditorComponent>Value editors by field type, merged over the built-in string, number, date and option editors. | |
fieldValueEditors | Record<string, ValueEditorComponent>Value editors for a specific field, taking precedence over the type editors. | |
operatorValueEditors | Record<string, ValueEditorComponent>Value editors for a specific operator. | |
operatorLabels | Record<string, string>Compact operator labels shown in the chips, merged over the built-in overrides (`gt` `>`, `gte` `≥`, `lt` `<`, `lte` `≤`). Unmapped operators use the operator label from the definition. | |
formatValue | (context: { fieldId, field, value }) => string | undefinedFormats a committed value for display in a chip. Return `undefined` to fall back to the default formatting (option labels, localized dates, Yes/No booleans). |
FilterBar
| Prop | Default | Type |
|---|---|---|
children | React.ReactNodeReplaces the default composition (chips, add button, clear button). Compose your own bar from `conditions.FilterChips`, `conditions.AddFilterButton` and `conditions.ClearFiltersButton`. |
AddFilterButton
| Prop | Default | Type |
|---|---|---|
parentId | 'the root group' | stringThe condition group to add the condition to. |
children | 'Filter' | React.ReactNodeThe button content. |
ClearFiltersButton
| Prop | Default | Type |
|---|---|---|
children | 'Clear' | React.ReactNodeThe button content. The button only renders while at least one filter is active. |