Keyboard navigation in icon grids
How to make a large icon grid work by keyboard with one tab stop, roving tabindex, arrow keys and Home and End, plus a short React sketch.
6 min read
Put 2,000 token logos on a page as 2,000 buttons and a keyboard user has to press Tab hundreds of times to get past them. The grid is technically keyboard accessible and practically useless. The fix is a pattern from desktop software: the whole grid is a single Tab stop, and the arrow keys move around inside it.
Getting it right takes three decisions and a little code: how focus moves, which ARIA roles describe the grid, and which keys do what. A React sketch you can adapt is further down.
One tab stop, arrows inside
With roving tabindex, exactly one cell in the grid has tabindex="0" and every other cell has tabindex="-1". Tab lands on that one cell, and the next Tab leaves the grid for whatever comes after it. Inside, your key handler moves focus with element.focus() and moves the 0 along with it, so if someone tabs out and back in, they return to the icon they were on.
The alternative is aria-activedescendant: focus stays on the container, and an attribute points at the active cell's id. It works, but you then have to draw the focus indicator and scroll the active cell into view yourself. Moving real focus gets you the browser's own scrolling and :focus-visible styling, which is why roving tabindex is usually the simpler choice for a grid of icons.
Several WCAG criteria are involved: 2.1.1 Keyboard (A), 2.4.3 Focus Order (A), 2.4.7 Focus Visible (AA) and 4.1.2 Name, Role, Value (A).
Choose the semantics first
The right markup depends on what a cell does.
| Pattern | Markup | Fits when | Watch out for |
|---|---|---|---|
| List of links or buttons | ul > li > a |
each icon just opens its page | no "selected" state; the arrow keys are your own addition |
| Listbox | role="listbox" with role="option" children |
you pick one icon to preview, and selection follows focus | options can't contain other interactive controls |
| Grid | role="grid" > role="row" > role="gridcell" |
each cell holds its own controls, such as a download button | needs real row elements, which must be rebuilt whenever the column count changes |
For a picker where moving through icons updates a preview panel, a listbox is the closest match. Screen readers announce each option's name, whether it is selected and, in many cases, its position in the set. If you want a quick download button inside each cell, either move that action into the preview panel or use the full grid pattern from the ARIA Authoring Practices Guide.
A key map people already know
Borrow the keys from the APG grid pattern so the grid behaves like other grids people have used:
| Key | Moves to |
|---|---|
| Tab, Shift+Tab | into the grid (the remembered icon), then out of it |
| Right / Left | next or previous icon, continuing onto the next or previous row |
| Down / Up | the icon in the same column one row below or above |
| Home / End | first or last icon in the current row |
| Ctrl+Home / Ctrl+End | first or last icon in the whole set |
| Enter | opens the selected icon's page, if you have one |
Page Up and Page Down jumping several rows is a nice extra. Don't intercept keys you don't use, and leave modifier combinations alone: Alt+Left is "back" in several browsers.
A React sketch
The grid is plain CSS Grid with as many columns as fit. To move up or down you need the current column count, which you can read from the computed grid-template-columns. Browsers return the resolved track list, such as 96px 96px 96px, so splitting it on spaces gives the count.
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
import { useRef, type KeyboardEvent } from 'react'
type Icon = { key: string; name: string; src: string }
type Props = {
icons: Icon[]
total: number
selectedKey: string
onSelect: (key: string) => void
}
export function IconPicker({ icons, total, selectedKey, onSelect }: Props) {
const listRef = useRef<HTMLUListElement>(null)
const cells = useRef<(HTMLLIElement | null)[]>([])
const selected = Math.max(0, icons.findIndex((i) => i.key === selectedKey))
function columns() {
const list = listRef.current
return list ? getComputedStyle(list).gridTemplateColumns.split(' ').length : 1
}
function moveTo(index: number) {
if (index < 0 || index >= icons.length) return
onSelect(icons[index].key)
const cell = cells.current[index]
cell?.focus({ preventScroll: true })
cell?.scrollIntoView({ block: 'nearest' })
}
function onKeyDown(event: KeyboardEvent<HTMLUListElement>) {
if (event.altKey || event.metaKey) return
const cols = columns()
const rowStart = selected - (selected % cols)
const last = icons.length - 1
const targets: Record<string, number> = {
ArrowRight: selected + 1,
ArrowLeft: selected - 1,
ArrowDown: selected + cols,
ArrowUp: selected - cols,
Home: event.ctrlKey ? 0 : rowStart,
End: event.ctrlKey ? last : Math.min(rowStart + cols - 1, last),
}
if (!(event.key in targets)) return
event.preventDefault()
moveTo(targets[event.key])
}
return (
<ul ref={listRef} role="listbox" aria-label="Icons" className="icon-grid" onKeyDown={onKeyDown}>
{icons.map((icon, i) => (
<li
key={icon.key}
ref={(el) => {
cells.current[i] = el
}}
role="option"
aria-selected={i === selected}
aria-posinset={i + 1}
aria-setsize={total}
tabIndex={i === selected ? 0 : -1}
onClick={() => moveTo(i)}
>
<img src={icon.src} alt="" width={32} height={32} />
<span>{icon.name}</span>
</li>
))}
</ul>
)
}
preventDefault stops the arrow keys from also scrolling the page. Focusing with preventScroll and then calling scrollIntoView lets you control where the cell lands, which matters under sticky headers (see below). Down and Up from the last or first row do nothing instead of jumping to a strange cell, because moveTo ignores out-of-range indexes. If selectedKey disappears after a search, the first result becomes the tab stop, so the grid always keeps one. The logo image has an empty alt because the visible name already labels the option; alt text for logos explains when that is the right call.
Announcing selection without extra noise
Because focus moves to each option, a screen reader announces it on its own: the name, the selected state and often "12 of 96". You don't need a live region for every arrow press, and adding one usually means hearing everything twice.
Two things are worth announcing separately. The first is the result count after a search, such as "48 icons", in a polite role="status" region updated after typing pauses, not on every keystroke. The second is the true size of the set. If you render 96 icons at a time and load more as people scroll, the screen reader will report "of 96" unless you set aria-setsize to the full result count, as the sketch does.
Edge cases that break grids
Lazy loading. End and Ctrl+End can only reach icons that exist in the DOM. Either load the rest when someone presses Ctrl+End, or accept that it goes to the last loaded icon. If you virtualize the list, never unmount the focused cell, or focus falls back to the page body and the user loses their place. Rendering thousands of icons goes into the trade-offs.
Sticky toolbars. Category tabs or a search bar stuck to the top of the viewport can cover the focused cell as the grid scrolls, which runs into 2.4.11 Focus Not Obscured (Minimum) (AA). Setting scroll-margin-top on cells, or scroll-padding-top on the scroll container, to the toolbar's height keeps scrollIntoView from parking the cell under it. Focus not obscured has more on this.
Visible focus. Logos come in every color, so a colored focus ring will vanish on some of them. Draw the indicator on the cell's edge rather than over the artwork, and check it against the cell background; designing visible focus styles covers the 3:1 contrast that applies.
The search box. Arrow keys inside a text field belong to the text field. A common and useful handoff is to let Down Arrow or Enter in the search box move focus to the first result, so typing a ticker and pressing Down lands you on the logo. You can try this kind of flow in the icon library.