Pattern
Table toolbar
The strip above a table that holds search, filtering, sorting and bulk actions. Its job is to change mode cleanly: browsing and acting-on-a-selection are two different states, and the toolbar should look like it knows which one it is in.
The patternPermalink to this section
Select a row to watch the toolbar switch into bulk-action mode, then clear the selection to switch back.
| Project | Region | Status | Updated | |
|---|---|---|---|---|
| Q3 marketing site | Europe | 2 hours ago | ||
| billing-service | North America | 5 hours ago | ||
| search-indexer | Asia Pacific | 12 minutes ago | ||
| legacy-webhooks | North America | 3 days ago |
Two modesPermalink to this section
One toolbar, two jobs. Swapping the contents rather than stacking a second bar keeps the table's position on the page stable.
- Browsing — search on the left, filter and sort on the right. This is the resting state.
- Selection — a clear-selection control, the count, and the bulk actions. Search and sort step aside.
- The bar keeps the same height in both modes, so the table does not jump when a row is selected.
- The count is the anchor: it says exactly what the bulk actions will apply to.
SelectionPermalink to this section
The header checkbox selects everything currently visible — not everything in the dataset. When the two differ, say so.
Select all 128 projects — offered only when the filter hides more than is shown.
BehaviorPermalink to this section
- Selecting a row highlights it with the brand-soft fill, so the selection is visible in the table as well as the count.
- Clearing the selection returns focus to the header checkbox, not to the top of the page.
- Destructive bulk actions open a confirmation dialog that names the count: “Delete 4 projects?”.
- Changing a filter clears the selection, and says so — silently acting on rows that scrolled out of view is the bug this prevents.
- The count sits in a role="status" region so selection changes are announced.
- Keep bulk actions to three or fewer; put the rest behind an overflow menu.
- Disable bulk actions that do not apply to every selected row, and say why in the label.
Do and don'tPermalink to this section
Do — show the count with the actions
The count is what makes a bulk action safe to press — it states the blast radius.
Don't — hide the count
Bulk actions with no visible count invite someone to delete far more than they meant to.
Do — swap modes in place
Replacing the toolbar’s contents keeps the table where it was, so nothing shifts under the cursor.
Don't — stack a second bar
A second bar pushes the table down the moment a row is ticked, moving the next row out from under the pointer.
Code examplePermalink to this section
"use client";
import { Button, Checkbox, IconButton, Input } from "@borneo/react";
import { Download, Search, Trash2, X } from "lucide-react";
export function ProjectsToolbar({ rows, selected, setSelected, query, setQuery }) {
const allSelected = rows.length > 0 && selected.length === rows.length;
const someSelected = selected.length > 0 && !allSelected;
// One bar, two modes — the height stays constant so the
// table never shifts when a row is ticked.
if (selected.length > 0) {
return (
<div className="flex items-center gap-2 border-b p-2">
<IconButton
label="Clear selection"
icon={<X />}
size="sm"
onClick={() => setSelected([])}
/>
{/* Announced, so the count is perceivable without sight */}
<span role="status" className="type-body-sm font-[550]">
{selected.length} selected
</span>
<div className="ml-auto flex gap-1.5">
<Button size="sm" variant="secondary" leadingIcon={<Download />}>
Export
</Button>
<Button size="sm" variant="ghost" leadingIcon={<Trash2 />}>
Delete
</Button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 border-b p-2">
<Input
size="sm"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search projects"
aria-label="Search projects"
leadingIcon={<Search />}
/>
</div>
);
}Responsive behaviorPermalink to this section
The toolbar stacks below 640px, and the table itself scrolls horizontally rather than shrinking its columns into unreadability.
| Project | Region | Status | Updated | |
|---|---|---|---|---|
| Q3 marketing site | Europe | 2 hours ago | ||
| billing-service | North America | 5 hours ago | ||
| search-indexer | Asia Pacific | 12 minutes ago | ||
| legacy-webhooks | North America | 3 days ago |
Or turn rows into cards
Horizontal scrolling works for a table people scan. When each row is something to act on individually, a stack of cards below 640px is usually better — the toolbar and selection model stay the same.
AccessibilityPermalink to this section
- The table is a real table with a caption, th scope attributes and one row per record.
- Every row checkbox has a unique name (“Select billing-service”), never just “Select”.
- The header checkbox reports aria-checked="mixed" when the selection is partial.
- The selection count lives in a role="status" region so it is announced as it changes.
- Selected rows are marked with a data attribute and a background fill, and the checkbox state is the authoritative signal.
- The horizontal scroll container is focusable, so a keyboard user can scroll a wide table.
- Bulk actions are real buttons in the tab order, reachable without a pointer.