Directional focus navigation (using arrow keys or D-Pads) enables keyboard-only and alternative-input users to navigate interactive items based on their visual 2D layout. It is highly beneficial for spatial user interfaces (such as media rails, dashboard panels, or nested catalogs), but it must be implemented carefully to avoid breaking native browser behaviors like viewport scrolling.
This guide covers building performant, accessible 2D directional navigation inside composite widgets.
Core Architectural Principles
When implementing custom arrow-key spatial focus, adhere to these foundational rules:
- Limit to composite widgets: Do not override arrow keys on normal document text flow or simple vertical list layouts where default sequential Tab navigation is expected. See Accessibility Coding Guidelines for general document flow guidance.
- Preserve native boundary scrolling: If there is no focusable element in the pressed direction, do not prevent default browser behavior. Let the event bubble so the browser can scroll the viewport natively. Overriding arrow keys unconditionally breaks accessibility (WCAG 1.4.10).
- Ensure focus visibility and alignment: When focus changes, ensure the newly focused element is scrolled into view (e.g., using
scrollIntoView({ block: 'nearest', inline: 'nearest' })) so that it remains fully visible. - Coordinate with the layout system: Determine focus candidate positions from live bounding geometries (
getBoundingClientRect()) while handling each keypress. Avoid assuming that DOM order or a fixed grid structure represents the visual layout. See CSS Layouts and Responsive Design for modern layout recommendations.
Accessible Focus Management
The spatial container must expose a single tab stop to sequential Page Tab navigation. Inside the widget, manage keyboard focus using the Roving Tabindex Pattern:
- The active item has
tabindex="0". - All inactive items have
tabindex="-1". - When navigating, programmatically move focus by shifting the
tabindex="0"attribute and callingfocus().
Visual Candidate Selection
In asymmetrical, wrapped, or grid layouts, index-based mapping fails. Instead, derive candidates from the live visual geometry for the current keypress:
- Candidates: Use the focusable descendants of the widget container that pass
checkVisibility(). Hidden elements can have a zero-sized rect at(0, 0)and must not become accidentaluporlefttargets. - Geometry: Read
getBoundingClientRect()for the focused element and every candidate while handling the keypress. Do not cache these rects globally: layout can change for many reasons, and a small number of geometry reads is cheap when handling a user-paced key event. - Direction filter: A candidate is in the pressed direction only when its near edge is at or beyond the current element’s far edge on that axis, allowing a small tolerance for sub-pixel rounding. For example, a candidate for
rightmust havecandidate.left >= current.right - tolerance. Do not compare centres to determine whether a candidate is in the direction. - Aligned candidates first: Among candidates that pass the direction filter, prefer candidates whose extent on the perpendicular axis overlaps the current element’s extent. Choose the smallest gap on the navigation axis, breaking ties with the smallest perpendicular centre offset. This keeps navigation in the adjacent visual row or column even when an item is unusually wide.
- Fallback scoring: If no candidate overlaps on the perpendicular axis, score the remaining candidates as
gap + w * perpendicularCentreOffsetand choose the minimum. Use a weightw >= 2as a starting point, then tune it for the layout; a larger weight favours candidates that are more closely aligned. - No candidate: Return nothing. The event handler must not call
preventDefault(), allowing the browser to scroll natively.
The exact implementation can vary, but keep the direction test edge-based and apply the overlap preference before the weighted fallback. This is the crux of the algorithm; avoid presenting a cache or a fixed weight as universally correct.
Keypress Integration & Scroll Cooperation
Your event router must prevent standard viewport scrolling only when a valid spatial focus transition occurs:
- Do not intercept the event when a modifier key is held, when
e.defaultPreventedis already true, or when the innermost event target consumes arrow keys itself. Usee.composedPath()[0]rather than onlye.target, becausee.targetcan be retargeted to a shadow host. A practical editable/control check istarget.matches(':read-write, select, input[type="range"], [role="slider"]'); closed shadow roots cannot be inspected. - Scope the listener to the widget container, so multiple spatial widgets can coexist on a page.
- Act only when the focused element is itself a candidate. If items contain their own focusable controls, either make those controls candidates too or leave them alone; do not resolve a parent with
closest(). - When a candidate is found, call
preventDefault(), update the rovingtabindex, callfocus(), and usescrollIntoView({ block: 'nearest', inline: 'nearest' }). - When no candidate is found, return without calling
preventDefault()so native scrolling continues.
Avoid adding single-character shortcuts such as h/j/k/l or W/A/S/D for spatial navigation. Arrow keys are not character keys, and preserving native scrolling when no candidate exists avoids unnecessarily blocking viewport movement.