Skip to content

FilterBuilder

Shared Components

Typed text, number, and select conditions with AND/OR matching.

Text and number rulesSelect valuesAll/any matchingAdd and remove conditionsLive results
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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

FilterBuilder props
PropType / DefaultDescription
fieldsFilterField[]

Default: required

Field ids, labels, types, and select options.
value / onValueChangeFilterCondition[] / callback

Default: required

Controlled condition list.
matchall | any

Default: all

Boolean matching policy.
onMatchChange(match: FilterMatch) => void

Default: —

Enable changing the matching policy.
maxRulesnumber

Default: 10

Disable adding rules at this limit.
disabledboolean

Default: false

Disable the editor fieldset.

Build with these too