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.
Click any pill to change · AND/OR to combine · + add filter · × remove row
// 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 “Add filter” 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
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Inline Data Table
Notion + Linear