HapplyUI

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.

  • button
  • checkbox
  • divider
  • input
  • link-button
  • loader
  • happly-ui-utils
  • polymorphic

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>

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

PropTypeDefaultDescription
openboolean-Controlled open state
onOpenChange(open: boolean) => void-Callback when open state changes

FilterDropdown.Trigger

The element that opens the filter dropdown.

PropTypeDefaultDescription
asChildbooleanfalseRender as child element using Radix Slot

FilterDropdown.Content

The dropdown content panel with border, shadow, and animations.

PropTypeDefaultDescription
align'start' | 'center' | 'end''start'Alignment relative to the trigger
sideOffsetnumber8Distance in pixels from the trigger

FilterDropdown.Header

Navigation header with Back and Reset filter buttons.

PropTypeDefaultDescription
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.
backLabelstring'Back'Custom label for the back button
resetLabelstring'Reset filter'Custom label for the reset button

FilterDropdown.Search

A search input with magnifying glass icon for filtering options.

PropTypeDefaultDescription
placeholderstring'Search...'Placeholder text
valuestring-Controlled input value
onChange(e: ChangeEvent) => void-Input change handler

FilterDropdown.SelectAll

Checkbox with 'Select all' label and a divider below. Supports indeterminate state.

PropTypeDefaultDescription
checkedboolean | 'indeterminate'-Checkbox state
onCheckedChange(checked: boolean) => void-Callback when checked state changes
labelstring'Select all'Custom label text

FilterDropdown.Group

Scrollable container for filter items with a custom scrollbar. Supports infinite scroll via onScrollEnd.

PropTypeDefaultDescription
maxHeightnumber280Maximum height in pixels before scrolling
onScrollEnd() => void-Called when the user scrolls near the bottom of the list. Use for infinite scroll / load-more.
isLoadingMorebooleanfalseShows 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).

PropTypeDefaultDescription
checkedboolean-Whether the item is selected
onCheckedChange(checked: boolean) => void-Callback when selection changes
disabledbooleanfalseDisables the item
childrenReact.ReactNode-Item content — plain text, Badge, StatusBadge, Avatar + name, or any custom content

FilterDropdown.ItemIcon

Polymorphic icon for use inside Item children.

PropTypeDefaultDescription
asReact.ElementType-The icon component to render

FilterDropdown.CategoryList

Container for category items in the category menu view.

PropTypeDefaultDescription

FilterDropdown.CategoryItem

A non-checkbox item with an icon for category navigation.

PropTypeDefaultDescription
iconReact.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.

PropTypeDefaultDescription
labelstring'Apply'Button label
onClick() => void-Callback when apply is clicked
disabledbooleanfalseDisables 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.

PropTypeDefaultDescription
childrenReact.ReactNode-The trigger element. Rendered via asChild.
filtersFilterConfig[]-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.
selectedRecord<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
sideOffsetnumber-Distance in pixels from the trigger
contentClassNamestring-Additional className for the content panel
applyLabelstring'Apply'Custom label for the apply button
openboolean-Controlled open state
onOpenChange(open: boolean) => void-Callback when open state changes

FilterConfig

Configuration object for each filter group passed to FilterDropdown.Composed.

PropTypeDefaultDescription
keystring-Unique identifier for this filter group
labelstring-Display label shown in the category menu
iconReact.ElementType-Icon shown in the category menu
searchablebooleanfalseEnable search input for this filter
searchPlaceholderstring-Placeholder text for the search input
optionsFilterOption[]-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.debounceMsnumber300Debounce delay in milliseconds for search input before triggering a remote fetch.

RemoteFetchResult

Return type for the remote.onFetch callback.

PropTypeDefaultDescription
optionsFilterOption[]-The fetched options for this page
hasMoreboolean-Whether more pages are available. When false, infinite scroll stops fetching.

Previous
Emoji Dialog
Next
Modal