Custom Filters
Build custom filter UI components for Semaphor dashboards
Custom filters let you replace Semaphor's built-in filter UI with your own React components. You control how filters look; Semaphor handles everything else.
How Filters Work
When you build a custom filter, you're building UI only. Semaphor manages:
- Fetching filter options from the database
- Tracking selected values
- Re-running queries when selections change
- Cascading filters between dependent columns
- Applying SQL WHERE clauses
Your component receives options and callbacks. Render the UI and call onChange when users make selections.
┌─────────────────────────────────────────────────────────────┐
│ Semaphor │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Fetch │───▶│ Your │───▶│ Update │ │
│ │ Options │ │ Filter │ │ Query │ │
│ └─────────────┘ │ Component │ └─────────────┘ │
│ └─────────────┘ │
│ │ │
│ onChange() │
└─────────────────────────────────────────────────────────────┘Tutorial: Build a Chip Filter
Create a filter that displays options as clickable chips with search functionality.
Step 1: Create the Component
import { CustomFilterProps, TSelectedRecord } from '../../config-types';
export function ChipFilter({
options,
selectedValues,
onChange,
onClear,
searchQuery,
onSearchChange,
isLoading,
isSingleSelect,
theme,
settings,
}: CustomFilterProps) {
const showSearch = settings?.showSearch !== false;
const maxVisibleChips = Number(settings?.maxVisibleChips) || 20;
const handleChipClick = (record: TSelectedRecord) => {
const isSelected = selectedValues.some((v) => v.value === record.value);
if (isSingleSelect) {
onChange(isSelected ? [] : [record]);
} else {
onChange(
isSelected
? selectedValues.filter((v) => v.value !== record.value)
: [...selectedValues, record]
);
}
};
const isSelected = (record: TSelectedRecord) =>
selectedValues.some((v) => v.value === record.value);
const visibleOptions = options.slice(0, maxVisibleChips);
const hiddenCount = options.length - visibleOptions.length;
const isDark = theme?.mode === 'dark';
const selectedBg = theme?.colors?.[0] || '#3b82f6';
const unselectedBg = isDark ? '#374151' : '#f3f4f6';
if (isLoading) {
return <div className="p-4 text-muted-foreground">Loading...</div>;
}
return (
<div className="flex flex-col gap-2">
{showSearch && onSearchChange && (
<input
type="text"
value={searchQuery || ''}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search..."
className="px-3 py-1.5 text-sm border rounded-md"
/>
)}
{selectedValues.length > 0 && (
<button onClick={onClear} className="self-start text-xs text-muted-foreground">
Clear ({selectedValues.length})
</button>
)}
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto">
{visibleOptions.map((record) => (
<button
key={String(record.value)}
onClick={() => handleChipClick(record)}
className="px-3 py-1.5 text-sm font-medium rounded-lg"
style={{
backgroundColor: isSelected(record) ? selectedBg : unselectedBg,
color: isSelected(record) ? '#fff' : isDark ? '#e5e7eb' : '#374151',
}}
>
{record.label || String(record.value)}
</button>
))}
</div>
{hiddenCount > 0 && (
<p className="text-xs text-muted-foreground">+{hiddenCount} more</p>
)}
</div>
);
}Step 2: Register in components.config.ts
export const config: ComponentsConfig = {
visuals: [],
filters: [
{
name: 'Chip Filter',
component: 'ChipFilter',
filterType: 'chip-filter',
icon: 'Layers',
supportedDataTypes: [],
settings: {
showSearch: {
title: 'Show Search',
defaultValue: 'true',
ui: 'select',
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' },
],
},
maxVisibleChips: {
title: 'Max Visible Chips',
defaultValue: '20',
ui: 'input',
},
},
docs: {
description: 'Display filter options as clickable chips.',
useCases: ['Category filters', 'Tag selection', 'Status filtering'],
},
},
],
};Step 3: Export
export { ChipFilter } from './semaphor-components/chip-filter/chip-filter';CustomFilterProps Reference
type CustomFilterProps = {
// Options from database
options: TSelectedRecord[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
// Selection state
selectedValues: TSelectedRecord[];
// Callbacks (Semaphor handles all logic)
onChange: (records: TSelectedRecord[]) => void;
onClear: () => void;
onSelectAll?: (checked: boolean) => void;
// Search
searchQuery?: string;
onSearchChange?: (query: string) => void;
isSearching?: boolean;
// Config
isSingleSelect?: boolean;
allSelected?: boolean;
theme?: { colors?: string[]; mode?: 'light' | 'dark' | 'system' };
settings?: Record<string, string | number | boolean>;
};
type TSelectedRecord = {
value: string | number | boolean;
label?: string;
};Single vs Multi-Select
Check isSingleSelect to determine behavior:
const handleSelection = (record: TSelectedRecord) => {
const isAlreadySelected = selectedValues.some((v) => v.value === record.value);
if (isSingleSelect) {
// One at a time; clicking selected clears it
onChange(isAlreadySelected ? [] : [record]);
} else {
// Toggle individual selections
onChange(
isAlreadySelected
? selectedValues.filter((v) => v.value !== record.value)
: [...selectedValues, record]
);
}
};Handling Loading States
export function MyFilter({ options, isLoading, isFetching }: CustomFilterProps) {
// Initial load - show skeleton
if (isLoading) {
return (
<div className="space-y-2 p-2">
<div className="h-8 bg-muted animate-pulse rounded" />
<div className="h-8 bg-muted animate-pulse rounded w-3/4" />
</div>
);
}
// Refetch - show spinner overlay
return (
<div className="relative">
{isFetching && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Options */}
</div>
);
}Styling with Theme
export function ThemedFilter({ theme, options }: CustomFilterProps) {
const isDark = theme?.mode === 'dark';
const primaryColor = theme?.colors?.[0] || '#3b82f6';
const colors = {
background: isDark ? '#1f2937' : '#ffffff',
border: isDark ? '#4b5563' : '#e5e7eb',
text: isDark ? '#f3f4f6' : '#111827',
selected: primaryColor,
};
return (
<div style={{ backgroundColor: colors.background }}>
{options.map((option) => (
<button
key={String(option.value)}
style={{
backgroundColor: isSelected(option) ? colors.selected : 'transparent',
color: isSelected(option) ? '#fff' : colors.text,
}}
>
{option.label}
</button>
))}
</div>
);
}Example: Button Group Filter
For small option sets (2-5 items):
export function ButtonGroupFilter({
options,
selectedValues,
onChange,
isSingleSelect,
theme,
}: CustomFilterProps) {
const handleClick = (record: TSelectedRecord) => {
const isSelected = selectedValues.some((v) => v.value === record.value);
if (isSingleSelect) {
onChange(isSelected ? [] : [record]);
} else {
onChange(
isSelected
? selectedValues.filter((v) => v.value !== record.value)
: [...selectedValues, record]
);
}
};
const primaryColor = theme?.colors?.[0] || '#3b82f6';
const isDark = theme?.mode === 'dark';
return (
<div className="inline-flex rounded-lg border overflow-hidden">
{options.map((record, index) => {
const selected = selectedValues.some((v) => v.value === record.value);
return (
<button
key={String(record.value)}
onClick={() => handleClick(record)}
className="px-4 py-2 text-sm font-medium"
style={{
backgroundColor: selected ? primaryColor : 'transparent',
color: selected ? '#fff' : isDark ? '#e5e7eb' : '#374151',
borderLeft: index > 0 ? '1px solid' : 'none',
}}
>
{record.label || String(record.value)}
</button>
);
})}
</div>
);
}Date Filters
Date filters use CustomDateFilterProps:
type CustomDateFilterProps = {
dateRange: DateRange | undefined;
initialDateRange: DateRange | undefined;
isLoading: boolean;
onDateChange: (range: DateRange | undefined) => void;
onClear: () => void;
theme?: { colors?: string[]; mode?: 'light' | 'dark' };
settings?: Record<string, string | number | boolean>;
};
type DateRange = {
from: Date | undefined;
to?: Date | undefined;
};export function SimpleDateFilter({ dateRange, onDateChange, onClear }: CustomDateFilterProps) {
return (
<div className="flex gap-2 items-center">
<input
type="date"
value={dateRange?.from?.toISOString().split('T')[0] || ''}
onChange={(e) => {
const from = e.target.value ? new Date(e.target.value) : undefined;
onDateChange({ from, to: dateRange?.to });
}}
/>
<span>to</span>
<input
type="date"
value={dateRange?.to?.toISOString().split('T')[0] || ''}
onChange={(e) => {
const to = e.target.value ? new Date(e.target.value) : undefined;
onDateChange({ from: dateRange?.from, to });
}}
/>
{dateRange?.from && (
<button onClick={onClear} className="text-xs text-muted-foreground">
Clear
</button>
)}
</div>
);
}Filter Config Reference
type FilterConfig = {
name: string; // Display name (unique)
component: string; // Export name
filterType?: string; // Unique identifier
icon?: string; // Lucide icon name
supportedDataTypes?: string[]; // Empty = all types
settings?: Record<string, SettingConfig>;
docs?: { description: string; useCases?: string[] };
};Supported Data Types
// Text columns only
supportedDataTypes: ['text', 'varchar', 'string']
// Numeric columns only
supportedDataTypes: ['number', 'integer', 'decimal']
// All types (default)
supportedDataTypes: []Next Steps
- Single-Input Visuals - Build custom charts
- Multi-Input Visuals - Combine data sources
- API Reference - Complete props documentation