Patterns
List Toolbar
The row of search, filters and actions above a list, a table or a grid.
Patterns
The row of search, filters and actions above a list, a table or a grid.
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>
)
}SearchInput first, then one FilterMenu for each filter.PageHeader of the Page.Import the parts and put them together.
import { Button, FilterButton, FilterMenu, ListToolbar, SearchInput } from 'ferry-ui'
<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.
onValueChange gives the query after each key. Your code filters the list. Esc and the × button clear the query.
Pass value to control the query, or defaultValue to let the field hold it.
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>
</>
)
}The size prop of SearchInput sets the height. The default is sm, the height of the toolbar buttons.
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 |
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.
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>
</>
)
}For a long list or a custom panel, use FilterButton as the trigger of a Popover. Give the labels of the selected values to selected.
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>
)
}For the sort order, use a Button with a Dropdown Menu. Put a Toggle Group for the view in actions.
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>
)
}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.
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()}`
}
/>
)
}SearchInput. Pass label to give a different name.ListToolbar also accepts each attribute of the <div> element.
| Prop | Type | Default |
|---|---|---|
actions | ReactNode | - |
Right-aligned controls: view toggles, export, and the list's primary "New …" action last. | ||
SearchInput also accepts most props of Input, such as id, name and disabled.
| Prop | Type | Default |
|---|---|---|
value | string | - |
Controlled query. Pair with | ||
defaultValue | string | |
Initial query when uncontrolled. | ||
onValueChange | ((value: string) => void) | - |
Called on every keystroke, and with | ||
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 | ||
className | string | - |
Merged onto the wrapper (controls width: full width on mobile, 200–320px flexible from | ||
inputClassName | string | - |
Merged onto the inner | ||
| Prop | Type | Default |
|---|---|---|
labelRequired | string | - |
Name of the filtered dimension, shown on the trigger ("Status"). | ||
optionsRequired | FilterOption[] | - |
The selectable values, in display order. | ||
value | string[] | - |
Controlled selection (option values). Pair with | ||
defaultValue | string[] | [] |
Initial selection when uncontrolled. | ||
onValueChange | ((value: string[]) => void) | - |
Called with the new selection (in | ||
heading | ReactNode | - |
Mono heading at the top of the menu. Default | ||
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 | ||
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 | ||
disabled | boolean | - |
Disables the trigger. | ||
className | string | - |
Merged onto the trigger button. | ||
contentClassName | string | - |
Merged onto the menu panel (default width 192px). | ||
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 also accepts the props of Button, but not children and variant.
| Prop | Type | Default |
|---|---|---|
labelRequired | 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. | ||
triggerLabel | ((label: string, selected: string[]) => string) | defaultTriggerLabel |
Builds the accessible name from | ||
shape | "default" | "pill" | - |
Corners: | ||
asChild | boolean | - |
Style the single child element (usually an | ||
loading | boolean | - |
Shows a spinner in place of | ||
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. | ||