Skip to content

Toolbar

A row of 28px view controls above a grid or board, with search and filters.

Status
Beta
Category
Layout
Adoption
Not used yet
import { ToolbarRow } from "@oration/canon/components/toolbar";
packages/canon/src/components/toolbar.tsx
  • 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

Full-bleed tools stack the header, then toolbar rows of 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

Toolbar controls are ghost or outline. If the view has a primary action such as New ticket, it lives in the app header, not in the toolbar.

The Scroll Edge Rule

When view tabs overflow the row, they scroll horizontally and fade their clipped edge over 2rem instead of wrapping onto a second line.

Anatomy#

  1. Row. ToolbarRow: a wrapping flex row, at least 44px tall, 12px by 8px padding, 8px gaps and a hairline bottom rule.
  2. View tabs. Saved views as 28px Body Dense pills with trailing counts in 12px Slate Meta, at the start of the row.
  3. Search. ToolbarSearch: a 28px field with a 14px search glyph, 176px wide at rest and 224px while focused.
  4. Controls. Filter, sort and display as ghost sm buttons, then a segmented control for layout, grouped with ml-auto at the end of the row.
  5. Filter chip row. An optional second ToolbarRow (min-h-10 py-1.5) that appears only while filters are active.

Examples#

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.

Disputes3 of 11
StatusisWaiting on supplierSupplieris any ofNorthwind Freight, Halcyon Supply
3 disputes waiting on a reply
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.

Callbacks

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#

Rest
Focus
With a query
Filter active
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>    );}
States
StateTreatment
Search at rest176px wide with the hairline lift as its edge and a Slate Meta placeholder.
Search focusedWidens to 224px over 200ms and swaps the lift for a 1px Focus Indigo ring.
Search with a queryA clear button appears at the right. Clearing empties the field and hides the button.
Filters activeThe filter button turns ink and shows the count in indigo, and the chip row opens under the toolbar.
Filtered to nothingThe rows area says what hid everything and offers Clear filters. The toolbar stays as it was.
NarrowThe 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#

  • ToolbarRow is layout only: it holds no state, sets no role and does no keyboard handling. Tab moves through its controls in order.
  • ToolbarSearch is controlled: pass value and onChange, 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-out and 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#

Do. Keep every control in the row at 28px: sm and icon-sm buttons, the segmented control and the search.
Don't. Mix 32 and 36px outline buttons into the row. The heights stagger and the row reads as a form.
Do. Keep toolbar controls ghost or outline, with the page's primary action in the header.
Don't. Fill toolbar buttons with indigo. Two filled buttons in one row, and neither is the page's main action.

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#

  • ToolbarRow has no toolbar role, which is right for a row of independent controls: role="toolbar" would promise arrow-key movement it doesn't implement.
  • ToolbarSearch wraps 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 tablist of tabs with aria-selected, controlling the rows as a tabpanel.
  • 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-label and a tooltip with the same words.
Keyboard interactions
KeysAction
TabMoves through the row's controls from left to right.
EscapeIn the search, clears the query where the browser supports it for search fields.
←→Inside the segmented control, moves and selects the layout.

Design tokens#

Design tokens
TokenUsed for
--borderThe row's hairline bottom rule
shadow-borderThe search field's edge at rest
--ringThe search field's 1px focus ring
--muted-foregroundSearch glyph, placeholder and clear button
--radius-md8px search corners, matching sm buttons

API reference#

ToolbarRow

The row. A wrapping flex container with a hairline bottom.

Other props spread onto <div>.

Props of ToolbarRow
PropTypeDefaultDescription
classNamestringNo defaultMerged 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.

Props of ToolbarSearch
PropTypeDefaultDescription
valueRequiredstringNo defaultThe current query.
onChangeRequired(value: string) => voidNo defaultCalled with the new query on every keystroke and on clear.
placeholderstring"Search"Placeholder and accessible name.
classNamestringNo defaultMerged 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.