Filter Builder (AND / OR)

A query builder with stacked filter rows — each row chooses a field, operator, and value — combined by a global AND/OR toggle. Includes per-row remove, an Add filter button, and an empty state. Inspired by Linear's view filters.

Command PalettesInspired by Linear
Live PreviewInteractive
View · My Open Issues
Filters
AND
2 conditions

Click any pill to change · AND/OR to combine · + add filter · × remove row

FilterBuilder.tsx
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { Plus, X, ChevronDown } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

type FilterField = 'Status' | 'Assignee' | 'Priority' | 'Label';
type FilterOperator = 'is' | 'is not' | 'contains';
type Combinator = 'AND' | 'OR';

type FilterRow = {
  id: string;
  field: FilterField;
  operator: FilterOperator;
  value: string;
};

const FIELDS: FilterField[] = ['Status', 'Assignee', 'Priority', 'Label'];
const OPERATORS: FilterOperator[] = ['is', 'is not', 'contains'];
const VALUES: Record<FilterField, string[]> = {
  Status: ['Todo', 'In Progress', 'Done', 'Cancelled'],
  Assignee: ['Sarah Chen', 'Marcus Johnson', 'Priya Patel'],
  Priority: ['Urgent', 'High', 'Medium', 'Low'],
  Label: ['bug', 'feature', 'design', 'infra'],
};

let nextId = 0;
const newId = () => `f-${++nextId}`;

export function FilterBuilder() {
  const [rows, setRows] = useState<FilterRow[]>([
    { id: newId(), field: 'Status', operator: 'is', value: 'In Progress' },
    { id: newId(), field: 'Priority', operator: 'is', value: 'High' },
  ]);
  const [combinator, setCombinator] = useState<Combinator>('AND');

  const addRow = () => {
    setRows((rs) => [...rs, { id: newId(), field: 'Status', operator: 'is', value: 'Todo' }]);
  };

  const removeRow = (id: string) => setRows((rs) => rs.filter((r) => r.id !== id));

  const updateRow = (id: string, patch: Partial<FilterRow>) => {
    setRows((rs) =>
      rs.map((r) => {
        if (r.id !== id) return r;
        const next = { ...r, ...patch };
        if (patch.field && !VALUES[patch.field].includes(next.value)) {
          next.value = VALUES[patch.field][0];
        }
        return next;
      })
    );
  };

  return (
    <div className="w-full max-w-[560px] bg-zinc-900 border border-white/10 rounded-xl shadow-2xl">
      <div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.08] rounded-t-xl">
        <span className="text-sm font-semibold text-white">Filters</span>
        <div className="flex items-center gap-1 bg-zinc-800 border border-white/[0.06] rounded-md p-0.5">
          {(['AND', 'OR'] as Combinator[]).map((c) => (
            <button
              key={c}
              onClick={() => setCombinator(c)}
              className={cn(
                'px-2.5 py-1 text-[11px] font-semibold rounded transition-colors',
                combinator === c
                  ? 'bg-indigo-500/20 text-indigo-300'
                  : 'text-zinc-500 hover:text-zinc-300'
              )}
            >
              {c}
            </button>
          ))}
        </div>
      </div>

      <div className="p-3 flex flex-col gap-2">
        {rows.length === 0 && (
          <p className="text-xs text-zinc-500 text-center py-6">
            No filters yet. Click &ldquo;Add filter&rdquo; below to get started.
          </p>
        )}

        {rows.map((row, i) => {
          const openUpward = rows.length > 1 && i >= Math.ceil(rows.length / 2);
          return (
            <div key={row.id} className="flex flex-col gap-2">
              {i > 0 && (
                <div className="flex items-center gap-3 pl-2">
                  <span className="h-px flex-1 bg-white/[0.06]" />
                  <span className="text-[10px] font-semibold tracking-widest text-zinc-600">
                    {combinator}
                  </span>
                  <span className="h-px flex-1 bg-white/[0.06]" />
                </div>
              )}
              <div className="flex items-center gap-2 bg-zinc-800/60 border border-white/[0.06] rounded-lg p-1.5">
                <Select value={row.field} options={FIELDS} onChange={(v) => updateRow(row.id, { field: v as FilterField })} openUpward={openUpward} />
                <Select value={row.operator} options={OPERATORS} onChange={(v) => updateRow(row.id, { operator: v as FilterOperator })} tone="subtle" openUpward={openUpward} />
                <Select value={row.value} options={VALUES[row.field]} onChange={(v) => updateRow(row.id, { value: v })} tone="accent" openUpward={openUpward} />
                <button
                  onClick={() => removeRow(row.id)}
                  className="ml-auto w-6 h-6 rounded flex items-center justify-center text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
                  aria-label="Remove filter"
                >
                  <X className="w-3 h-3" />
                </button>
              </div>
            </div>
          );
        })}
      </div>

      <div className="flex items-center justify-between px-3 py-3 border-t border-white/[0.08] bg-black/20 rounded-b-xl">
        <button
          onClick={addRow}
          className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-zinc-300 hover:text-white hover:bg-white/[0.06] transition-colors"
        >
          <Plus className="w-3.5 h-3.5" /> Add filter
        </button>
        <span className="text-[11px] text-zinc-500">
          {rows.length} {rows.length === 1 ? 'condition' : 'conditions'}
        </span>
      </div>
    </div>
  );
}

type SelectProps = {
  value: string;
  options: string[];
  onChange: (v: string) => void;
  tone?: 'default' | 'subtle' | 'accent';
  openUpward?: boolean;
};

function Select({ value, options, onChange, tone = 'default', openUpward = false }: SelectProps) {
  const [open, setOpen] = useState(false);
  const toneClass =
    tone === 'accent'
      ? 'bg-indigo-500/10 border-indigo-500/20 text-indigo-200 hover:bg-indigo-500/20'
      : tone === 'subtle'
      ? 'bg-zinc-900 border-white/[0.06] text-zinc-400 hover:text-zinc-200'
      : 'bg-zinc-900 border-white/[0.06] text-zinc-200 hover:bg-zinc-800';

  return (
    <div className="relative">
      <button
        onClick={() => setOpen((v) => !v)}
        onBlur={() => setTimeout(() => setOpen(false), 100)}
        className={cn(
          'flex items-center gap-1.5 border rounded px-2 py-1 text-xs font-medium transition-colors',
          toneClass
        )}
      >
        {value}
        <ChevronDown className="w-3 h-3 opacity-60" />
      </button>
      {open && (
        <div
          className={cn(
            'absolute left-0 z-50 min-w-[140px] max-h-[180px] overflow-y-auto bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-2xl py-1',
            openUpward ? 'bottom-full mb-1' : 'top-full mt-1'
          )}
        >
          {options.map((opt) => (
            <button
              key={opt}
              onMouseDown={(e) => {
                e.preventDefault();
                onChange(opt);
                setOpen(false);
              }}
              className={cn(
                'w-full text-left px-3 py-1.5 text-xs transition-colors',
                opt === value
                  ? 'bg-indigo-500/15 text-indigo-200'
                  : 'text-zinc-200 hover:bg-white/[0.06] hover:text-white'
              )}
            >
              {opt}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Unlock to copy

Free access to all patterns