logoSemaphor

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

src/components/semaphor-components/chip-filter/chip-filter.tsx
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

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

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