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.

Sticky via CSS versus a cloned header table Comparison of making real table headers sticky with CSS against cloning the header row into a separate fixed table. Sticky via CSS versus a cloned header table✓ position:sticky on real thOne table, one set of headersHeader associations untouchedColumn widths always matchWorks with scroll-padding for focus✗ Cloned header tableTwo tables in the accessibility treeHeaders read twice, or the clone read aloneWidths drift when columns resizeNeeds aria-hidden and extra script
The clone is a legacy technique from before sticky positioning worked on table cells; it is no longer needed.

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
Sticky parts and the scroll padding that protects focus Mock of a scrolled table with the sticky header row and sticky first column highlighted, and a focused cell kept clear of both by scroll padding. Sticky parts and the scroll padding that protects focusRegionJan1FebMarAprNorth120135128140South29810111096West7780913881Sticky header row: position sticky on realth, opaque background2Sticky first column: row headers stayvisible while scrolling sideways3Focused cell: scroll-padding keeps itclear of both sticky strips
scroll-padding reserves the sticky strips, so the browser scrolls focused cells into the visible remainder.

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.

Share of a 320 px tall viewport taken by sticky areas Bar chart of how much of a 320 pixel tall viewport, typical at 400 percent zoom, is taken by different sticky configurations. Share of a 320 px tall viewport taken by sticky areasSingle-row header14%Header + sticky page bar33%Two-row header + filters +page bar62% — fails 2.4.11 in practice
Past about a third of the viewport, release stickiness — the media query in the example does this automatically.

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.

Permalink to "Related"

← Back to Responsive Data Tables