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.

Two ways to freeze a column Comparison of sticky positioning against splitting the table into two, showing what each does to the row relationship. Two ways to freeze a column✓ position: stickyOne table, one DOM orderRow relationships intactAnnounced as a single table with all itscolumns✗ A second overlaid tableTwo tables, neither with complete rowsHeader-to-cell relationships broken across thesplitAnnounced as two unrelated tables
One is a paint-time effect; the other destroys the table.

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'}`);
});
Freezing as an operable control Keyboard contract for a freeze toggle: it must be reachable, its state must be announced, and unfreezing must be as easy as freezing. Freezing as an operable controlEnter / SpaceToggle freeze on the focused columnAnnounce "Amount column frozen" or "unfrozen"TabReach the freeze control from theheaderIt is a button, not a hover-only affordanceEscapeClose the column menu holding thecontrolFocus returns to the header button
Sticky positioning is silent, so the control carries the whole message.

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.

Why a focused cell hides behind a frozen column Three-step diagram of the scroll-into-view failure: the browser considers a cell visible, scrolls it to the edge, and the sticky column paints over it. Why a focused cell hides behind a frozen columnFocus moves to a cellthe cell is off-screen to the leftscrollIntoView is calledThe browser scrollsthe cell now touches the left edgeThe browser considers this visibleThe sticky column paintsit covers the first 160 pixelsThe focused cell is hidden — add scroll padding
The browser has no idea a sticky element is covering the cell.

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.

Permalink to "Related"

Back to Resizable, Reorderable & Frozen Columns