Many people never use a mouse: some work from the keyboard, some through a switch device or a screen reader, some only temporarily, with a broken arm. For them focus is the cursor: the invisible point where the browser sends keystrokes, since the focused element receives Enter or Space. On a product page that means the size picker, the add-to-cart button, the gallery, the review links. A node unreachable from the keyboard does not exist for part of the audience.
The naive conclusion is reasonable: since the browser walks elements on Tab by itself, you only place the interactive nodes. True, but only while the markup stays semantic. Tab visits only the elements the spec makes focusable: links with href, buttons, inputs, select. A native button brings focusability, a response to Enter and Space, a role for the screen reader - all for free.
This breaks on homemade widgets. The designer asks for a pretty button, the developer hangs onclick on a div. Visually identical, but a div is not in the tab order, does not react to the keyboard, and has no role. The mouse adds the item to the cart, the keyboard does not. The same with a dropdown built on a span or a gallery of bare images: the mouse works, the keyboard is silent.
The second layer of the problem is visibility: a reachable element is useless if it is unclear where focus is. The browser draws an indicator - a ring around the active element - but designers often strip it with outline: none for looks. The right tool is :focus-visible: the ring shows during keyboard navigation and hides on a mouse click, defusing the old conflict between aesthetics and access. WCAG 2.2 requires the indicator to be visible (2.4.7) and not hidden behind sticky headers (2.4.11).
The tab order must match the visual and logical reading order (2.4.3): jumps of focus disorient. Govern it through DOM order rather than positive tabindex values, which break the natural sequence. A separate pain is a menu and header before the content: tabbing through them every time is exhausting. The fix is a skip link, hidden until it receives focus and leading to the start of the content.
On a classic site, following a link reloads the page: the browser resets focus and announces the new title itself. In a single-page application, on which the store is built, there is no navigation - only part of the DOM changes, and the browser stays silent. After a route change you must manually update the title, announce the new page, and move focus to the meaningful start - to the card's h1, not leave it on a link that has vanished.
A genre of its own is the modal window: the size picker opens on top of the page. By the WAI-ARIA APG pattern focus must enter the window, must not leave it while it is open, must close on Escape, and return to the button that opened it. This is the focus trap - mandatory inside an open dialog and forbidden everywhere else (2.1.2). The window is given role dialog, aria-modal true, and tied to its heading through aria-labelledby.
The price is discipline. Manual focus management is easy to forget during a refactor, and automated tests rarely catch a lost focus. The temptation is to sprinkle tabindex and aria over everything, but extra focus stops and roles harm as much as their absence: the screen reader reads garbage. It is cheaper to stay with native elements and add manual management only where there is no way around it - in dialogs and during client-side navigation.
A typical failure: the user opens the size window, Tab carries focus behind the dialog onto the page buttons beneath it. Visually the window is on top, but focus wanders across the invisible part, Enter fires in the wrong place, Escape does not close. A keyboard person is trapped in an interface that is flawless for the mouse. That is why focus is checked as strictly as layout: walk the whole purchase path with one keyboard.
:focus-visible {
outline: 3px solid CanvasText;
outline-offset: 3px;
}
@media (forced-colors: active) {
.button { border: 1px solid ButtonText; }
}<a class="skip-link" href="#main">
Skip to content
</a>const dialog = document.querySelector('#size-dialog');
const trigger = document.querySelector('#choose-size');
trigger.addEventListener('click', () => {
dialog.showModal(); // native focus trap + Escape to close
});
dialog.addEventListener('close', () => {
trigger.focus(); // return focus to the opener
});