Toolbar
A row of 28px view controls above a grid or board, with search and filters.
- TCK-4127Short-paid invoice INV-20931Northwind Freight
- TCK-4131Remittance missing for Sep 25 runHalcyon Supply
- TCK-4134Update bank details before FridayOrchard Street Bakery
- TCK-4138W-9 upload link expiredHalcyon Supply
import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import { cn } from "@oration/canon/lib/utils";import { KanbanIcon, ListFilterIcon, ListIcon, MailIcon, PhoneIcon } from "lucide-react";import * as React from "react";export function Hero() { const tickets = [ { id: "TCK-4127", title: "Short-paid invoice INV-20931", supplier: "Northwind Freight", channel: "Email", mine: true, assigned: true, }, { id: "TCK-4131", title: "Remittance missing for Sep 25 run", supplier: "Halcyon Supply", channel: "Voice", mine: false, assigned: false, }, { id: "TCK-4134", title: "Update bank details before Friday", supplier: "Orchard Street Bakery", channel: "Voice", mine: true, assigned: true, }, { id: "TCK-4138", title: "W-9 upload link expired", supplier: "Halcyon Supply", channel: "Email", mine: false, assigned: true, }, ]; const tabsId = React.useId(); const [view, setView] = React.useState("open"); const [search, setSearch] = React.useState(""); const [channels, setChannels] = React.useState<string[]>([]); const [layout, setLayout] = React.useState<"list" | "board">("list"); const views = [ { id: "open", label: "Open", count: tickets.length }, { id: "mine", label: "Mine", count: tickets.filter((t) => t.mine).length, }, { id: "unassigned", label: "Unassigned", count: tickets.filter((t) => !t.assigned).length, }, ]; const query = search.trim().toLowerCase(); const rows = tickets.filter( (ticket) => (view === "open" || (view === "mine" && ticket.mine) || (view === "unassigned" && !ticket.assigned)) && (channels.length === 0 || channels.includes(ticket.channel)) && (!query || `${ticket.id} ${ticket.title} ${ticket.supplier}` .toLowerCase() .includes(query)), ); const toggleChannel = (channel: string, checked: boolean) => setChannels((current) => checked ? [...current, channel] : current.filter((c) => c !== channel), ); return ( <section aria-label="Tickets" className="w-full overflow-hidden rounded-xl bg-card shadow-border" > <ToolbarRow> <div role="tablist" aria-label="Ticket views" className="flex min-w-0 items-center gap-0.5" > {views.map((entry) => { const active = entry.id === view; return ( <button key={entry.id} id={`${tabsId}-${entry.id}`} type="button" role="tab" aria-selected={active} onClick={() => setView(entry.id)} className={cn( "flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-13 whitespace-nowrap outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {entry.label} <span className="text-xs text-muted-foreground tabular-nums"> {entry.count} </span> </button> ); })} </div> <div className="ml-auto flex items-center gap-1 max-sm:w-full"> <ToolbarSearch value={search} onChange={setSearch} placeholder="Search tickets" className="text-[13px] max-sm:flex-1" /> <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="sm" className={cn( "text-muted-foreground", channels.length && "text-foreground", )} /> } > <ListFilterIcon data-icon="inline-start" aria-hidden="true" /> Filter {channels.length ? ( <span className="text-xs text-primary tabular-nums"> {channels.length} </span> ) : null} </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-44"> <DropdownMenuGroup> <DropdownMenuLabel>Channel</DropdownMenuLabel> {["Voice", "Email"].map((channel) => ( <DropdownMenuCheckboxItem key={channel} checked={channels.includes(channel)} onCheckedChange={(checked) => toggleChannel(channel, checked) } > {channel === "Voice" ? ( <PhoneIcon aria-hidden="true" /> ) : ( <MailIcon aria-hidden="true" /> )} {channel} </DropdownMenuCheckboxItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <SegmentedControl label="Layout" value={layout} onValueChange={(next) => { setLayout(next); toast.add({ title: next === "board" ? "Board layout" : "List layout", }); }} className="max-sm:hidden" options={[ { value: "list", label: "List", icon: <ListIcon aria-hidden="true" />, hideLabel: true, }, { value: "board", label: "Board", icon: <KanbanIcon aria-hidden="true" />, hideLabel: true, }, ]} /> </div> </ToolbarRow> {channels.length ? ( <ToolbarRow className="min-h-10 py-1.5"> <FilterChipRow> <FilterChip icon={ <PhoneIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> } field="Channel" valuesLabel={channels.join(", ")} multiple={channels.length > 1} onRemove={() => setChannels([])} /> </FilterChipRow> </ToolbarRow> ) : null} <div role="tabpanel" aria-labelledby={`${tabsId}-${view}`} className="min-h-44" > {rows.length ? ( <ul> {rows.map((ticket) => ( <li key={ticket.id} className="flex h-9 items-center gap-3 border-b border-border px-3 text-13 last:border-b-0" > <span className="w-18 shrink-0 font-mono text-xs text-muted-foreground"> {ticket.id} </span> <span className="min-w-0 flex-1 truncate text-foreground"> {ticket.title} </span> <span className="shrink-0 text-muted-foreground max-sm:hidden"> {ticket.supplier} </span> </li> ))} </ul> ) : ( <div className="flex flex-col items-center gap-2 px-4 py-10 text-center"> <p className="text-13 font-medium text-foreground"> No tickets match these filters </p> <Button type="button" variant="outline" size="sm" onClick={() => { setSearch(""); setChannels([]); }} > Clear filters </Button> </div> )} </div> </section> );}Usage#
Toolbar is the row of view controls between a list's header and its rows: view tabs on the left, then search, filter, display and layout controls, all 28px tall. ToolbarRow is the row itself (a hairline-ruled, wrapping flex row at least 44px tall) and ToolbarSearch is the compact search field that widens on focus. It's used across Contact Center, Ticketing and Settings lists. The mistake is mixing heights: a 32px default button or a filled primary in a toolbar row breaks the rhythm, and the row stops reading as quiet chrome around the data.
When to use
- Above a grid, table, board or list, to hold the controls that change what the rows show: views, search, filters, sort, display, layout.
- As a second row under the first, holding active filter chips, so filters stay visible without crowding the controls.
- As the top row of a card that frames a table on a document page, like the team roster.
- For search that filters the rows below as people type, with
ToolbarSearch.
When not to use
- For the page's title, breadcrumbs and page actions. That is the app header. Use List page
- For actions on selected rows. Those float in the bulk action bar. Use Action bar
- For formatting controls inside an editor, which need arrow-key toolbar semantics. Use Prompt editor
- For a standalone search box outside a toolbar row. Use Search field
- For switching between two to five layouts on their own, without the rest of the row. Use Segmented control
Toolbar rows use 28px controls
sm buttons, icon-sm icon buttons, the 28px segmented control, 28px filter chips and the 28px search. Default 32px controls stay out of the row.The One Filled Button Rule
The Scroll Edge Rule
Anatomy#
- Row.
ToolbarRow: a wrapping flex row, at least 44px tall, 12px by 8px padding, 8px gaps and a hairline bottom rule. - View tabs. Saved views as 28px Body Dense pills with trailing counts in 12px Slate Meta, at the start of the row.
- Search.
ToolbarSearch: a 28px field with a 14px search glyph, 176px wide at rest and 224px while focused. - Controls. Filter, sort and display as ghost
smbuttons, then a segmented control for layout, grouped withml-autoat the end of the row. - Filter chip row. An optional second
ToolbarRow(min-h-10 py-1.5) that appears only while filters are active.
Examples#
Search
ToolbarSearch filters as people type. It widens on focus, shows a clear button once there is a query, and pairs with a polite count of what's left.
- Northwind Freight
- Halcyon Supply
- Orchard Street Bakery
- Harborline Logistics
- Cobalt Office Supply
import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import * as React from "react";export function Search() { const suppliers = [ "Northwind Freight", "Halcyon Supply", "Orchard Street Bakery", "Harborline Logistics", "Cobalt Office Supply", ]; const [search, setSearch] = React.useState(""); const query = search.trim().toLowerCase(); const matches = suppliers.filter((name) => name.toLowerCase().includes(query), ); return ( <div className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <ToolbarRow> <ToolbarSearch value={search} onChange={setSearch} placeholder="Search suppliers" className="text-[13px]" /> <span role="status" className="ml-auto text-xs text-muted-foreground tabular-nums" > {matches.length} of {suppliers.length} </span> </ToolbarRow> <ul className="py-1"> {matches.map((name) => ( <li key={name} className="px-3 py-1.5 text-13 text-foreground" > {name} </li> ))} </ul> </div> );}28px controls
One row of the controls a toolbar holds: search, ghost sm buttons, a segmented control and an icon-sm button with a tooltip, all 28px.
Every control in the row is 28px tall: the search, the small buttons, the segmented control and the square icon button.
import { Button } from "@oration/canon/components/button";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArrowUpDownIcon, PlusIcon, SlidersHorizontalIcon } from "lucide-react";import * as React from "react";export function ControlHeights() { const [search, setSearch] = React.useState(""); const [density, setDensity] = React.useState<"compact" | "default">( "default", ); return ( <div className="w-full overflow-hidden rounded-xl bg-card shadow-border"> <ToolbarRow> <ToolbarSearch value={search} onChange={setSearch} placeholder="Search invoices" className="text-[13px]" /> <Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={() => toast.add({ title: "Sorted by due date, oldest first" }) } > <ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" /> Sort </Button> <Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={() => toast.add({ title: "Display options" })} > <SlidersHorizontalIcon data-icon="inline-start" aria-hidden="true" /> Display </Button> <SegmentedControl label="Row density" value={density} onValueChange={setDensity} options={[ { value: "compact", label: "Compact" }, { value: "default", label: "Default" }, ]} /> <div className="ml-auto flex items-center gap-1"> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="outline" size="icon-sm" aria-label="New invoice" onClick={() => toast.add({ title: "New invoice", description: "Opens a blank invoice.", }) } /> } > <PlusIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>New invoice</TooltipContent> </Tooltip> </div> </ToolbarRow> <p className="px-3 py-3 text-xs text-muted-foreground"> Every control in the row is 28px tall: the search, the small buttons, the segmented control and the square icon button. </p> </div> );}Filter chip row
A second ToolbarRow at min-h-10 py-1.5 holds active filter chips and a Clear action. It only renders while filters exist.
import { Button } from "@oration/canon/components/button";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { StatusLabel } from "@oration/canon/components/status-dot";import { ToolbarRow } from "@oration/canon/components/toolbar";import { ListFilterIcon } from "lucide-react";import * as React from "react";export function FilterRow() { const [filters, setFilters] = React.useState([ { field: "Status", values: ["Waiting on supplier"] }, { field: "Supplier", values: ["Northwind Freight", "Halcyon Supply"] }, ]); return ( <div className="w-full overflow-hidden rounded-xl bg-card shadow-border"> <ToolbarRow> <span className="text-13 font-medium text-foreground"> Disputes </span> <span className="text-xs text-muted-foreground tabular-nums"> {filters.length ? "3 of 11" : "11"} </span> </ToolbarRow> {filters.length ? ( <ToolbarRow className="min-h-10 py-1.5"> <FilterChipRow> {filters.map((filter) => ( <FilterChip key={filter.field} icon={ <ListFilterIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> } field={filter.field} valuesLabel={filter.values.join(", ")} multiple={filter.values.length > 1} onRemove={() => setFilters((current) => current.filter( (f) => f.field !== filter.field, ), ) } /> ))} </FilterChipRow> <Button type="button" variant="ghost" size="sm" className="ml-auto text-muted-foreground" onClick={() => setFilters([])} > Clear </Button> </ToolbarRow> ) : ( <p className="border-b border-border px-3 py-2.5 text-xs text-muted-foreground"> Filters cleared. </p> )} <div className="px-3 py-3"> <StatusLabel tone="warning" className="text-[13px]"> {filters.length ? "3 disputes" : "11 disputes"} waiting on a reply </StatusLabel> </div> </div> );}Wrapping on narrow screens
The Ticketing pattern: flex-nowrap gap-3 max-md:flex-wrap keeps one line on desktop, then gives the controls their own line with a full-width search.
Below 768px the controls take their own line and the search grows to fill it.
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import { KanbanIcon, ListIcon } from "lucide-react";import * as React from "react";export function Wrapping() { const [search, setSearch] = React.useState(""); const [layout, setLayout] = React.useState<"list" | "board">("list"); return ( <div className="w-full max-w-sm overflow-hidden rounded-xl bg-card shadow-border"> <ToolbarRow className="flex-nowrap gap-3 max-md:flex-wrap"> <span className="min-w-0 flex-1 truncate text-13 font-medium text-foreground"> Callbacks </span> <div className="flex shrink-0 items-center gap-1 max-md:w-full"> <ToolbarSearch value={search} onChange={setSearch} placeholder="Search callbacks" className="text-[13px] max-md:flex-1" /> <SegmentedControl label="Layout" value={layout} onValueChange={setLayout} options={[ { value: "list", label: "List", icon: <ListIcon aria-hidden="true" />, hideLabel: true, }, { value: "board", label: "Board", icon: <KanbanIcon aria-hidden="true" />, hideLabel: true, }, ]} /> </div> </ToolbarRow> <p className="px-3 py-3 text-xs text-muted-foreground"> Below 768px the controls take their own line and the search grows to fill it. </p> </div> );}States#
import { Button } from "@oration/canon/components/button";import { ToolbarSearch } from "@oration/canon/components/toolbar";import { cn } from "@oration/canon/lib/utils";import { ListFilterIcon } from "lucide-react";export function States() { const cells = [ { label: "Rest", value: "", className: "" }, { label: "Focus", value: "", className: "w-56 shadow-[0_0_0_1px_var(--ring)]", }, { label: "With a query", value: "Halcyon", className: "" }, ]; return ( <div className="flex w-full flex-wrap items-end gap-6"> {cells.map((cell) => ( <div key={cell.label} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> {cell.label} </span> <ToolbarSearch value={cell.value} onChange={() => undefined} placeholder="Search suppliers" className={cn( "pointer-events-none text-[13px]", cell.className, )} /> </div> ))} <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Filter active </span> <Button type="button" variant="ghost" size="sm" tabIndex={-1} className="pointer-events-none" > <ListFilterIcon data-icon="inline-start" aria-hidden="true" /> Filter <span className="text-xs text-primary tabular-nums">2</span> </Button> </div> </div> );}| State | Treatment |
|---|---|
| Search at rest | 176px wide with the hairline lift as its edge and a Slate Meta placeholder. |
| Search focused | Widens to 224px over 200ms and swaps the lift for a 1px Focus Indigo ring. |
| Search with a query | A clear button appears at the right. Clearing empties the field and hides the button. |
| Filters active | The filter button turns ink and shows the count in indigo, and the chip row opens under the toolbar. |
| Filtered to nothing | The rows area says what hid everything and offers Clear filters. The toolbar stays as it was. |
| Narrow | The row wraps. Below 640px the search goes full width (max-sm:w-full); pages often move the control group to its own line below 768px. |
Behavior#
ToolbarRowis layout only: it holds no state, sets no role and does no keyboard handling. Tab moves through its controls in order.ToolbarSearchis controlled: passvalueandonChange, which receives the string. It filters as people type; there is no submit and no debounce.- The clear button calls
onChange(""). It only renders while there is a value. - The field is a native
type="search"input with the browser's own cancel button hidden. Browsers that clear search fields on Escape still do; the component adds no key handling of its own. - The width change on focus runs over 200ms on
ease-outand isn't turned off under reduced motion. - The filter chip row is a second
ToolbarRow, rendered only while filters exist, so the rows below don't shift until a filter is added.
Do and don't#
sm and icon-sm buttons, the segmented control and the search.Content#
- Search placeholders say what is searched: Search tickets, Search suppliers. The placeholder is also the field's accessible name.
- View tab labels are short nouns or states: Open, Mine, Unassigned. The count follows the label, never inside parentheses.
- Control labels are single words where the icon helps: Filter, Sort, Display.
- When filters hide every row, name it and offer the way out: No tickets match these filters with Clear filters.
Accessibility#
ToolbarRowhas notoolbarrole, which is right for a row of independent controls:role="toolbar"would promise arrow-key movement it doesn't implement.ToolbarSearchwraps the input in a<label>with a screen-reader copy of the placeholder, so it always has a name. Write a placeholder that works as a label.- Announce result counts in a polite live region (
role="status") beside the search, as the search example does, so filtering is audible. - View tabs that change which rows show are a
tablistoftabs witharia-selected, controlling the rows as atabpanel. - The search clear button is named Clear search but its hit area is the 14px icon, below the 24px minimum. When it's pressed it unmounts, and keyboard focus falls back to the page.
- Icon-only controls in the row carry an
aria-labeland a tooltip with the same words.
| Keys | Action |
|---|---|
| Tab | Moves through the row's controls from left to right. |
| Escape | In the search, clears the query where the browser supports it for search fields. |
| ←→ | Inside the segmented control, moves and selects the layout. |
Design tokens#
| Token | Used for |
|---|---|
--border | The row's hairline bottom rule |
shadow-border | The search field's edge at rest |
--ring | The search field's 1px focus ring |
--muted-foreground | Search glyph, placeholder and clear button |
--radius-md | 8px search corners, matching sm buttons |
API reference#
ToolbarRow
The row. A wrapping flex container with a hairline bottom.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after the base classes. Use flex-nowrap max-md:flex-wrap for a row that wraps only on small screens, or min-h-10 py-1.5 for a chip row. |
ToolbarSearch
The toolbar search field. Takes only the props below; nothing else reaches the input.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The current query. |
onChangeRequired | (value: string) => void | No default | Called with the new query on every keystroke and on clear. |
placeholder | string | "Search" | Placeholder and accessible name. |
className | string | No default | Merged onto the wrapping <label>. Pass text-[13px] for dense text and max-sm:flex-1 to grow on narrow rows. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
ToolbarSearch lists text-13 before text-muted-foreground, and cn from the cn package reads both as colors, so the 13px size is dropped and the query renders at the inherited size, usually 14px. The examples here pass text-[13px].
The search widens by animating width. DESIGN.md animates only transform, opacity, filter, clip-path and disclosure height, and the transition has no motion-reduce guard.
The search's focus indicator is a 1px ring. Inputs elsewhere use an indigo border plus a 3px ring at 50%.
The clear button's hit area is 14px, below the 24px minimum, and it unmounts when pressed, dropping keyboard focus.
ToolbarSearch doesn't grow to 16px below 768px, so iOS zooms the page when it's focused. Search field does; the two toolbar searches differ.
ToolbarSearch accepts no input props (id, name, onKeyDown, aria-controls), so it can't be wired to a results list or a shortcut.
View tabs, the filter menu, filter chips, sort and display menus live in apps/web/src/components/records/toolbar, not in packages/canon. Canon can't document them as components, and this page hand-rolls a minimal view tab strip.