# Toolbar

> A React toolbar for lists and tables: search, filter chips, applied filters, sort, a list or grid switch, the count, actions and bulk mode.

- Type: React component (Layout)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.1
- Install: `npx wingo-ui@latest add toolbar`
- Docs: https://wingo-ui.com/components/toolbar

## Interaction

Type in the search (results update after a short pause, at once on Enter; Escape or × clears it). Tap the filter chips to switch quick filters on and off; when there are more than fit they scroll sideways with snapping and fade at the edges. Applied filters sit on a second row as chips with an ×; Clear filters removes them all. Sort opens a menu of options (arrow keys and Enter, Escape closes); the list / grid switch is a radio group (arrow keys move it). Page actions keep the primary visible and fold the rest into ⋯ as the row narrows. Select rows and the bar turns into a selection bar: the count, the bulk actions (folding into ⋯ when they do not fit) and × to clear, which Escape also does while focus is in the toolbar or the list next to it. On a phone the toolbar takes two rows (search with the primary action and ⋯, then the chips with sort and view as icon buttons), menus open as bottom sheets, every target is 44px, and the selection bar floats as an inverted pill above the tab bar and the home indicator. Sticky toolbars pin under the top bar on glass and show a hairline once stuck.

## Installation

```bash
npx wingo-ui@latest add toolbar
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/toolbar` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/toolbar.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Toggle](https://wingo-ui.com/components/toggle) ([Markdown](https://wingo-ui.com/components/toggle.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useOverflowItems](https://wingo-ui.com/components/use-overflow-items) ([Markdown](https://wingo-ui.com/components/use-overflow-items.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { Archive, Download, Plus, Trash2, Upload } from "lucide-react"
import { Toolbar } from "@/components/ui/toolbar"

export function ClientsToolbar() {
  const [query, setQuery] = useState("")
  const [active, setActive] = useState<string[]>(["active"])
  const [sort, setSort] = useState("newest")
  const [view, setView] = useState("list")
  const [selected, setSelected] = useState<string[]>([])

  return (
    <Toolbar
      searchPlaceholder="Search clients"
      searchValue={query}
      onSearchChange={setQuery}
      filters={[
        { id: "active", label: "Active", active: active.includes("active") },
        { id: "vip", label: "VIP", active: active.includes("vip") },
        { id: "overdue", label: "Overdue", count: 3, active: active.includes("overdue") },
      ]}
      onFilterToggle={(id, on) => setActive((list) => (on ? [...list, id] : list.filter((x) => x !== id)))}
      sort={{
        label: "Sort",
        value: sort,
        onValueChange: setSort,
        options: [
          { value: "newest", label: "Newest" },
          { value: "name", label: "Name (A-Z)" },
          { value: "balance", label: "Highest balance" },
        ],
      }}
      view={view}
      onViewChange={setView}
      resultCount={48}
      locale="en-US"
      actions={[
        { label: "Import CSV", icon: <Upload /> },
        { label: "Export", icon: <Download /> },
        { label: "New client", icon: <Plus />, primary: true },
      ]}
      selectedCount={selected.length}
      bulkActions={[
        { label: "Archive", icon: <Archive /> },
        { label: "Delete", icon: <Trash2 />, tone: "danger" },
      ]}
      onClearSelection={() => setSelected([])}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `search?` | `boolean` | `true` | Shows the search field (a SearchInput): full width on phones, 224 to 288px on wider toolbars. |
| `searchPlaceholder?` | `string` | `"Search"` | The search field's placeholder and accessible name. |
| `searchShortcut?` | `string` | `""` | A key that focuses the search from anywhere, shown as a hint in the empty field (never on touch). None by default: "/" belongs to the app's global search. |
| `searchValue?` | `string` |  | The controlled query. Typing stays local; the field follows this value whenever it changes. |
| `defaultSearchValue?` | `string` | `""` | The starting query while uncontrolled. |
| `onSearchChange?` | `(query: string) => void` |  | The trimmed query, debounced by searchDebounce; at once on Enter and when the field is cleared. |
| `searchDebounce?` | `number` | `300` | Milliseconds of quiet before onSearchChange runs. |
| `searchProps?` | `Partial<SearchInputProps>` |  | Anything else for the SearchInput: suggestions, recent searches, variant, an aria-label. |
| `filters?` | `ToolbarFilter[]` |  | Quick toggle chips { id, label, icon?, count?, active?, disabled? }: outline pills with aria-pressed and an optional count. active makes a chip controlled; without it the chip keeps its own state. They scroll sideways with snapping when they do not fit. |
| `onFilterToggle?` | `(id: string, active: boolean) => void` |  | Runs when a chip is switched on or off. |
| `filterTrigger?` | `ReactNode` |  | A slot after the chips for a "Filters" button with its own panel (a dropdown, a popover, a filter panel). |
| `appliedFilters?` | `ToolbarAppliedFilter[]` |  | { id, label }[]: removable chips on a second row. They enter and leave with a spring and the rest slide into place. |
| `onRemoveFilter?` | `(id: string) => void` |  | Called by an applied chip's × (and Backspace or Delete on it). Without it the chips have no ×. |
| `onClearFilters?` | `() => void` |  | Shows the Clear filters link after the applied chips. |
| `clearLabel?` | `string` | `"Clear filters"` | The text of the link that removes every applied filter. |
| `sort?` | `ToolbarSort` |  | { value, options: { value, label }[], onValueChange, label? }: a ghost "Sort: Newest" button opening a radio menu (an icon button on narrow toolbars and phones, where the menu is a bottom sheet). |
| `view?` | `string` |  | The controlled view (list / grid). A value that is not in views checks no segment. |
| `defaultView?` | `string` | `"list"` | The starting view while uncontrolled. |
| `onViewChange?` | `(view: string) => void` |  | Runs when the view switch changes. |
| `views?` | `ToolbarView[]` |  | { value, label, icon }[] for the icon-only segmented switch; labels become tooltips and accessible names. Default list and grid; fewer than two hide the switch. |
| `viewPersistKey?` | `string` | `""` | Remembers the uncontrolled view in localStorage under this key (use-persisted-state), shared by every toolbar with the same key. |
| `resultCount?` | `number` | `0` | "48 results" (through labels.results and the locale) before the actions, announced politely. Shown on toolbars wider than 896px; leave it out to hide the count. |
| `actions?` | `ToolbarAction[] \| ReactNode` |  | { id?, label, icon?, onSelect?, variant?, tone?, primary?, disabled? }[], the page-header shape: the primary (or the last solid) stays visible at the end, the others are outline buttons that fold into ⋯ from the end. On phones the primary is an icon button and the rest open from ⋯ as an action sheet. onSelect returning a promise shows the button loading. A node renders as is. |
| `selectedCount?` | `number` | `0` | Above 0 the toolbar becomes the selection bar: "3 selected", the bulk actions and ×. |
| `bulkActions?` | `ToolbarAction[]` |  | Ghost buttons in the selection bar; they fold into ⋯ when they do not fit, danger keeps its red, and in the floating bar on phones they turn icon-only. |
| `onClearSelection?` | `() => void` |  | The selection bar's × and Escape (while focus is in the toolbar, the floating bar or the list next to the toolbar). |
| `bulkPosition?` | `"inline" \| "floating"` | `"inline"` | inline swaps the toolbar's row for the selection bar; floating shows an inverted pill (bg-primary) pinned to the bottom of the screen, above var(--bottom-bar-offset) and the safe area. Below 768px it always floats. |
| `size?` | `"sm" \| "md"` | `"md"` | Controls 32 / 40px (44px on touch); the chips are a step shorter (28 / 32px with a 44px hit area). |
| `variant?` | `"plain" \| "surface"` | `"plain"` | plain sits on the page; surface is a raised rounded-2xl panel with 8px padding. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | The radius of the search field, the buttons, the view switch and the selection band, per size like the button's. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The color of pressed filter chips (a soft tint with a tone border). |
| `color?` | `string` | `""` | Any CSS color for pressed filter chips; replaces the tone. Leave empty to use the tone. |
| `sticky?` | `boolean` | `false` | Sticks under the top bar (top: var(--topbar-offset, 0px)) on glass, with a hairline that appears once it is stuck. |
| `loading?` | `boolean` | `false` | The search icon turns into a spinner and the field gets aria-busy. |
| `locale?` | `string` | `""` | Formats the result count, the selected count and chip counts (1,250 in en-US). Default: the app's locale. |
| `labels?` | `Partial<ToolbarLabels>` |  | Every string: region ("List controls"), results ((n, formatted) => "48 results"), selected ((n, formatted) => "3 selected"), sort ("Sort"), view ("View"), more ("More actions"), clearSelection ("Clear selection"), filters ("Quick filters"), appliedFilters ("Applied filters"), removeFilter ("Remove filter"), bulkActions ("Selection actions"), cancel ("Cancel", the phone sheets). |
| `children?` | `ReactNode` |  | Extra controls after the view switch (a density toggle, a columns menu, a select). Below a 48rem toolbar they get their own wrapping row and share its width equally, so they need no fixed widths on phones; from 48rem up they sit inline. |
| `classNames?` | `Partial<Record<ToolbarSlot, string>>` |  | Classes for the parts: row, search, filters, filter, filterCount, filterTrigger, sort, view, count, actions, more, applied, appliedFilter, clear, bulk, bulkCount, bulkActions, bulkClear, floating, extra (the row holding children). |

## Examples

- **Minimal**: A search field and one primary action; no view switch.
- **Applied filters**: Removable chips on a second row that spring out of the way, with a Clear filters link.
- **Sticky**: sticky pins the toolbar under the top bar on glass; the hairline appears once it is stuck. Scroll the frame.
- **Surface**: variant="surface" as a raised panel above a product grid; the switch changes the layout.
- **Phone**: Two rows under 768px, sort and view as icon buttons, and the floating selection bar pinned to the bottom.
- **Sizes**: sm (32px controls) and md (40px, 44px on touch).

## See also

- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [Page Header](https://wingo-ui.com/components/page-header) ([Markdown](https://wingo-ui.com/components/page-header.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Toggle](https://wingo-ui.com/components/toggle) ([Markdown](https://wingo-ui.com/components/toggle.md))
- [useOverflowItems](https://wingo-ui.com/components/use-overflow-items) ([Markdown](https://wingo-ui.com/components/use-overflow-items.md))

---

Canonical: https://wingo-ui.com/components/toolbar
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
