Designing Focus Indicators for Dense Data Grids

Permalink to "Designing Focus Indicators for Dense Data Grids"

In a data grid, the focus indicator is how keyboard users know which cell they are on. Grids are also where focus indicators most often fail: compact rows leave no room for an outline outside the cell, neighbouring cells paint over it, the selection highlight uses the same colour, sticky headers cover the focused row, and a subtle 1px border that looked fine in a design review disappears on a laptop screen in sunlight.

This page designs a cell focus indicator that survives density, selection, stickiness and themes. It belongs to focus indicator contrast auditing, and the measurement techniques are in auditing focus indicator contrast for WCAG 2.4.11.

Spec reference

Permalink to "Spec reference"
  • SC 2.4.7 Focus Visible (AA) — a keyboard focus indicator is visible.
  • SC 2.4.11 Focus Not Obscured (Minimum) (AA, WCAG 2.2) — the focused component is not entirely hidden by author-created content, such as sticky headers.
  • SC 2.4.13 Focus Appearance (AAA, WCAG 2.2) — the indicator is at least as large as a 2 CSS pixel thick perimeter of the component and has 3:1 contrast between focused and unfocused states. Not required at AA, but a practical design target for grids.
  • SC 1.4.11 Non-text Contrast (AA) — visual information required to identify states, including focus, has 3:1 against adjacent colours.

In a grid, “adjacent colours” include the cell background, the selected-cell background, grid lines and neighbouring cells — not only the page background.

Focus, selection and both in a dense grid Mock grid showing a focused cell with an inset ring, a selected range with an edge bar, and a cell that is both focused and selected. Focus, selection and both in a dense gridRegionQ1Q2Q3North1201135128South981012110West77809131Focused only: 2px inset ring in the focuscolour, 3:1 against the cell and grid lines2Selected only: tint plus a 3px left edge bar— never the focus colour3Focused and selected: the ring drawn overthe tint, still 3:1 against it
Focus is a thick inset ring; selection is a tint plus an edge bar — distinct even where they overlap.

When to use an inset ring — and when outside outlines work

Permalink to "When to use an inset ring — and when outside outlines work"

Use an inset indicator (outline-offset: -2px or an inset box-shadow with a solid fallback) for cells in grids with no gap between cells. Outlines drawn outside the cell are painted under neighbouring cells in many layouts, especially with sticky columns, and end up visible on only two sides.

Outside outlines work for controls with space around them — the grid’s toolbar buttons, the column header menu buttons — and for the grid container itself when it receives focus.

The misapplication to name is using the brand’s selection colour for focus as well. When a user moves through a selected range, they cannot tell which cell is focused — the whole range looks focused.

Annotated code example

Permalink to "Annotated code example"
:root {
  --cell-bg: #ffffff;
  --grid-line: #dde8e1;
  --focus: #155c38;                 /* 8.0:1 on white — well above 3:1 */
  --selected-bg: #d6f9e8;           /* tint: the ring must still reach 3:1 on this */
  --selected-edge: #219150;
}
[data-theme="dark"] {
  --cell-bg: #141d18;
  --grid-line: #2c3a32;
  --focus: #6ddfa0;
  --selected-bg: #173625;
  --selected-edge: #3ec97a;
}

/* SC 2.4.7 + 2.4.13 target: 2px perimeter, inside the cell so neighbours cannot clip it */
[role="gridcell"]:focus-visible,
[role="columnheader"]:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* Selection: tint + edge bar, never the focus colour (SC 1.4.1, 1.4.11) */
[role="gridcell"][aria-selected="true"] {
  background: var(--selected-bg);
  box-shadow: inset 3px 0 0 var(--selected-edge);
}

/* SC 2.4.11: keep focused cells clear of sticky header and frozen column */
.grid-scroll {
  scroll-padding-block-start: var(--sticky-header-h, 2.5rem);
  scroll-padding-inline-start: var(--frozen-col-w, 10rem);
}

/* Forced colours: box-shadow disappears; outline survives with a system colour */
@media (forced-colors: active) {
  [role="gridcell"]:focus-visible { outline-color: Highlight; }
  [role="gridcell"][aria-selected="true"] { outline: 1px solid SelectedItem; outline-offset: -4px; }
}

The ring uses outline rather than box-shadow so it survives forced colours mode, where shadows are removed. Selection uses box-shadow for the edge bar because in forced colours the media query replaces it with an outline in SelectedItem.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Situation Expected visual Failure indicator
Arrow to a cell 2px inset ring, all four sides visible Ring clipped on right and bottom
Arrow within a selected range Ring distinct from the tint Cannot tell focused from selected
Arrow to a row under the sticky header Row scrolls into full view Row hidden under header (SC 2.4.11)
Dark theme Ring in the lighter focus colour Dark ring on dark cells
Forced colours Ring in Highlight No ring (box-shadow removed)
200% zoom Ring scales; still 2 CSS px Hairline ring at 1 device pixel
Focus ring contrast against three adjacent colours Bar chart of the contrast of the light-theme focus colour against the white cell background, the selected-cell tint and the grid line colour. Focus ring contrast against three adjacent coloursvs cell background #ffffff8.0:1vs selected tint #d6f9e87.1:1vs grid line #dde8e16.4:1
Computed with the relative luminance formula: the ring clears 3:1 against every colour it can sit next to.

Integration context

Permalink to "Integration context"

Automating the check on every build — screenshotting focused cells in both themes and measuring the ring — is covered in automating focus indicator screenshots with Playwright. Sticky headers and frozen columns, the main cause of obscured focus in grids, are covered in sticky table headers and first columns.

Selection design — tint plus edge bar — follows the selection model in aria-selected on cells versus rows.

Outside outline versus inset ring in a grid Comparison of a focus outline drawn outside a grid cell against an inset ring drawn inside it, in a grid with no gaps between cells. Outside outline versus inset ring in a grid✗ Outside outlinePainted under neighbouring cellsVisible on two sides, or noneClipped by frozen column edgesLooks fine in isolated component demos✓ Inset ringDrawn inside the focused cellAll four sides always visibleUnaffected by neighbours and stackingSurvives forced colours as an outline
In gapless grids, inside is the only place a ring is guaranteed to be fully visible.

Gotchas

Permalink to "Gotchas"

Contrast against the tint. A ring that passes against white can fail against the selected tint. Measure against every background the focused cell can have.

Row focus versus cell focus. Grids with row-level focus need the ring around the whole row, which is easiest with an outline on the row element and outline-offset: -2px.

Hover styles. Hover highlights that look like focus confuse keyboard users watching the screen. Make hover lighter and clearly different from the focus ring.

Design system notes

Permalink to "Design system notes"

Define grid focus and selection as a small token set — --focus, --selected-bg, --selected-edge — per theme, with measured ratios against the cell background, the selected background and the grid line recorded in the token docs. Grid components apply them through :focus-visible and [aria-selected] selectors only.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Why do focus outlines disappear in data grids?

Because cells sit edge to edge, and an outline drawn outside a cell is painted under its neighbours. Draw the indicator inside the cell with a negative outline offset.

Can the focus indicator use the same colour as selection?

It should not. Users moving through a selected range need to see which cell is focused. Use a ring for focus and a tint with an edge bar for selection.

What does SC 2.4.11 mean for grids with sticky headers?

A focused cell must not be completely hidden by the sticky header or a frozen column. Add scroll padding so the browser scrolls focused cells into the visible area.

Is SC 2.4.13 Focus Appearance required?

It is Level AAA, so not required for AA conformance, but its 2px perimeter and 3:1 change targets are a good, measurable design standard for grid focus indicators.

Permalink to "Related"

← Back to Focus Indicator Contrast Auditing