Column Header Menus in Data Grids

Permalink to "Column Header Menus in Data Grids"

Data grids increasingly put per-column actions in a header menu: sort ascending or descending, filter, hide column, pin left or right, autosize, group by. The menu saves header space and gives keyboard users a single place to reach column operations that are otherwise mouse-only (drag to reorder, drag to resize). But header menus also introduce problems: icon-only triggers named “Menu”, triggers that compete with the header’s sort button, and actions that move or remove the very column that held focus.

This guide belongs to column resizing, reordering & freezing.

Spec reference

Permalink to "Spec reference"
  • Menu button pattern — trigger with aria-haspopup="menu" and aria-expanded; role="menu" with menuitem, menuitemcheckbox (e.g. “Show column”), menuitemradio (e.g. pin left / right / none).
  • SC 4.1.2 (A) — trigger names identify the column.
  • SC 2.1.1 (A) and SC 2.5.7 (AA) — menu actions provide keyboard and single-pointer alternatives to dragging columns.
  • SC 2.4.3 (A) — focus returns logically after actions that move or hide columns.
  • SC 4.1.3 (AA) — effects are announced.
A column header menu Hierarchy of the Amount column menu: sort ascending and descending as radio items, a Filter item opening a dialog, Hide column as an action, Pin as a radio group with left, right and none, Move left and Move right actions, and Resize opening a dialog. A column header menuOptions for Amountmenu button in the headerSortradio items: ascending, descending, noneFilter…opens a filter dialogHide columnaction; announces "Amount column hidden"Pinradio items: left, right, noneMove left / Move rightalternatives to drag reorderingResize…dialog with a width field
Item types match what each action does.

When to use — and when not to

Permalink to "When to use — and when not to"

Use a header menu when columns have more than one or two operations, or when you need keyboard and single-pointer alternatives to drag reordering and resizing. Keep the most frequent action — sorting — directly on the header button.

Do not hide sorting exclusively inside the menu; users expect to sort by activating the header. Do not put free-form inputs (filter text) directly in the menu; open a dialog or popover.

The misapplication: a single header element that sorts on click and opens the menu on right-click only, leaving keyboard users without the menu.

Combined header target versus separate controls Comparison: one element that sorts on click and opens a menu on right-click hides the menu from keyboard users; a sort button plus a named menu button gives each action its own control. Combined header target versus separate controls✗ One element, click and right-clickMenu unreachable by keyboardRight-click only for optionsSort and menu confusedSmall combined target✓ Sort button and menu button"Options for Amount, menu button"Enter or Down opens the menuClear separation of actionsTwo targets meeting 24 px

Annotated code example

Permalink to "Annotated code example"
<th scope="col" aria-sort="none">
  <div class="th-inner">
    <button type="button" class="sort-btn">Amount</button>                    <!-- primary action: sort -->
    <button type="button" class="menu-btn" aria-haspopup="menu" aria-expanded="false"
            aria-controls="menu-amount" aria-label="Options for Amount">⋮</button>  <!-- named with column (SC 4.1.2) -->
  </div>
  <div role="menu" id="menu-amount" aria-label="Options for Amount" hidden>
    <div role="group" aria-label="Sort">
      <div role="menuitemradio" aria-checked="false" tabindex="-1">Sort ascending</div>
      <div role="menuitemradio" aria-checked="false" tabindex="-1">Sort descending</div>
    </div>
    <div role="menuitem" tabindex="-1" aria-haspopup="dialog">Filter…</div>
    <div role="menuitem" tabindex="-1" data-action="hide">Hide column</div>
    <div role="menuitem" tabindex="-1" data-action="left">Move left</div>               <!-- drag alternative (SC 2.5.7) -->
    <div role="menuitem" tabindex="-1" data-action="right">Move right</div>
    <div role="menuitem" tabindex="-1" aria-haspopup="dialog">Resize…</div>
  </div>
</th>
function onHide(col) {
  const next = neighbourHeader(col) ?? firstHeader();
  hideColumn(col);
  next.querySelector('.sort-btn').focus();                   // the menu's column is gone (SC 2.4.3)
  status.textContent = `${col.label} column hidden. Show it again from the Columns menu.`;   // SC 4.1.3
}
function onMove(col, dir) {
  moveColumn(col, dir);
  headerFor(col).querySelector('.menu-btn').focus();         // follow the moved column
  status.textContent = `${col.label} moved ${dir}, now column ${col.index + 1} of ${count()}`;
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Action Expected announcement
Tab to header Sort button “Amount, button” (+ sort state)
Tab Menu button “Options for Amount, menu button, collapsed”
Enter / Down Open menu Menu name and first item
Arrows, Enter Choose action Item name; then effect message
Escape Close Focus back to menu button
Menu actions and their item types Matrix of actions — sort direction, filter, hide, pin, move, resize — against menu item type, focus after the action and the announcement. Menu actions and their item typesItem typeFocus afterAnnouncementSort directionmenuitemradioMenu button"Sorted by Amount,descending"Filter…menuitem (dialog)Dialog, then backResult countHide columnmenuitemNeighbour header"Amount column hidden"PinmenuitemradioMenu button"Amount pinned left"Move left/rightmenuitemMoved column's menu buttonNew positionResize…menuitem (dialog)Dialog, then backNew width
Item type and focus destination follow from what the action does.

Integration context

Permalink to "Integration context"

Column visibility management in one place is covered in column visibility menus and column choosers; reordering without drag in announcing column reorder without drag and drop; resizing in keyboard accessible column resizing. The menu pattern itself follows row action menus with the menu button pattern.

Gotchas

Permalink to "Gotchas"

Inside role=“grid”. In interactive grids, header buttons should not each be tab stops; reach them via grid navigation (Enter on a header cell moves into its controls).

Target size. Menu buttons in headers are often tiny; give them 24 px hit areas.

Pinned columns and reading order. Pinning changes visual order; keep DOM order consistent with the reading order users expect, and announce pin changes.

Worked example

Permalink to "Worked example"

An analytics grid offered column operations only by dragging headers (reorder), dragging edges (resize) and right-clicking (hide). Keyboard and switch users could do none of it. The team added a named menu button per header with sort radios, Filter…, Hide, Pin, Move left/right and Resize…, kept sorting on the header button, and announced each effect. Column operations became available to everyone, and support tickets about “lost” hidden columns dropped because the hide message explained how to restore them.

Common mistakes

Permalink to "Common mistakes"
  • Unnamed “⋮” triggers.
  • Menu only on right-click.
  • Focus lost after hiding the column.
  • Menu items with the wrong roles (toggles as plain items).

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Provide a ColumnMenu slot in the grid with standard actions and messages, and let products add custom items. Standard wording across grids helps users recognise the menu everywhere.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Add a dedicated menu button in each header cell. Keep sorting on the header button itself and put the menu on a separate, smaller button named “Column options for Amount”. Two controls with two names avoid the ambiguity of one control doing two things.
  2. Use the menu button pattern. The button has aria-haspopup="menu" and aria-expanded. Opening it moves focus to the first menuitem; arrow keys move within the menu; Escape closes it and returns focus to the button.
  3. Group related items. Sorting items (ascending, descending, clear), visibility items (hide column, choose columns), and layout items (pin left, pin right, autosize) belong in separate groups with separators, so screen reader users hear structure rather than a flat list of ten items.
  4. Reflect state in items. Use menuitemradio with aria-checked for the current sort direction and pin state, so users hear which option is active.
  5. Confirm the outcome. After an action that changes the grid, announce the result politely — “Amount pinned left” — and keep focus on the header cell, not on a menu that has closed.

Edge cases

Permalink to "Edge cases"
  • Grid keyboard mode. Inside an ARIA grid, the header cell is a grid cell; make the menu reachable with a documented shortcut (often Alt+Down) as well as by Tab within the cell.
  • Hidden column. If the user hides the column from its own menu, focus must move to the neighbouring header, because the original element no longer exists.
  • Narrow columns. When a column is too narrow to show both buttons, keep the menu button and move sort into the menu rather than shrinking targets below 24 pixels.

FAQ

Permalink to "FAQ"
Should sorting be in the menu as well as on the header?

Yes, offering direction choices in the menu is useful; keep header activation as the primary sort.

Can the header menu open on right-click too?

Yes, as an addition. The menu button and Shift+F10 must also work.

Where should focus go after hiding a column?

To a neighbouring header, with a message explaining how to show the column again.

How do I label pin options?

As a radio group: “Pin left”, “Pin right”, “Do not pin”, with the current choice checked.

Should filters live in the header menu?

A “Filter…” item that opens a dialog works well; inline filter inputs in menus do not.

How many items are too many?

More than eight to ten becomes hard to scan; group with separators or move rare actions to a column settings dialog.

Permalink to "Related"

← Back to Resizable, Reorderable & Frozen Columns