The Toolbar Pattern for Grid Actions
Permalink to "The Toolbar Pattern for Grid Actions"A grid toolbar is the bar of controls above a data grid: New, Export, Columns, Density, Filter, Refresh, and often a search field. As an ARIA toolbar, it becomes a single tab stop — Tab moves past it in one step, and arrow keys move between its controls. For a toolbar of ten controls above a grid, that is the difference between ten Tab presses to reach the data and one.
This page implements the pattern, including the parts that trip teams up: controls that use arrow keys themselves (a search field, a select), disabled controls, and knowing when not to use the pattern at all. It belongs to row actions & context menus.
Spec reference
Permalink to "Spec reference"role="toolbar" (ARIA 1.2) is “a collection of commonly used function buttons or controls represented in compact visual form.” The ARIA practices recommend:
- The toolbar is one tab stop; focus enters on the last-focused control (or the first).
Left/Right Arrowmove between controls in a horizontal toolbar (Up/Downifaria-orientation="vertical"); wrapping is optional.Home/Endgo to the first and last control.- Controls that need arrow keys themselves — a text input, a slider, a spin button — keep them; typically such controls are placed last, or the toolbar uses
Tabwithin those controls. - Disabled controls remain focusable (via
aria-disabled) so users can discover them. - The toolbar needs an accessible name when there is more than one on the page, and
aria-controlscan point at the grid it acts on.
Criteria: SC 2.1.1 Keyboard, SC 2.4.3 Focus Order, SC 4.1.2 Name, Role, Value.
When to use role=“toolbar” — and when plain tab order
Permalink to "When to use role=“toolbar” — and when plain tab order"Use the toolbar pattern when there are five or more controls above a grid, or when the bar is used repeatedly and users move between the grid and the bar often. The single tab stop pays off quickly.
Leave three or four plain buttons in the normal tab order. The toolbar pattern adds a keyboard model users must know; for a tiny bar, the savings are small and the arrow-key behaviour can surprise users who expect Tab to move between buttons.
The misapplication to name is role="toolbar" without the keyboard model — the role on a <div> of ordinary buttons, all still in tab order. Screen readers announce “toolbar”, and some users then try arrow keys, which do nothing. Either implement the model or leave the role off.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.2: named, and pointed at the grid it controls -->
<div role="toolbar" aria-label="Invoice table actions" aria-controls="inv-grid" class="toolbar">
<button type="button" tabindex="0">New invoice</button>
<button type="button" tabindex="-1" aria-haspopup="menu" aria-expanded="false">Export</button>
<button type="button" tabindex="-1" aria-haspopup="menu" aria-expanded="false">Columns</button>
<button type="button" tabindex="-1" aria-pressed="false">Compact rows</button>
<button type="button" tabindex="-1" aria-disabled="false" id="refresh">Refresh</button>
<!-- last: this control needs Left/Right for its caret -->
<input type="search" tabindex="-1" aria-label="Search invoices">
</div>
const bar = document.querySelector('.toolbar');
const controls = () => [...bar.querySelectorAll('button, input')];
bar.addEventListener('keydown', (e) => {
const list = controls();
const i = list.indexOf(document.activeElement);
if (i === -1) return;
const inText = e.target.matches('input[type="search"], input[type="text"]');
let j = null;
if (e.key === 'ArrowRight' && !inText) j = (i + 1) % list.length;
if (e.key === 'ArrowLeft' && !inText) j = (i - 1 + list.length) % list.length;
if (e.key === 'ArrowLeft' && inText && e.target.selectionStart === 0) j = i - 1; // leave field at start
if (e.key === 'Home' && !inText) j = 0;
if (e.key === 'End' && !inText) j = list.length - 1;
if (j === null) return;
e.preventDefault();
list[i].tabIndex = -1;
list[j].tabIndex = 0; // SC 2.4.3: roving tabindex, position remembered
list[j].focus();
});
// Disabled while refreshing, but still focusable and announced
async function refresh() {
const btn = document.getElementById('refresh');
if (btn.getAttribute('aria-disabled') === 'true') return;
btn.setAttribute('aria-disabled', 'true');
await reloadGrid();
btn.setAttribute('aria-disabled', 'false');
status('Invoices refreshed.');
}
Because the tab stop stays on whichever control last had focus, a user who exported, went into the grid and tabbed back lands on Export again — which is usually what they want next.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Expected behaviour | Announcement |
|---|---|---|
Tab into toolbar |
Focus on remembered control | “Invoice table actions, toolbar. Export, menu button” |
Right Arrow |
Next control | “Columns, menu button” |
End |
Last control (search) | “Search invoices, search edit” |
Left Arrow at start of search text |
Previous control | “Refresh, button” |
Tab from toolbar |
Leaves toolbar; next stop is the grid | Grid name and cell |
| Refresh while refreshing | Nothing happens | “Refresh, button, unavailable” |
Integration context
Permalink to "Integration context"Toolbar buttons that have shortcuts should carry aria-keyshortcuts, as in aria-keyshortcuts for grid commands. Menu buttons in the toolbar (Export, Columns) use the menu button pattern from row action menus with the menu button pattern; when their menu closes, focus returns to them inside the toolbar.
A bulk-action toolbar that appears on selection is a toolbar too, with its own placement and announcement rules in contextual bulk action toolbars.
Gotchas
Permalink to "Gotchas"Toggle buttons. Density or “show archived” toggles need aria-pressed, not just a visual state. The toolbar pattern does not change that.
Overflow menus. Toolbars that collapse controls into “More” at narrow widths must keep the roving tabindex correct as controls move between the bar and the menu.
Grouping. Several related toggles can be grouped with role="group" and a label inside the toolbar; arrow navigation still runs across all controls.
Design system notes
Permalink to "Design system notes"The grid component’s header slot should accept a toolbar definition — an ordered list of controls, with text inputs forced to the end — and render the full pattern. Teams that pass fewer than four plain buttons can get plain tab order automatically, which removes the “role without behaviour” failure without anyone having to remember the threshold.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"When should a grid's action bar use role="toolbar"?
When it has five or more controls or is used often, so that one tab stop and arrow-key movement save real effort. Small bars of three or four buttons are fine in normal tab order without the role.
How do users move between controls in an ARIA toolbar?
With Left and Right Arrow, and Home and End, while the whole toolbar is a single Tab stop. Tab moves past the toolbar to the next part of the page.
Should disabled buttons in a toolbar be focusable?
Yes. Use aria-disabled so they stay in the arrow-key sequence and are announced as unavailable. Removing them from focus hides the feature from keyboard and screen reader users.
What about a search field inside a toolbar?
It needs Left and Right Arrow for its text caret, so place it last and let Left Arrow at the start of the text move back into the toolbar.
Related
Permalink to "Related"- Contextual bulk action toolbars — a toolbar that fills in on selection
- aria-keyshortcuts — shortcuts on toolbar buttons
- Roving tabindex — the same technique in the grid