Frozen Columns and Screen Reader Reading Order
Permalink to "Frozen Columns and Screen Reader Reading Order"Freezing a column so it stays visible while the rest of the table scrolls is a purely visual affordance — and it is one of the few grid features where the naive implementation actively destroys accessibility. This page covers the sticky-positioning approach that costs nothing semantically, the split-table approach that costs everything, and the scroll-into-view bug that hides a focused cell behind the frozen region. The failure it prevents is a table announced as two unrelated tables, neither of which contains a complete row.
It is the freezing detail beneath resizable, reorderable and frozen columns, and it assumes the relationships described in semantic HTML table construction.
Spec reference
Permalink to "Spec reference"There is no ARIA for freezing, and that is the correct outcome: position: sticky changes where a box is painted, not what it means. A screen reader reading a table with a sticky first column encounters exactly the same DOM, the same row structure and the same header relationships as one without.
What the specification does govern is what happens when you fake it. SC 1.3.1 Info and Relationships (Level A) requires that structure conveyed visually is programmatically determinable. A user sees one table with seven columns; if the implementation renders columns one and two into a separate table element, the programmatic structure is two tables of two and five columns, and the visual and programmatic structures no longer agree.
position: sticky requires a scroll container and an inset — left: 0 for a frozen column, top: 0 for a sticky header — and an opaque background, or the scrolling content shows through.
When to use — and when not to
Permalink to "When to use — and when not to"Freeze the identifying column of a wide table: the name, the id, the date — whatever a user looks back at to remember which row they are reading. Freezing the selection checkbox column alongside it is usually worthwhile too.
Do not freeze more than about a third of the visible width. Beyond that the scrollable region becomes too narrow to be useful, and on small viewports it can leave no room at all. Disable freezing below a breakpoint rather than shipping a table where the scrollable area is 80 pixels wide.
Do not use freezing as a substitute for a responsive layout. On narrow viewports, collapsing rows into cards — as described in how TalkBack differs for data grid navigation — serves users far better than a frozen column and a 120-pixel scroll window.
Annotated code example
Permalink to "Annotated code example"/* SC 1.3.1: sticky changes painting only — the DOM order is untouched */
.grid-scroll {
overflow-x: auto;
/* SC 2.4.7: keep scrollIntoView from parking a focused cell under the sticky column */
scroll-padding-left: 160px; /* == total frozen width */
}
.grid th.is-frozen,
.grid td.is-frozen {
position: sticky;
left: 0;
z-index: 2;
background: var(--color-surface); /* opaque, or rows bleed through */
}
/* A second frozen column offsets by the width of the first */
.grid th.is-frozen-2,
.grid td.is-frozen-2 {
position: sticky;
left: 160px;
z-index: 2;
background: var(--color-surface);
}
/* The corner cell must sit above both the sticky row and the sticky column */
.grid thead th.is-frozen { z-index: 3; }
<!-- SC 4.1.2: the freeze control is a button with a real pressed state -->
<th scope="col" class="is-frozen">
Account
<button type="button" class="freeze-toggle" aria-pressed="true"
aria-label="Freeze Account column">📌</button>
</th>
// The control carries the whole message, because the CSS says nothing
toggle.addEventListener('click', () => {
const frozen = toggle.getAttribute('aria-pressed') === 'true';
toggle.setAttribute('aria-pressed', String(!frozen));
setFrozen(columnId, !frozen);
announce(`${labelOf(columnId)} column ${!frozen ? 'frozen' : 'unfrozen'}`);
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | Reading a table with a frozen column | Deviation |
|---|---|---|
| NVDA + Firefox | Identical to an unfrozen table; all columns in DOM order | None — sticky is invisible to it |
| JAWS + Chrome | Identical; table navigation keys work across the whole row | None |
| VoiceOver + Safari | Identical in table mode | None |
| TalkBack + Chrome | Swipes through cells in DOM order | Sticky positioning has no effect on swipe order |
The uniformity of that table is the point. A correctly frozen column changes nothing for assistive technology, which is exactly why the split-table implementation is such a serious regression: it converts a zero-cost visual feature into a structural defect.
Integration context
Permalink to "Integration context"Freezing interacts with two other features from the parent guide. With column reordering, decide whether the frozen set follows the column or the position — position is less surprising, and it means a move into position one freezes the moved column. With column resizing, remember that resizing a frozen column changes the offset of every column frozen after it, so the left values and the scroll-padding-left must be recomputed together.
Both of those are much easier if the frozen offsets are driven from a single computed value rather than hard-coded in CSS. A CSS custom property updated in one place — --frozen-width — keeps the padding and the offsets from drifting apart.
Gotchas
Permalink to "Gotchas"Transparent backgrounds. A sticky cell without an opaque background shows the scrolling content through it. In dark mode this is often missed, because the default surface token differs; set the background from a theme token rather than a literal colour.
A stacking context above the sticky column. Any ancestor with transform, filter or will-change creates a stacking context that traps the sticky element’s z-index, so rows paint over the frozen column. This looks like a sticky bug and is a stacking bug.
The header corner cell. With both a sticky header row and a sticky first column, the intersection cell needs a higher z-index than either, or it is painted over by one of them while scrolling diagonally.
Focus lands under the frozen column. Covered above: scroll-padding-left on the scroll container, equal to the frozen width. Without it, tabbing leftwards through cells appears to skip cells that are in fact focused and hidden.
FAQ
Permalink to "FAQ"Does a frozen column need any ARIA?
The column itself does not — position: sticky is invisible to assistive technology and that is fine, because freezing is a visual convenience rather than a semantic one. What needs ARIA is the control that toggles it: a real button whose pressed state is exposed, and an announcement when the state changes. If freezing conveys information the user needs, that information belongs in the control or in the column header name, never in the CSS.
Why not render the frozen column as a separate table?
Because it destroys the row. A screen reader reading the left table encounters rows with one cell, and reading the right table encounters rows missing their identifying column. Table navigation commands then move within one fragment only, and the header-to-cell relationships across the split are gone. Sticky positioning achieves the same visual result with no semantic cost.
How do I stop a focused cell from hiding behind the frozen column?
Add scroll-padding-left to the scroll container equal to the total frozen width. The browser then treats that strip as unavailable when it scrolls an element into view, so the focused cell lands clear of the sticky column instead of underneath it. The same applies vertically to a sticky header row with scroll-padding-top.
Related
Permalink to "Related"- Resizable, reorderable & frozen columns — the parent guide
- Semantic HTML table construction — the relationships a split table destroys
- Keyboard-accessible column resizing — resizing a frozen column changes every later offset