web3icons

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.

More in Accessibility

All guides