UX Standards — 09: Column Filter Popup
Governs: The per-column filter/sort/pin/hide popup that opens when clicking a column header.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Overview
Every data table column header opens a per-column popup when clicked. This popup provides:
- Copy the column data to clipboard
- Pin the column to left or right (sticky column)
- Hide the column from view
- Sort ascending / descending / clear sort
- Filter with operator selection + value input + multiple conditions
- Distinct values quick-select for common values
The popup triggers from the column header button (the ⇅ icon next to the column name).
Visual Anatomy
┌──────────────────────────────────────────────────┐
│ Filter: Email [✕] │ ← Title: "Filter: {ColumnName}" + close
├──────────────────────────────────────────────────┤
│ [📋 Copy Column ] │ ← Full-width button, Copy icon
├──────────────────────────────────────────────────┤
│ [← Pin Left] [🚫 Hide] [Pin Right →] │ ← 3 action buttons in a row
│ [↑↓ Sort A-Z] [↑↓ Sort Z-A] [Clear sort] │ ← 3 sort buttons in a row
├──────────────────────────────────────────────────┤
│ [Contains ▼] [Filter value ] │ ← Operator dropdown + value input
│ │
│ AND + Add condition │ ← multi-condition support
├──────────────────────────────────────────────────┤
│ Distinct values (top 2) │
│ ☐ superadmin@anshinhealth.c... 1 │ ← Checkbox + truncated value + count
│ ☐ system@anshinhealth.internal 1 │
│ │
│ │
├──────────────────────────────────────────────────┤
│ Clear column [Cancel] [Apply] │ ← Footer: left action + right CTAs
└──────────────────────────────────────────────────┘
Column Header Button
The trigger is a ghost button on every column header:
<Button
variant="ghost"
size="sm"
className="-ml-3 flex h-8 items-center gap-1 font-medium data-[state=open]:bg-accent"
>
<span className="truncate">{title}</span>
{/* Active filter indicator */}
{hasFilter && <Filter className="ml-1 h-3 w-3 text-primary" />}
{/* Sort indicator */}
{isSorted === 'desc' && <ArrowDown className="ml-1 h-4 w-4 text-primary" />}
{isSorted === 'asc' && <ArrowUp className="ml-1 h-4 w-4 text-primary" />}
{!isSorted && <ArrowUpDown className="ml-1 h-4 w-4 text-muted-foreground/50" />}
{/* Multi-sort number badge */}
{isMultiSorted && <span className="text-[10px] font-semibold text-primary">{sortIndex + 1}</span>}
</Button>
| State | Icon | Color |
|---|---|---|
| Unsorted | ArrowUpDown | text-muted-foreground/50 |
| Ascending | ArrowUp | text-primary |
| Descending | ArrowDown | text-primary |
| Active filter | Filter h-3 w-3 | text-primary |
| Multi-sort | Icon + number | text-primary |
Section 1 — Copy Column
A full-width button that copies all visible values from that column to the clipboard.
<Button variant="outline" className="w-full justify-start" onClick={handleCopyColumn}>
<Copy className="mr-2 h-4 w-4" />
Copy Column
</Button>
Copies values as newline-separated text. Shows a toast.success("Column copied to clipboard").
Section 2 — Pin Left / Hide / Pin Right
Three outline buttons in a horizontal row:
| Button | Icon | Action |
|---|---|---|
| Pin Left | PinLeft (or ArrowLeft) | column.pin('left') — sticks column to left edge during horizontal scroll |
| Hide | EyeOff (or Eye with slash) | column.toggleVisibility(false) — hides column (reveal via Manage Columns) |
| Pin Right | PinRight (or ArrowRight) | column.pin('right') — sticks column to right edge |
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="flex-1" onClick={() => column.pin('left')}>
<PinLeft className="mr-1 h-3.5 w-3.5" />
Pin Left
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => column.toggleVisibility(false)}>
<EyeOff className="mr-1 h-3.5 w-3.5" />
Hide
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => column.pin('right')}>
Pin Right
<PinRight className="ml-1 h-3.5 w-3.5" />
</Button>
</div>
Pinned column behavior:
- Pinned-left columns freeze to the left side of the table during horizontal scroll
- Pinned-right columns freeze to the right side (e.g., "Actions" column is always right-pinned)
- When already pinned, button changes to "Unpin":
column.pin(false) - TanStack Table v8 supports column pinning via
columnPinningstate
TanStack Table setup for pinning:
const table = useReactTable({
state: {
columnPinning: { left: ['select', 'username'], right: ['actions'] },
// ...
},
onColumnPinningChange: setColumnPinning,
// ...
});
Section 3 — Sort Controls
Three buttons in a horizontal row for sorting:
| Button | Icon | Action |
|---|---|---|
| Sort A-Z | ArrowUpDown or SortAsc | column.toggleSorting(false) — ascending |
| Sort Z-A | ArrowUpDown or SortDesc | column.toggleSorting(true) — descending |
| Clear sort | (text only, grayed when inactive) | column.clearSorting() |
<div className="flex gap-1.5">
<Button
variant="outline"
size="sm"
className={cn("flex-1", isSorted === 'asc' && "border-primary text-primary")}
onClick={() => column.toggleSorting(false)}
>
<ArrowUp className="mr-1 h-3.5 w-3.5" />
Sort A-Z
</Button>
<Button
variant="outline"
size="sm"
className={cn("flex-1", isSorted === 'desc' && "border-primary text-primary")}
onClick={() => column.toggleSorting(true)}
>
<ArrowDown className="mr-1 h-3.5 w-3.5" />
Sort Z-A
</Button>
<Button
variant="ghost"
size="sm"
disabled={!isSorted}
onClick={() => column.clearSorting()}
className="text-muted-foreground"
>
Clear sort
</Button>
</div>
Active sort button shows border-primary text-primary styling. "Clear sort" is grayed and disabled when no sort is active.
Multi-sort: Use column.toggleSorting(false, true) with multi=true parameter to add to existing sorts without clearing. Show the sort priority number next to the sort arrow.
Section 4 — Filter Conditions
The filter section has:
- A row with an operator dropdown + a value input
- A connector (
AND) + "Add condition" link for multi-condition support
Filter Operators (by column type)
| Column Type | Available Operators |
|---|---|
| Text | Contains, Equals, Starts with, Ends with, Is empty, Is not empty |
| Number | Equals, Greater than, Less than, Between, Is empty |
| Date | Is, Is after, Is before, Is between, Is empty |
| Boolean | Is true, Is false |
| Select/Enum | Is, Is not, Is empty |
Condition Row
<div className="flex gap-2">
{/* Operator dropdown */}
<Select value={operator} onValueChange={setOperator}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="startsWith">Starts with</SelectItem>
<SelectItem value="endsWith">Ends with</SelectItem>
<SelectItem value="isEmpty">Is empty</SelectItem>
</SelectContent>
</Select>
{/* Value input (hidden when operator is "Is empty"/"Is not empty") */}
{!isEmptyOperator && (
<Input
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="Filter value"
className="h-8 flex-1 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
)}
</div>
Add Condition (multi-condition)
{/* AND connector + add button */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">AND</span>
<button
type="button"
className="flex items-center gap-1 text-primary hover:underline"
onClick={addCondition}
>
<Plus className="h-3 w-3" />
Add condition
</button>
</div>
Multiple conditions are combined with AND logic. Each additional condition row has an X remove button on the right.
Section 5 — Distinct Values
A quick-select list showing the most common values in the column (top N, typically 5–10):
Distinct values (top 2)
☐ superadmin@anshinhealth.c... 1
☐ system@anshinhealth.internal 1
{distinctValues.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
Distinct values (top {distinctValues.length})
</p>
<div className="space-y-1 max-h-[120px] overflow-y-auto">
{distinctValues.map(({ value, count }) => (
<label key={value} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={selectedValues.includes(value)}
onCheckedChange={(checked) => toggleDistinctValue(value, checked)}
/>
<span className="flex-1 truncate text-xs text-foreground">{value}</span>
<span className="text-xs text-muted-foreground">{count}</span>
</label>
))}
</div>
</div>
)}
Clicking a distinct value checkbox adds it to the filter. Multiple selections use OR logic within distinct values, combined with AND for other conditions.
Section 6 — Footer
<div className="flex items-center justify-between border-t pt-3">
{/* Left: destructive action */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={handleClearColumn}
>
Clear column
</Button>
{/* Right: cancel + apply */}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleApply}>
Apply
</Button>
</div>
</div>
| Action | Behavior |
|---|---|
| Clear column | Removes all filter conditions + resets column to unfiltered state |
| Cancel | Closes the popup without applying any changes |
| Apply | Applies all pending filter conditions to the table data |
Apply is the primary button (filled/dark). Filter changes do NOT apply in real-time while the popup is open — they apply only on Apply click.
Base Implementation (Simple Version)
For tables that don't need the full-featured popup, the simpler DataTableColumnHeader DropdownMenu is acceptable as a baseline:
// Simple DropdownMenu version (used in referral-automation, basic tables)
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-3 h-8 ...">
{title}
{/* sort/filter indicators */}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{/* Sort Ascending / Sort Descending / Clear Sort */}
{/* Filter label + Input or Select */}
{/* Clear Filter (when active) */}
{/* Hide Column */}
</DropdownMenuContent>
</DropdownMenu>
The simple version includes:
- Sort Ascending / Sort Descending / Clear Sort
- Text input or Select dropdown for filtering
- Clear Filter (shown only when filter is active)
- Hide Column
The simple version does NOT include: Pin Left/Right, Copy Column, distinct values, multi-condition, Apply/Cancel footer.
Use the full-featured popup for: Primary list pages with many columns (Users, Bookings, Products). Use the simple DropdownMenu for: Secondary tables, sub-tables, simple inline grids.
Component: DataTableColumnHeader (Shared)
// packages/shared/components/data-table/data-table-column-header.tsx
export function DataTableColumnHeader<TData, TValue>({
column,
title,
enableSorting = true,
enableHiding = true,
enableFiltering = true,
filterType = 'text', // 'text' | 'number' | 'select'
}: DataTableColumnHeaderProps<TData, TValue>)
filterType options:
text—Inputfield (default)number—Input type="number"fieldselect—Selectdropdown usingcolumn.columnDef.meta.options
meta.options format:
// Simple string array (label = value):
meta: { options: ['active', 'inactive', 'pending'] }
// Object array (separate label/value):
meta: { options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
] }
When all capabilities disabled, renders plain text (no trigger):
if (!canSort && !canHide && !canFilter) {
return <div>{title}</div>;
}
Disabling Capabilities Per Column
// In column definition:
{
accessorKey: 'id',
enableSorting: false, // No sort options in popup
enableHiding: false, // No hide option + column excluded from Manage Columns
enableColumnFilter: false, // No filter section
}
// In DataTableColumnHeader:
<DataTableColumnHeader
column={column}
title="Actions"
enableSorting={false}
enableHiding={false}
enableFiltering={false}
/>
// When all false: renders plain text, no popup
Column Pinning State (TanStack Table)
Pinned columns are tracked in columnPinning state:
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: ['select'], // Always pin checkbox left
right: ['actions'], // Always pin actions right
});
const table = useReactTable({
state: { columnPinning, ... },
onColumnPinningChange: setColumnPinning,
enableColumnPinning: true,
});
Pinned columns receive a sticky class and a z-index to stay fixed during scroll:
// TanStack Table provides column.getIsPinned() === 'left' | 'right' | false
// Use column.getPinnedIndex() for the stacking order
className={cn(
column.getIsPinned() === 'left' && 'sticky left-0 z-10 bg-card',
column.getIsPinned() === 'right' && 'sticky right-0 z-10 bg-card',
)}
Violation Checklist
- Column header button uses
variant="ghost" size="sm" -ml-3 h-8 - Active sort shows
text-primarycolored arrow icon - Unsorted shows
ArrowUpDownintext-muted-foreground/50 - Active filter shows
Filter h-3 w-3 text-primaryicon - Pin Left, Hide, Pin Right are available in the column popup (full-featured version)
- Sort A-Z / Sort Z-A / Clear sort buttons use outline style
- Filter operator dropdown is present (not just a text input)
- "Clear column" appears in popup footer on the left
- Apply button is primary (filled), Cancel is outline
- Filter changes only apply when Apply is clicked (not real-time)
-
onClickandonKeyDownhavee.stopPropagation()on filter inputs -
actionscolumn is alwaysenableHiding: falseand pinned right -
selectcheckbox column is alwaysenableHiding: falseand pinned left