Column Visibility Menus and Column Choosers
Permalink to "Column Visibility Menus and Column Choosers"A column chooser lets users hide columns they do not need — a common feature in wide admin tables, and one that helps screen reader users more than anyone, because every hidden column is one fewer cell read per row. The chooser itself, though, is often a custom popover full of unlabelled toggles, and hiding a column quietly changes the table’s shape without telling anyone.
This page covers the two good implementations — a menu of checkable items and a disclosure with a checkbox list — plus the table-side updates that keep the structure honest. It belongs to resizable, reorderable & frozen columns.
Spec reference
Permalink to "Spec reference"Menu variant. A <button aria-haspopup="menu" aria-expanded> opens a role="menu" containing role="menuitemcheckbox" items with aria-checked="true|false". Arrow keys move between items, Space toggles, Escape closes and returns focus to the button. Menus are single-tab-stop widgets and cannot contain other controls such as a search field.
Disclosure variant. A <button aria-expanded aria-controls> reveals a panel containing a <fieldset> with a <legend> and native checkboxes. Tab moves between checkboxes. This variant can hold a search field, “Show all” and “Reset” buttons.
On the table side, hidden columns in a static <table> are simply removed from the DOM. In a role="grid" that virtualises or hides columns, aria-colcount states the total and aria-colindex on cells states each column’s position in the full set.
Criteria in play: SC 4.1.2 Name, Role, Value; SC 4.1.3 Status Messages for the change; SC 1.3.1 for honest structure after hiding.
When to use a column chooser — and when not to
Permalink to "When to use a column chooser — and when not to"Offer one whenever a table has more columns than fit comfortably at 320 CSS pixels wide, or more than about eight columns of which users typically need a few. It is also an effective mitigation for SC 1.4.10 Reflow on wide tables, discussed in meeting reflow with wide tables.
Do not use visibility toggles to replace a responsive design — hiding half the columns by default on mobile, with no indication they exist, is data loss. If columns are hidden by default, say so in the caption or next to the chooser: “Showing 5 of 12 columns”.
The misapplication to name is a chooser made of <div>s with onClick and a checkmark icon. It has no role, no state, and no keyboard support. Use real menu items or real checkboxes.
Annotated code example
Permalink to "Annotated code example"<!-- Menu variant -->
<button type="button" id="cols-btn"
aria-haspopup="menu" aria-expanded="false" aria-controls="cols-menu">
Columns <span class="count">(5 of 7)</span> <!-- SC 1.3.1: shape stated -->
</button>
<ul role="menu" id="cols-menu" aria-labelledby="cols-btn" hidden>
<!-- SC 4.1.2: role + checked state per column -->
<li role="menuitemcheckbox" aria-checked="true" aria-disabled="true" tabindex="-1">
Invoice (always shown) <!-- row header column cannot be hidden -->
</li>
<li role="menuitemcheckbox" aria-checked="true" tabindex="-1">Customer</li>
<li role="menuitemcheckbox" aria-checked="false" tabindex="-1">Region</li>
<li role="menuitemcheckbox" aria-checked="true" tabindex="-1">Amount</li>
</ul>
<p role="status" class="visually-hidden" id="cols-status"></p>
menu.addEventListener('keydown', (e) => {
const item = e.target.closest('[role="menuitemcheckbox"]');
if (!item) return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (item.getAttribute('aria-disabled') === 'true') return;
const visible = menu.querySelectorAll('[aria-checked="true"]').length;
const turningOff = item.getAttribute('aria-checked') === 'true';
if (turningOff && visible === 2) { // guard: keep one data column
status('At least one data column must stay visible.');
return;
}
item.setAttribute('aria-checked', String(!turningOff));
setColumnVisible(item.dataset.col, !turningOff);
// SC 4.1.3: what changed, and the resulting shape. Focus stays on the item.
status(`${item.textContent.trim()} ${turningOff ? 'hidden' : 'shown'}. ${visibleCount()} of 7 columns.`);
}
// ArrowUp / ArrowDown / Home / End / Escape handled as in any menu
});
The menu stays open after each toggle. Closing it on every Space forces users to reopen it for each column, which turns hiding four columns into sixteen keypresses.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Enter on Columns button |
“Columns (5 of 7), menu” then first item | JAWS says “menu, 4 items” |
Down Arrow |
“Region, not checked, menu item checkbox, 3 of 4” | VoiceOver: “Region, unticked, menu item” |
Space |
“checked” … “Region shown. 6 of 7 columns.” | NVDA may speak both in one utterance |
Space on “Invoice (always shown)” |
“unavailable” | Must not toggle |
Escape |
Menu closes; “Columns (6 of 7), button, collapsed” | Button text updated with the new count |
Integration context
Permalink to "Integration context"For grids that already manage partial column rendering, the counting rules are the same as in aria-colcount and aria-rowcount for partial grids: a user-hidden column is part of the total if your product thinks of it as “still there”, or removed from the total if hiding means “not part of this view”. Pick one and be consistent; the latter is usually clearer.
Reorder and visibility often share one panel. The reorder interaction is covered separately in announcing column reorder without drag and drop.
Gotchas
Permalink to "Gotchas"Hiding with CSS only. display: none on a column’s cells via nth-child removes them visually and from the accessibility tree, but leaves colspan values and headers references pointing at missing cells. Re-render instead.
Focus in a hidden column. If the grid’s active cell is in the column being hidden, move it to the nearest visible column in the same row before hiding.
Persisted preferences. Saving column choices across sessions is good; restoring them silently is confusing. Put the “Showing 5 of 7 columns” text somewhere persistent.
Design system notes
Permalink to "Design system notes"A column chooser belongs in the data-table component, not in each product, because it has to update several things at once: the table’s columns, aria-colcount, the trigger’s “5 of 7” label and the status message. Give the component a columnVisibility state it owns, a lockedColumns list for row-header columns, and a single onColumnVisibilityChange callback. Products then decide which columns can be hidden; the component guarantees the structure stays honest.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Can a column chooser use drag and drop to reorder as well?
It can, but drag must not be the only way. Pair it with move up and move down buttons or keyboard commands on each item, and announce the new position after each move.
Should a column chooser be a menu or a list of checkboxes?
A menu of menuitemcheckbox items for a short list of columns with nothing else in the popup; a disclosure containing a fieldset of checkboxes when there are many columns or the panel needs a search field or reset button.
Should the column menu close after toggling a column?
No. Keep it open so users can change several columns in one visit, keep focus on the toggled item, and announce the change and the new column count.
What should happen to aria-colcount when a user hides a column?
Decide whether hidden columns are part of the view. If hiding means removing the column from this view, lower aria-colcount and renumber aria-colindex; if the column is only scrolled or virtualised out, keep the total. Apply the choice consistently.
Related
Permalink to "Related"- Announcing column reorder — the other half of column customisation
- aria-colcount and aria-rowcount — counts when columns are hidden
- Menu button pattern — the menu mechanics