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.

Menu or disclosure? Comparison of the menuitemcheckbox menu variant and the checkbox fieldset disclosure variant for a column chooser. Menu or disclosure?✓ Menu of menuitemcheckboxCompact; one tab stopArrow keys between columnsSpace toggles, menu stays openNo room for search or reset✓ Disclosure with checkboxesfieldset and legend name the groupTab between native checkboxesCan hold search, Show all, ResetBest for 15 or more columns
Choose the disclosure as soon as the chooser needs anything besides the toggles themselves.

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
What changes in the table when a column is hidden Layers showing the updates a column hide must make: menu state, table DOM, grid counts, button label and status message. What changes in the table when a column is hiddenChooser statearia-checked flips on the menu item; focus stays on itTable DOMth and td for the column removed, or hidden with the hidden attributeGrid countsaria-colcount keeps the total; aria-colindex stays true to the full setButton label"Columns (6 of 7)" reflects the new shapeStatus message"Region shown. 6 of 7 columns."
Five updates, one user action — missing any one leaves the structure lying about its shape.

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.

Hiding two columns in one visit Timeline of a keyboard user opening the column menu, hiding two columns while the menu stays open, and closing it to hear the updated trigger label. Hiding two columns in one visitOpen menu"Columns (7 of 7), menu"Space on Region"Region hidden. 6 of 7 columns."Down, Space"Notes hidden. 5 of 7 columns."Escapefocus back on the triggerTrigger read"Columns (5 of 7), button"one visit to the column menu
The menu stays open between toggles, so two changes cost two keypresses rather than eight.

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.

Permalink to "Related"

← Back to Resizable, Reorderable & Frozen Columns