# List Toolbar

The row of search, filters and actions above a list, a table or a grid.

```tsx
import * as React from 'react'
import { Button, FilterMenu, ListToolbar, SearchInput, type FilterOption } from 'ferry-ui'
import { Plus } from 'lucide-react'

const PROJECTS = [
  { name: 'Billing portal', status: 'active' },
  { name: 'Customer portal', status: 'active' },
  { name: 'Marketing site', status: 'paused' },
  { name: 'Internal wiki', status: 'archived' },
]

const STATUS_OPTIONS: FilterOption[] = [
  { value: 'active', label: 'Active' },
  { value: 'paused', label: 'Paused' },
  { value: 'archived', label: 'Archived' },
]

export default function ListToolbarHero() {
  const [query, setQuery] = React.useState('')
  const [statuses, setStatuses] = React.useState<string[]>([])

  const rows = PROJECTS.filter(
    (project) =>
      project.name.toLowerCase().includes(query.trim().toLowerCase()) &&
      (statuses.length === 0 || statuses.includes(project.status)),
  )

  return (
    <div className="flex flex-col gap-4">
      <ListToolbar
        actions={
          <Button variant="primary" icon={<Plus />}>
            New project
          </Button>
        }
      >
        <SearchInput placeholder="Search projects" value={query} onValueChange={setQuery} />
        <FilterMenu label="Status" options={STATUS_OPTIONS} value={statuses} onValueChange={setStatuses} />
      </ListToolbar>
      <div className="divide-y rounded-lg border bg-surface-100 text-sm">
        {rows.map((project) => (
          <div key={project.name} className="flex items-center justify-between gap-4 px-4 py-2.5">
            <span className="text-foreground">{project.name}</span>
            <span className="text-[13px] text-foreground-light capitalize">{project.status}</span>
          </div>
        ))}
        {rows.length === 0 && (
          <p className="px-4 py-6 text-center text-foreground-light">No projects match your filters.</p>
        )}
      </div>
    </div>
  )
}
```

## Usage guidelines

- **Put it above the list.** The toolbar controls the list, the table or the grid directly below it.
- **Search first, then filters.** Put `SearchInput` first, then one `FilterMenu` for each filter.
- **Only actions on the list.** Put the other actions in the `PageHeader` of the [Page](/docs/components/page).
- **The search is for the list only.** For a search in the full app, use [Command Menu](/docs/components/command-menu).

## Anatomy

Import the parts and put them together.

```tsx title="Anatomy"

<ListToolbar actions={<Button />}>
  <SearchInput />
  <FilterMenu label="" options={[]} />
</ListToolbar>
```

| Part | Role |
| --- | --- |
| `ListToolbar` | The row. The children go to the left and `actions` to the right. |
| `SearchInput` | The search field, with a button that clears it. |
| `FilterMenu` | A filter button with a menu of options. |
| `FilterButton` | The filter button alone, for a panel that you build. |

The toolbar has no outer margin. Add `className="mb-4"` if the parent does not space its children.

## Examples

### Search

`onValueChange` gives the query after each key. Your code filters the list. <Kbd>Esc</Kbd> and the × button clear the query.

Pass `value` to control the query, or `defaultValue` to let the field hold it.

```tsx
import * as React from 'react'
import { SearchInput } from 'ferry-ui'

export default function SearchInputDemo() {
  const [query, setQuery] = React.useState('')

  return (
    <>
      <SearchInput placeholder="Search invoices" value={query} onValueChange={setQuery} />
      <span className="text-[13px] text-foreground-light" aria-live="polite">
        {query === '' ? 'The query is empty.' : `Query: ${query}`}
      </span>
    </>
  )
}
```

### Search sizes

The `size` prop of `SearchInput` sets the height. The default is `sm`, the height of the toolbar buttons.

```tsx
import { SearchInput } from 'ferry-ui'

export default function SearchInputSizes() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <SearchInput size="tiny" placeholder="Search members" />
      <SearchInput size="sm" placeholder="Search members" />
      <SearchInput size="md" placeholder="Search members" />
    </div>
  )
}
```

| Size | Height |
| --- | --- |
| `tiny` | 26px |
| `sm` (default) | 30px |
| `md` | 34px |

### Filter menu

`FilterMenu` takes a `label` and a short list of `options`, about 10 at most. An option can have an `icon`, a `count` and a `disabled` state.

The user can select more than one option. The button shows the count. An empty selection means no filter.

```tsx
import * as React from 'react'
import { FilterMenu, StatusDot, type FilterOption } from 'ferry-ui'

const STATUS_OPTIONS: FilterOption[] = [
  { value: 'paid', label: 'Paid', count: 18, icon: <StatusDot tone="success" /> },
  { value: 'open', label: 'Open', count: 6, icon: <StatusDot tone="info" /> },
  { value: 'overdue', label: 'Overdue', count: 2, icon: <StatusDot tone="destructive" /> },
  { value: 'void', label: 'Void', count: 0, icon: <StatusDot tone="neutral" />, disabled: true },
]

export default function FilterMenuDemo() {
  const [statuses, setStatuses] = React.useState<string[]>(['overdue'])

  return (
    <>
      <FilterMenu label="Status" options={STATUS_OPTIONS} value={statuses} onValueChange={setStatuses} />
      <span className="text-[13px] text-foreground-light" aria-live="polite">
        {statuses.length === 0 ? 'No filter' : `Selected: ${statuses.join(', ')}`}
      </span>
    </>
  )
}
```

### Custom filter panel

For a long list or a custom panel, use `FilterButton` as the trigger of a [Popover](/docs/components/popover). Give the labels of the selected values to `selected`.

```tsx
import * as React from 'react'
import { Checkbox, FilterButton, Label, Popover, PopoverContent, PopoverTrigger } from 'ferry-ui'

const OWNERS = ['Maya Chen', 'Jonas Weber', 'Priya Patel']

export default function FilterButtonDemo() {
  const [selected, setSelected] = React.useState<string[]>(['Maya Chen'])

  function toggle(owner: string, checked: boolean) {
    setSelected(OWNERS.filter((name) => (name === owner ? checked : selected.includes(name))))
  }

  return (
    <Popover>
      <PopoverTrigger asChild>
        <FilterButton label="Owner" selected={selected} />
      </PopoverTrigger>
      <PopoverContent align="start" className="flex w-56 flex-col gap-3" aria-label="Filter by owner">
        {OWNERS.map((owner) => (
          <Label key={owner}>
            <Checkbox
              checked={selected.includes(owner)}
              onCheckedChange={(checked) => toggle(owner, checked === true)}
            />
            {owner}
          </Label>
        ))}
      </PopoverContent>
    </Popover>
  )
}
```

### Sort and view controls

For the sort order, use a [Button](/docs/components/button) with a [Dropdown Menu](/docs/components/dropdown-menu). Put a [Toggle Group](/docs/components/toggle-group) for the view in `actions`.

```tsx
import * as React from 'react'
import {
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
  ListToolbar,
  SearchInput,
  ToggleGroup,
  ToggleGroupItem,
} from 'ferry-ui'
import { ArrowUpDown, LayoutGrid, List } from 'lucide-react'

export default function ListToolbarSortView() {
  const [sort, setSort] = React.useState('updated')
  const [view, setView] = React.useState('list')

  return (
    <ListToolbar
      actions={
        <ToggleGroup
          type="single"
          variant="outline"
          aria-label="View"
          value={view}
          onValueChange={(next) => next && setView(next)}
        >
          <ToggleGroupItem value="grid" aria-label="Grid view">
            <LayoutGrid />
          </ToggleGroupItem>
          <ToggleGroupItem value="list" aria-label="List view">
            <List />
          </ToggleGroupItem>
        </ToggleGroup>
      }
    >
      <SearchInput placeholder="Search projects" />
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button icon={<ArrowUpDown />}>{sort === 'name' ? 'Name' : 'Last update'}</Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start" className="w-48">
          <DropdownMenuLabel>Sort by</DropdownMenuLabel>
          <DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
            <DropdownMenuRadioItem value="updated">Last update</DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
          </DropdownMenuRadioGroup>
        </DropdownMenuContent>
      </DropdownMenu>
    </ListToolbar>
  )
}
```

### Menu texts

Each text of `FilterMenu` is a prop with an English default. Use `heading`, `clearLabel` and `triggerLabel` to change or translate them. Pass `heading={null}` to hide the heading.

```tsx
import { FilterMenu, type FilterOption } from 'ferry-ui'

const PLAN_OPTIONS: FilterOption[] = [
  { value: 'free', label: 'Free' },
  { value: 'pro', label: 'Pro' },
  { value: 'enterprise', label: 'Enterprise' },
]

export default function FilterMenuTexts() {
  return (
    <FilterMenu
      label="Plan"
      options={PLAN_OPTIONS}
      defaultValue={['pro']}
      heading="Show the customers on"
      clearLabel="Show all plans"
      triggerLabel={(label, selected) =>
        selected.length > 0 ? `${label}: ${selected.join(', ')}. Change the filter` : `Select a ${label.toLowerCase()}`
      }
    />
  )
}
```

## Accessibility

- The placeholder is the accessible name of `SearchInput`. Pass `label` to give a different name.
- The default name of a filter button is "Filter by status". With a selection, it becomes "Status filter: Paid, Overdue".

## API reference

`ListToolbar` also accepts each attribute of the `<div>` element.

### ListToolbar

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `actions` | `ReactNode` |  | Right-aligned controls: view toggles, export, and the list's primary "New …" action last. |

### SearchInput

`SearchInput` also accepts most props of [Input](/docs/components/input), such as `id`, `name` and `disabled`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` |  | Controlled query. Pair with `onValueChange`. |
| `defaultValue` | `string` | `` | Initial query when uncontrolled. |
| `onValueChange` | `((value: string) => void)` |  | Called on every keystroke, and with `''` when cleared (× button or Escape). Debounce upstream if it hits a server. |
| `mono` | `boolean` |  | Monospace face at 13px. Use for machine values the user types or copies (identifiers, slugs, URLs, keys, hashes); keep prose fields in the sans face. |
| `placeholder` | `string` | `Search` | Placeholder, phrased as the action ("Search projects"). Default "Search". |
| `label` | `string` |  | Accessible name. Defaults to the placeholder. |
| `clearLabel` | `string` | `Clear search` | Accessible name of the clear (×) button, for translation. Default "Clear search". |
| `size` | `"tiny" \| "sm" \| "md"` | `sm` | Field height (default `sm` 30px, to line up with toolbar buttons). |
| `className` | `string` |  | Merged onto the wrapper (controls width: full width on mobile, 200–320px flexible from `sm`). |
| `inputClassName` | `string` |  | Merged onto the inner `<input>`. |

### FilterMenu

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | Name of the filtered dimension, shown on the trigger ("Status"). |
| `options` (required) | `FilterOption[]` |  | The selectable values, in display order. |
| `value` | `string[]` |  | Controlled selection (option values). Pair with `onValueChange`. |
| `defaultValue` | `string[]` | `[]` | Initial selection when uncontrolled. |
| `onValueChange` | `((value: string[]) => void)` |  | Called with the new selection (in `options` order) on every toggle and on "Clear filter". |
| `heading` | `ReactNode` |  | Mono heading at the top of the menu. Default `Filter by <label>` (the lower-cased `label`). Pass `null` to hide it. |
| `clearLabel` | `ReactNode` | `Clear filter` | Text of the reset item shown while something is selected (default "Clear filter"). |
| `triggerLabel` | `((label: string, selected: string[]) => string)` |  | Builds the accessible name of the trigger from `label` and the labels of the selected options, for translation. Default (English): "Filter by status", or "Status filter: Paid, Overdue" when active. |
| `open` | `boolean` |  | Controlled open state of the menu. |
| `defaultOpen` | `boolean` |  | Initial open state when uncontrolled. |
| `onOpenChange` | `((open: boolean) => void)` |  | Called when the menu opens or closes. |
| `align` | `"center" \| "start" \| "end"` | `start` | Menu alignment against the trigger (default `start`). |
| `disabled` | `boolean` |  | Disables the trigger. |
| `className` | `string` |  | Merged onto the trigger button. |
| `contentClassName` | `string` |  | Merged onto the menu panel (default width 192px). |

### FilterOption

The type of one option of `FilterMenu`.

| Name | Type | Role |
| --- | --- | --- |
| `value` | `string` | The value that the selection holds. |
| `label` | `string` | The text in the menu and in the name of the trigger. |
| `count` | `number` | The number of items that match. Optional. |
| `icon` | `ReactNode` | A small element before the label. Optional. |
| `disabled` | `boolean` | Makes the option not selectable. Optional. |

### FilterButton

`FilterButton` also accepts the props of [Button](/docs/components/button), but not `children` and `variant`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | Name of the filtered dimension ("Status", "Owner", "Plan"). |
| `selected` | `string[]` | `[]` | Labels of the currently selected values: drives the count badge, tooltip and accessible name. |
| `size` | `"tiny" \| "sm" \| "md" \| "lg" \| "icon-tiny" \| "icon" \| "icon-md" \| "icon-lg"` |  | Height. `tiny` 26px (dense inline actions), `sm` 30px (default: toolbars, table rows), `md` 34px (next to form fields), `lg` 38px. Square, icon-only sizes of the same heights: `icon-tiny` 26px, `icon` 30px, `icon-md` 34px, `icon-lg` 38px (they need an `aria-label`). |
| `triggerLabel` | `((label: string, selected: string[]) => string)` | `defaultTriggerLabel` | Builds the accessible name from `label` and the `selected` labels, for translation. Default (English): "Filter by status" when nothing is selected, "Status filter: Paid, Overdue" otherwise. An explicit `aria-label` wins over it. |
| `shape` | `"default" \| "pill"` |  | Corners: `default` (6px radius) or `pill` (fully rounded: top-bar actions, search triggers). |
| `asChild` | `boolean` |  | Style the single child element (usually an `<a>` or a router link) as the button instead of rendering a `<button>`. `icon` / `iconRight` are rendered inside the child. A link has no native disabled state, so `disabled` and `loading` map to ARIA on the child: `aria-disabled` (dimmed, pointer events off), `tabIndex={-1}` and, for `loading`, `aria-busy` plus the spinner. `type` is ignored. |
| `loading` | `boolean` |  | Shows a spinner in place of `icon`, disables the button and sets `aria-busy`. Use it while the action triggered by this button is in flight. |
| `icon` | `ReactNode` |  | Leading icon (rendered before children). Pass a bare lucide icon: it is sized for you. |
| `iconRight` | `ReactNode` |  | Trailing icon (rendered after children). Pass a bare lucide icon: it is sized for you. |
