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
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Sparkline Trend Cards
Linear
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Bottom Sheet
Stripe
Toast / Snackbar Stack
Vercel
Notification Center
Linear
Issue / Task Card
Linear
Subtask Checklist
Linear
Kanban Board Column
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
File Upload Dropzone
Vercel
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Row Detail Drawer
Stripe
Editable Cell
Notion
Status Badge System
Linear
Segmented Control
Notion