Sticky Table Headers and First Columns Without Breaking Access
Permalink to "Sticky Table Headers and First Columns Without Breaking Access"A sticky header keeps a table’s column headers visible as the user scrolls down; a sticky first column keeps the row labels visible as they scroll sideways. Both help sighted users enormously, particularly low-vision users at high zoom who can see only a few cells at a time. Both are also commonly built in ways that harm screen reader and keyboard users: a cloned header table that duplicates every header in the accessibility tree, or a sticky area so large at 400% zoom that the focused cell is permanently hidden beneath it.
This page shows the CSS-only approach that keeps a single table, and the limits that keep focus visible. It belongs to responsive data tables.
Spec reference
Permalink to "Spec reference"position: sticky works on <th> and <td> in all current browsers. A sticky cell sticks relative to its nearest scrolling ancestor — for a header row, usually the page or a vertically scrolling wrapper; for a first column, the horizontally scrolling wrapper from keyboard-scrollable table containers. No ARIA is involved: the table’s semantics are untouched because nothing is duplicated or moved.
SC 2.4.11 Focus Not Obscured (Minimum), new in WCAG 2.2 at Level AA, requires that when an element receives keyboard focus it is not entirely hidden by author-created content — sticky headers are the example the understanding document gives. scroll-padding on the scroll container tells the browser to leave room for the sticky parts when it scrolls a focused element into view.
SC 1.4.10 Reflow and SC 1.4.4 Resize Text are the zoom criteria: at 400% zoom a 3-row sticky header can occupy most of the viewport height.
When to use sticky parts — and when not to
Permalink to "When to use sticky parts — and when not to"Use a sticky header row whenever a table is longer than a screen, and a sticky first column whenever it scrolls horizontally. They are among the cheapest usability wins for data tables.
Switch stickiness off when it would take too much of the viewport. At 400% zoom on a laptop, the viewport is about 320 CSS pixels tall; a two-line header row plus a sticky page header can take half of it. A media query on viewport height handles this automatically.
The misapplication to name is making a multi-row header block sticky, including filters and a toolbar, so that the “sticky” region is 200 pixels tall. That fails SC 2.4.11 at modest zoom levels and makes the table hard to use for everyone.
Annotated code example
Permalink to "Annotated code example".table-scroll {
overflow: auto;
max-block-size: 70vh; /* the scroll container */
/* SC 2.4.11: leave room for sticky parts when focus scrolls into view */
scroll-padding-block-start: 2.75rem; /* header row height */
scroll-padding-inline-start: 10rem; /* first column width */
}
.table-scroll thead th {
position: sticky;
inset-block-start: 0;
z-index: 2; /* above body cells */
background: var(--surface); /* opaque: no text bleeding through */
}
.table-scroll tbody th[scope="row"] {
position: sticky;
inset-inline-start: 0;
z-index: 1;
background: var(--surface);
box-shadow: 1px 0 0 var(--color-rule); /* visible edge while scrolled */
}
/* the corner cell sits above both sticky axes */
.table-scroll thead th:first-child { inset-inline-start: 0; z-index: 3; }
/* SC 1.4.10: release stickiness when the viewport is very short (high zoom) */
@media (max-height: 25rem) {
.table-scroll { max-block-size: none; scroll-padding: 0; }
.table-scroll thead th, .table-scroll tbody th[scope="row"] { position: static; }
}
The opaque background matters for more than aesthetics. A transparent sticky header lets body text show through it, which fails text contrast (SC 1.4.3) for the header labels as the table scrolls beneath them.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected result | Failure indicator |
|---|---|---|
Tab into a link in row 40 |
Row scrolls into view below the sticky header | Focused link hidden under the header (SC 2.4.11) |
Right Arrow in scroll container |
Columns scroll; first column stays | First column scrolls away, or covers the focused cell |
| NVDA table navigation down a column | Header announced once per column change, as normal | Header announced twice (cloned table) |
| 400% zoom, short viewport | Stickiness released; content flows | Sticky area fills most of the screen |
| VoiceOver rotor, Tables | One table listed | Two tables listed |
Integration context
Permalink to "Integration context"In interactive grids, “frozen” columns are the same idea with extra concerns about DOM order and reading order, covered in frozen columns and screen reader reading order. The CSS approach here applies to static tables and to grids that keep a single DOM table.
Focus indicators on sticky cells need to be drawn inside the cell (negative outline-offset) or they are clipped by neighbouring sticky cells; the auditing approach in focus indicator contrast auditing catches clipped rings.
Gotchas
Permalink to "Gotchas"overflow: hidden on an ancestor. Sticky positioning stops working if any ancestor between the cell and the scroll container clips overflow. Check wrappers added by layout components.
border-collapse: collapse and sticky borders. Collapsed borders belong to the table, not the cell, so they scroll away. Use box-shadow for sticky edges, as in the example.
Page-level sticky headers. If the site header is also sticky, the table header must stick below it: set inset-block-start to the site header height, and include both in scroll-padding.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Do sticky table headers cause accessibility problems?
Not when they are made with position:sticky on the real header cells — the table structure is unchanged. Problems come from cloned header tables, transparent backgrounds, and sticky areas so tall that focused content is hidden, which SC 2.4.11 Focus Not Obscured addresses.
How do I stop a sticky header from hiding the focused cell?
Set scroll-padding on the scroll container equal to the sticky header’s height, and scroll-padding-inline-start equal to the sticky column’s width, so the browser keeps focused elements in the visible remainder.
Should sticky headers be disabled at high zoom?
When they would take a large share of the viewport, yes. A media query on viewport height releases stickiness on very short viewports, which is what 400 percent zoom produces on most screens.
Can I make the first column sticky in a table that uses rowspan?
Yes, but spanning row headers stick as one tall block, which can cover a lot of the scroll area. Test the specific table, and consider repeating the label per row instead of spanning.
Related
Permalink to "Related"- Keyboard-scrollable containers — the scroll context stickiness needs
- Frozen columns and reading order — freezing columns in interactive grids
- Focus indicator contrast auditing — checking focus under sticky areas