Components
Filter Dropdown
A composable multi-select filter dropdown with two-level category navigation, checkbox options, optional search, select all, and an apply button. Designed for maximum flexibility — use it as a flat filter or with drill-down categories.
Installation
bunx @happlyui/cli@latest add filter-dropdown
Dependencies
npm packages
@radix-ui/react-popover@radix-ui/react-scroll-area@remixicon/react
Registry dependencies
These are automatically installed when you add this component.
buttoncheckboxdividerinputlink-buttonloaderhapply-ui-utilspolymorphic
Usage
import * as FilterDropdown from "@/components/ui/filter-dropdown"
<FilterDropdown.Root>
<FilterDropdown.Trigger asChild>
<button>Filters</button>
</FilterDropdown.Trigger>
<FilterDropdown.Content>
<FilterDropdown.Header onBack={handleBack} onReset={handleReset} />
<FilterDropdown.SelectAll checked={allChecked} onCheckedChange={handleSelectAll} />
<FilterDropdown.Group>
<FilterDropdown.Item checked={true} onCheckedChange={handleChange}>Option</FilterDropdown.Item>
</FilterDropdown.Group>
<FilterDropdown.Apply onClick={handleApply} />
</FilterDropdown.Content>
</FilterDropdown.Root>
Examples
Category Menu
A compact category selector with icons. Click a category to drill into its options.
<FilterDropdown.Root>
<FilterDropdown.Trigger asChild>
<Button.Root variant="neutral" mode="stroke">
<Button.Icon as={RiFilterLine} />
Filters
</Button.Root>
</FilterDropdown.Trigger>
<FilterDropdown.Content className="w-[224px]">
<FilterDropdown.CategoryList>
<FilterDropdown.CategoryItem icon={RiPuzzle2Line}>Types</FilterDropdown.CategoryItem>
<FilterDropdown.CategoryItem icon={RiEyeLine}>Visibility</FilterDropdown.CategoryItem>
</FilterDropdown.CategoryList>
</FilterDropdown.Content>
</FilterDropdown.Root>
Text Options
Multi-select filter with plain text options, select all, and apply button.
<FilterDropdown.Content>
<FilterDropdown.Header onBack={handleBack} onReset={reset} />
<FilterDropdown.SelectAll checked={allChecked} onCheckedChange={selectAll} />
<FilterDropdown.Group>
<FilterDropdown.Item checked={selected.has('Grant')} onCheckedChange={() => toggle('Grant')}>Grant</FilterDropdown.Item>
</FilterDropdown.Group>
<FilterDropdown.Apply onClick={handleApply} />
</FilterDropdown.Content>
Badge Options
Filter options rendered as badges (e.g., deadline status with colored indicators).
<FilterDropdown.Item checked={true} onCheckedChange={handleChange}>
<Badge.Root variant="light" color="green" size="small">Open</Badge.Root>
</FilterDropdown.Item>
Status Badge Options
Filter options rendered as status badges for publication status filtering.
<FilterDropdown.Item checked={true} onCheckedChange={handleChange}>
<StatusBadge.Root>Waiting for review</StatusBadge.Root>
</FilterDropdown.Item>
With Search
Filter with a search input for large option lists (e.g., people/users).
<FilterDropdown.Content>
<FilterDropdown.Header onBack={handleBack} onReset={reset} />
<FilterDropdown.Search value={search} onChange={(e) => setSearch(e.target.value)} />
<FilterDropdown.SelectAll checked={allChecked} onCheckedChange={selectAll} />
<FilterDropdown.Group>
{filtered.map((person) => (
<FilterDropdown.Item key={person} checked={selected.has(person)} onCheckedChange={() => toggle(person)}>
<Avatar /> {person}
</FilterDropdown.Item>
))}
</FilterDropdown.Group>
<FilterDropdown.Apply onClick={handleApply} />
</FilterDropdown.Content>
Two-Level Navigation
Complete example with category menu that drills into option panels. Navigation state is managed by the consumer.
// Consumer manages view state
const [view, setView] = useState('categories');
<FilterDropdown.Content className={view === 'categories' ? 'w-[224px]' : undefined}>
{view === 'categories' && (
<FilterDropdown.CategoryList>
<FilterDropdown.CategoryItem icon={RiPuzzle2Line} onClick={() => setView('types')}>Types</FilterDropdown.CategoryItem>
</FilterDropdown.CategoryList>
)}
{view === 'types' && (
<>
<FilterDropdown.Header onBack={() => setView('categories')} onReset={reset} />
<FilterDropdown.SelectAll checked={allChecked} onCheckedChange={selectAll} />
<FilterDropdown.Group>
<FilterDropdown.Item checked={true} onCheckedChange={handleChange}>Grant</FilterDropdown.Item>
</FilterDropdown.Group>
<FilterDropdown.Apply onClick={handleApply} />
</>
)}
</FilterDropdown.Content>
Flat Filter
Single-level filter without category navigation — just options, select all, and apply.
<FilterDropdown.Content>
<FilterDropdown.Header onReset={reset} />
<FilterDropdown.SelectAll checked={allChecked} onCheckedChange={selectAll} />
<FilterDropdown.Group>
<FilterDropdown.Item checked={true} onCheckedChange={handleChange}>Visible for members</FilterDropdown.Item>
<FilterDropdown.Item checked={false} onCheckedChange={handleChange}>Hidden from members</FilterDropdown.Item>
</FilterDropdown.Group>
<FilterDropdown.Apply onClick={handleApply} />
</FilterDropdown.Content>
Composed (Two-Level)
The Composed shorthand renders a full two-level filter dropdown from a config array. When 2+ filters are provided, a category menu is shown first.
<FilterDropdown.Composed
filters={[
{ key: 'types', label: 'Types', icon: RiPuzzle2Line, options: [{ value: 'grant', label: 'Grant' }, { value: 'loan', label: 'Loan' }] },
{ key: 'visibility', label: 'Visibility', icon: RiEyeLine, options: [{ value: 'visible', label: 'Visible' }, { value: 'hidden', label: 'Hidden' }] },
]}
selected={{ types: [], visibility: [] }}
onSelectedChange={(key, values) => setSelected(prev => ({ ...prev, [key]: values }))}
onApply={(sel) => console.log(sel)}
>
<Button.Root>Filters</Button.Root>
</FilterDropdown.Composed>
Composed (Flat)
When a single filter is provided, the Composed variant skips the category menu and renders options directly.
<FilterDropdown.Composed
filters={[
{ key: 'visibility', label: 'Visibility', options: [{ value: 'visible', label: 'Visible' }, { value: 'hidden', label: 'Hidden' }] },
]}
selected={{ visibility: [] }}
onSelectedChange={(key, values) => setSelected(prev => ({ ...prev, [key]: values }))}
onApply={(sel) => console.log(sel)}
>
<Button.Root>Visibility</Button.Root>
</FilterDropdown.Composed>
Composed with Search
Composed variant with searchable enabled for large option lists.
<FilterDropdown.Composed
filters={[
{ key: 'people', label: 'Created by', icon: RiUserLine, searchable: true, searchPlaceholder: 'Search people...', options: people.map(p => ({ value: p, label: p })) },
]}
selected={{ people: [] }}
onSelectedChange={(key, values) => setSelected(prev => ({ ...prev, [key]: values }))}
onApply={(sel) => console.log(sel)}
>
<Button.Root>Created by</Button.Root>
</FilterDropdown.Composed>
Composed with Badges
Composed variant where option labels are ReactNode (Badge components) instead of plain strings.
<FilterDropdown.Composed
filters={[{
key: 'deadline',
label: 'Deadline status',
options: [
{ value: 'Open', label: <Badge.Root variant="light" color="green" size="small">Open</Badge.Root> },
{ value: 'Closed', label: <Badge.Root variant="light" color="red" size="small">Closed</Badge.Root> },
],
}]}
selected={{ deadline: [] }}
onSelectedChange={(key, values) => setSelected(prev => ({ ...prev, [key]: values }))}
onApply={(sel) => console.log(sel)}
>
<Button.Root>Deadline status</Button.Root>
</FilterDropdown.Composed>
Composed with Remote Search
Composed variant with server-side search and infinite scroll. The remote.onFetch callback is called on mount, on search input change (debounced), and when the user scrolls near the bottom. Options are passed as empty initially — the component fetches them lazily. A loading spinner shows during initial fetch, and a smaller spinner appears at the bottom of the list when loading more pages.
const fetchProviders = async ({ query, page }) => {
const res = await fetch(`/api/providers?q=${query}&page=${page}&limit=20`);
const data = await res.json();
return {
options: data.items.map(p => ({ value: p.id, label: p.name })),
hasMore: data.currentPage < data.lastPage,
};
};
<FilterDropdown.Composed
filters={[{
key: 'providers',
label: 'Providers',
icon: RiUserLine,
searchable: true,
searchPlaceholder: 'Search providers...',
options: [],
remote: { onFetch: fetchProviders, debounceMs: 300 },
}]}
selected={{ providers: [] }}
onSelectedChange={(key, values) => setSelected(prev => ({ ...prev, [key]: values }))}
onApply={(sel) => console.log(sel)}
>
<Button.Root>Providers</Button.Root>
</FilterDropdown.Composed>
Two-Level with Remote Category
Multi-category filter where one category uses static local options and another uses remote server-side search with infinite scroll. Demonstrates mixing static and remote filters in the same dropdown.
<FilterDropdown.Composed
filters={[
{
key: 'types',
label: 'Types',
icon: RiPuzzle2Line,
searchable: true,
options: types.map(t => ({ value: t, label: t })),
},
{
key: 'providers',
label: 'Providers',
icon: RiUserLine,
searchable: true,
searchPlaceholder: 'Search providers...',
options: [],
remote: { onFetch: fetchProviders, debounceMs: 300 },
},
]}
selected={selected}
onSelectedChange={(key, values) => setSelected(prev => ({ ...prev, [key]: values }))}
onApply={(sel) => console.log(sel)}
>
<Button.Root>Filters</Button.Root>
</FilterDropdown.Composed>
API Reference
FilterDropdown.Root
The root container. Wraps Radix Popover.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controlled open state |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
FilterDropdown.Trigger
The element that opens the filter dropdown.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as child element using Radix Slot |
FilterDropdown.Content
The dropdown content panel with border, shadow, and animations.
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'start' | 'center' | 'end' | 'start' | Alignment relative to the trigger |
sideOffset | number | 8 | Distance in pixels from the trigger |
FilterDropdown.Header
Navigation header with Back and Reset filter buttons.
| Prop | Type | Default | Description |
|---|---|---|---|
onBack | () => void | - | Callback for Back button. If omitted, Back button is hidden. |
onReset | () => void | - | Callback for Reset filter button. If omitted, Reset button is hidden. |
backLabel | string | 'Back' | Custom label for the back button |
resetLabel | string | 'Reset filter' | Custom label for the reset button |
FilterDropdown.Search
A search input with magnifying glass icon for filtering options.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | 'Search...' | Placeholder text |
value | string | - | Controlled input value |
onChange | (e: ChangeEvent) => void | - | Input change handler |
FilterDropdown.SelectAll
Checkbox with 'Select all' label and a divider below. Supports indeterminate state.
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | 'indeterminate' | - | Checkbox state |
onCheckedChange | (checked: boolean) => void | - | Callback when checked state changes |
label | string | 'Select all' | Custom label text |
FilterDropdown.Group
Scrollable container for filter items with a custom scrollbar. Supports infinite scroll via onScrollEnd.
| Prop | Type | Default | Description |
|---|---|---|---|
maxHeight | number | 280 | Maximum height in pixels before scrolling |
onScrollEnd | () => void | - | Called when the user scrolls near the bottom of the list. Use for infinite scroll / load-more. |
isLoadingMore | boolean | false | Shows a loading spinner at the bottom of the list when fetching more items. |
FilterDropdown.Item
A selectable filter option with a checkbox. Accepts children for flexible content (text, badges, avatars).
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | - | Whether the item is selected |
onCheckedChange | (checked: boolean) => void | - | Callback when selection changes |
disabled | boolean | false | Disables the item |
children | React.ReactNode | - | Item content — plain text, Badge, StatusBadge, Avatar + name, or any custom content |
FilterDropdown.ItemIcon
Polymorphic icon for use inside Item children.
| Prop | Type | Default | Description |
|---|---|---|---|
as | React.ElementType | - | The icon component to render |
FilterDropdown.CategoryList
Container for category items in the category menu view.
| Prop | Type | Default | Description |
|---|
FilterDropdown.CategoryItem
A non-checkbox item with an icon for category navigation.
| Prop | Type | Default | Description |
|---|---|---|---|
icon | React.ElementType | - | Leading icon component |
onClick | () => void | - | Callback when the category is clicked |
FilterDropdown.Apply
Full-width apply button at the bottom of the filter panel.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Apply' | Button label |
onClick | () => void | - | Callback when apply is clicked |
disabled | boolean | false | Disables the button |
FilterDropdown.Composed
A composed shorthand that renders a complete filter dropdown from a config array. Automatically handles two-level category navigation (2+ filters) or flat single-level (1 filter). Manages internal view state, search, select all, and reset.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | The trigger element. Rendered via asChild. |
filters | FilterConfig[] | - | Filter configuration array. Each entry has key, label, icon, searchable, searchPlaceholder, options (value + label), and an optional remote object for server-side search with infinite scroll. 1 entry = flat filter (no categories), 2+ = two-level navigation. |
selected | Record<string, string[]> | - | Selected values per filter key, e.g. { types: ['grant'], visibility: [] }. |
onSelectedChange | (key: string, values: string[]) => void | - | Called when selection changes for a filter key. |
onApply | (selected: Record<string, string[]>) => void | - | Called when Apply is clicked. Receives all current selections. |
align | 'start' | 'center' | 'end' | 'start' | Content alignment relative to trigger |
side | 'top' | 'right' | 'bottom' | 'left' | - | Content side relative to trigger |
sideOffset | number | - | Distance in pixels from the trigger |
contentClassName | string | - | Additional className for the content panel |
applyLabel | string | 'Apply' | Custom label for the apply button |
open | boolean | - | Controlled open state |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
FilterConfig
Configuration object for each filter group passed to FilterDropdown.Composed.
| Prop | Type | Default | Description |
|---|---|---|---|
key | string | - | Unique identifier for this filter group |
label | string | - | Display label shown in the category menu |
icon | React.ElementType | - | Icon shown in the category menu |
searchable | boolean | false | Enable search input for this filter |
searchPlaceholder | string | - | Placeholder text for the search input |
options | FilterOption[] | - | Static options array. For remote filters, pass an empty array — options are fetched via remote.onFetch. |
remote | { onFetch, debounceMs? } | - | Enable remote data fetching for this filter. When set, options are loaded lazily from the server with support for search and infinite scroll. |
remote.onFetch | (params: { query: string; page: number }) => Promise<RemoteFetchResult> | - | Async function that fetches a page of options. Called on initial mount, on search input change (debounced), and when the user scrolls near the bottom of the list. |
remote.debounceMs | number | 300 | Debounce delay in milliseconds for search input before triggering a remote fetch. |
RemoteFetchResult
Return type for the remote.onFetch callback.
| Prop | Type | Default | Description |
|---|---|---|---|
options | FilterOption[] | - | The fetched options for this page |
hasMore | boolean | - | Whether more pages are available. When false, infinite scroll stops fetching. |