FilterBuilder
Shared ComponentsTyped text, number, and select conditions with AND/OR matching.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import {
FilterBuilder,
matchesFilters,
type FilterCondition,
type FilterField,
type FilterMatch,
} from "@/components/ui/filter-builder";
import { useState } from "react";
const fields: FilterField[] = [
{ id: "name", label: "Project", type: "text" },
{ id: "budget", label: "Budget", type: "number" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "Active", label: "Active" },
{ value: "Planned", label: "Planned" },
],
},
];
const projects = [
{ name: "Brand refresh", budget: 2500, status: "Active" },
{ name: "Customer portal", budget: 12000, status: "Planned" },
{ name: "Design system", budget: 8000, status: "Active" },
];
export default function FilterBuilderExample() {
const [conditions, setConditions] = useState<FilterCondition[]>([
{ id: "initial", field: "name", operator: "contains", value: "" },
]);
const [match, setMatch] = useState<FilterMatch>("all");
const filtered = projects.filter((project) =>
matchesFilters(project, conditions, match),
);
return (
<div className="w-full max-w-xl space-y-4">
<FilterBuilder
fields={fields}
value={conditions}
onValueChange={setConditions}
match={match}
onMatchChange={setMatch}
/>
<div className="rounded-xl border p-4">
<p role="status" className="mb-3 text-xs text-muted-foreground">
{filtered.length} matching projects
</p>
<ul className="space-y-2">
{filtered.map((project) => (
<li
key={project.name}
className="flex justify-between gap-3 text-sm"
>
<span>{project.name}</span>
<span className="text-xs text-muted-foreground">
{project.status} · {project.budget}
</span>
</li>
))}
</ul>
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">
No projects match these conditions.
</p>
)}
</div>
</div>
);
}Usage
Control value and optionally match. Each condition needs a stable unique id. The included matchesFilters helper evaluates flat scalar record fields; blank values are ignored and an empty rule set matches all rows. This is local filtering, not a backend query serializer.
import {
FilterBuilder,
matchesFilters,
type FilterCondition,
type FilterField,
type FilterMatch,
} from "@/components/ui/filter-builder";Shipped as editable source in src/components/ui/filter-builder.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/filter-builder.tsx
"use client";
import { useId, useRef } from "react";
import { Plus, X } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import { cn } from "@dashboardpack/core/lib/utils";
export interface FilterField {
id: string;
label: string;
type?: "text" | "number" | "select";
options?: { value: string; label: string }[];
}
export type FilterOperator =
| "contains"
| "equals"
| "not-equals"
| "greater-than"
| "less-than";
export interface FilterCondition {
id: string;
field: string;
operator: FilterOperator;
value: string;
}
export type FilterMatch = "all" | "any";
export interface FilterBuilderProps {
fields: FilterField[];
value: FilterCondition[];
onValueChange: (conditions: FilterCondition[]) => void;
match?: FilterMatch;
onMatchChange?: (match: FilterMatch) => void;
disabled?: boolean;
maxRules?: number;
className?: string;
}
const selectClass =
"h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
export function matchesFilters(
record: Record<string, unknown>,
conditions: FilterCondition[],
match: FilterMatch = "all",
): boolean {
const active = conditions.filter(
(condition) => condition.value.trim() !== "",
);
if (active.length === 0) return true;
const results = active.map((condition) => {
if (!Object.hasOwn(record, condition.field)) return false;
const raw = record[condition.field];
if (
typeof raw !== "string" &&
typeof raw !== "number" &&
typeof raw !== "boolean"
)
return false;
const left = String(raw).toLocaleLowerCase();
const right = condition.value.toLocaleLowerCase();
switch (condition.operator) {
case "contains":
return left.includes(right);
case "equals":
return typeof raw === "number"
? Number.isFinite(raw) &&
Number.isFinite(Number(condition.value)) &&
raw === Number(condition.value)
: left === right;
case "not-equals":
return typeof raw === "number"
? Number.isFinite(raw) &&
Number.isFinite(Number(condition.value)) &&
raw !== Number(condition.value)
: left !== right;
case "greater-than":
return (
Number.isFinite(Number(raw)) &&
Number.isFinite(Number(condition.value)) &&
Number(raw) > Number(condition.value)
);
case "less-than":
return (
Number.isFinite(Number(raw)) &&
Number.isFinite(Number(condition.value)) &&
Number(raw) < Number(condition.value)
);
default:
return false;
}
});
return match === "all" ? results.every(Boolean) : results.some(Boolean);
}
export function FilterBuilder({
fields,
value,
onValueChange,
match = "all",
onMatchChange,
disabled,
maxRules = 10,
className,
}: FilterBuilderProps) {
const id = useId();
const counter = useRef(0);
const patch = (ruleId: string, next: Partial<FilterCondition>) =>
onValueChange(
value.map((rule) => (rule.id === ruleId ? { ...rule, ...next } : rule)),
);
return (
<fieldset
disabled={disabled}
className={cn("min-w-0 space-y-3 rounded-xl border p-4", className)}
>
<legend className="px-1 text-sm font-medium">Filter rules</legend>
<div className="flex flex-wrap items-center gap-2 text-sm">
<label htmlFor={`${id}-match`}>Match</label>
<select
id={`${id}-match`}
value={match}
disabled={!onMatchChange || disabled}
onChange={(event) =>
onMatchChange?.(event.target.value as FilterMatch)
}
className={selectClass}
>
<option value="all">All conditions</option>
<option value="any">Any condition</option>
</select>
</div>
{value.length === 0 && (
<p className="text-sm text-muted-foreground">
No filters. Showing all records.
</p>
)}
{value.map((rule, index) => {
const field = fields.find((item) => item.id === rule.field);
const operators: FilterOperator[] =
field?.type === "number"
? ["equals", "not-equals", "greater-than", "less-than"]
: field?.type === "select"
? ["equals", "not-equals"]
: ["contains", "equals", "not-equals"];
return (
<div
key={rule.id}
className="grid min-w-0 grid-cols-[1fr_auto] gap-2 sm:grid-cols-[1fr_1fr_1fr_auto]"
>
<select
aria-label={`Field for rule ${index + 1}`}
className={selectClass}
value={rule.field}
onChange={(event) => {
const nextField = fields.find(
(item) => item.id === event.target.value,
);
patch(rule.id, {
field: event.target.value,
operator:
!nextField?.type || nextField.type === "text"
? "contains"
: "equals",
value: "",
});
}}
>
{fields.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<select
aria-label={`Operator for rule ${index + 1}`}
className={selectClass}
value={rule.operator}
onChange={(event) =>
patch(rule.id, {
operator: event.target.value as FilterOperator,
})
}
>
{operators.map((operator) => (
<option key={operator} value={operator}>
{operator.replaceAll("-", " ")}
</option>
))}
</select>
{field?.type === "select" ? (
<select
aria-label={`Value for rule ${index + 1}`}
className={selectClass}
value={rule.value}
onChange={(event) =>
patch(rule.id, { value: event.target.value })
}
>
<option value="">Choose value</option>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<Input
aria-label={`Value for rule ${index + 1}`}
type={field?.type === "number" ? "number" : "text"}
value={rule.value}
onChange={(event) =>
patch(rule.id, { value: event.target.value })
}
placeholder="Enter value"
/>
)}
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`Remove rule ${index + 1}`}
onClick={() =>
onValueChange(value.filter((item) => item.id !== rule.id))
}
>
<X className="size-4" />
</Button>
</div>
);
})}
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || fields.length === 0 || value.length >= maxRules}
onClick={() => {
const field = fields[0];
if (!field) return;
onValueChange([
...value,
{
id: `${id}-rule-${++counter.current}`,
field: field.id,
operator:
!field.type || field.type === "text" ? "contains" : "equals",
value: "",
},
]);
}}
>
<Plus className="size-3.5" />
Add condition
</Button>
</fieldset>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| fields | FilterField[]Default: required | Field ids, labels, types, and select options. |
| value / onValueChange | FilterCondition[] / callbackDefault: required | Controlled condition list. |
| match | all | anyDefault: all | Boolean matching policy. |
| onMatchChange | (match: FilterMatch) => voidDefault: — | Enable changing the matching policy. |
| maxRules | numberDefault: 10 | Disable adding rules at this limit. |
| disabled | booleanDefault: false | Disable the editor fieldset. |