Keyboard access and focus management.
A keyboard user has one cursor, the focus, and two ways to move it. Tab hops from widget to widget; the arrow keys walk around inside the widget they are in. Your job is to make both movements predictable, keep the cursor visible, and when the interface changes under it, put it somewhere sensible instead of letting it fall back to the top of the page.
Builds on Semantics and the accessibility tree.
The idea.
A keyboard user holds one cursor, the focus. Tab moves it between widgets, the arrows move it within one, and the interface must never drop it.
Picture a Railmoor traveller who books with the keyboard alone. She might have a tremor that makes a trackpad miserable, she might be blind and driving a screen reader, or she might be standing at a station kiosk using the tactile arrow pad because the touchscreen is out of her reach. None of them can point. Each of them has exactly one position on the page, the focused element, and every action they take starts from it.
Two conventions make that single position workable, and they are shared by every desktop platform. Tab and Shift+Tab move from one widget to the next in a fixed order, so the search form is seven presses from top to bottom whatever it contains. Inside a widget that holds many items, such as a list of stations, a month of days or a coach of 32 seats, the arrow keys take over and move between the items, so crossing the seat map costs one Tab, not thirty-two.
The third part is a rule rather than a key. Whenever the interface changes, the code must decide where focus goes: into a dialog that just opened, back to the button that opened it, to the next passenger after one is removed, to the heading of the page the router just drew. If the code decides nothing, focus falls back to the document body: a screen reader announces nothing useful, and where the next Tab lands is up to the browser, sometimes near the old spot, sometimes back at the top of the page. Keyboard access is mostly the discipline of never leaving that to chance.
Railmoor's journey search, one tab stop per widget
The first Tab lands on From. The order is the order the fields appear in the HTML, which is also the order they appear on screen.
Tab 1. Form “Plan your journey”. Fields: From = “Kellbridge Central” (focused); To = “Station name”; Outbound date = “Tue 3 Nov”; Passengers = “1 adult”; Railcard = “None” [▾]. Button: “Find trains”. Secondary button: “Journey options ▾”.
- From: combobox, 1 stop2Station combobox
- To: combobox, 1 stop2Station combobox
- Date: opens a picker dialog3Date picker dialog
- Passengers: 1 stop
- Railcard: native select, 1 stop
- Find trains: 1 stop1Journey search form
- Journey options: menu button, last stop
The two movements on Railmoor's journey
| Widget | Tab stops | Moving inside it |
|---|---|---|
| From / To station | 1 each | Type to filter; Down and Up move the highlighted station; Enter accepts; Escape closes the list |
| Date picker | 1 (the focused day) | Arrows move a day or a week; Page Up and Page Down a month; Enter picks; Escape cancels |
| Railcard (native select) | 1 | The browser's own keys: nothing for Railmoor to write |
| Journey options menu | 1 (the button) | Enter or Down opens and lands on the first action; Up and Down move; Escape closes |
| Seat map (coach C) | 1 (the current seat) | Arrows move between seats; Home and End go to the ends of a row |
| Booking summary | its own set, while open | Tab never reaches the page behind (a native dialog lets it pass through the browser toolbar and back); Escape closes it |
How it works.
The tab sequence comes from the DOM and tabindex; composite widgets pick one of two ways to track their active item; dialogs, route changes and deletions each have a focus lifecycle you write down.
The tab sequence and tabindex
What tabindex does
| Value | Reachable by Tab | Focusable by script | Use it for |
|---|---|---|---|
| (none) on button, a, input, select | Yes, in source order | Yes | Everything interactive. Native controls come with focus and keyboard activation built in |
| 0 | Yes, in source order | Yes | A custom widget's one tab stop: the grid's current seat, a listbox container |
| -1 | No | Yes | Targets you move focus to in code: a page h1 after a route change, a dialog heading, the seats that are not current |
| 1 and up | Yes, before every 0, lowest first | Yes | Nothing. It pulls elements out of reading order and every later edit makes it worse |
The browser builds the tab sequence by walking the DOM in order and collecting everything focusable. That makes the source order the contract: if CSS grid or flex order places the To field visually before From while the HTML has them the other way round, Tab visits them in the HTML order and the focus appears to jump backwards (WCAG 2.4.3 Focus Order). Fix the DOM, not the tabindex.
Two more criteria frame everything else in this topic. Everything a pointer can do must also be possible from a keyboard (2.1.1 Keyboard), and wherever focus can go in, it must be able to come back out with the keyboard alone (2.1.2 No Keyboard Trap). Both are level A, so a single hover-only menu or an embedded map that swallows Tab fails Railmoor's AA target outright.
A skip link is the one sanctioned exception to walking everything. It is the first focusable element on the page, visually hidden until it receives focus, and it jumps to the main content, so a traveller does not tab through the header on every page (2.4.1 Bypass Blocks).
Focus must also be seen. 2.4.7 Focus Visible (AA) asks for an indicator on every stop, and :focus-visible lets you draw it where it helps: the browser matches it on keyboard focus and on text inputs, but usually not when a mouse click focuses a button, so removing the default outline without a :focus-visible replacement fails keyboard users. WCAG 2.2 added 2.4.11 Focus Not Obscured (Minimum, AA): the focused element must not be entirely hidden by author content, which in practice means sticky headers, cookie banners and Railmoor's basket bar. Partly covered passes at AA; the AAA version, 2.4.12, allows no overlap at all.
A modal dialog that keeps Tab away from the page is not a keyboard trap: 2.1.2 only requires a standard way out, and Escape plus the dialog's own buttons provide it. A trap is an embedded widget, such as a map or a rich-text editor, that captures Tab and offers no documented key to leave.
Composite widgets: roving tabindex or aria-activedescendant
A composite widget shows one tab stop to the outside and handles the arrow keys inside. There are two ways to know which inner item is current.
Roving tabindex moves real focus. The current item carries tabindex=0 and every other item tabindex=-1; on an arrow key the code swaps the two values and calls focus() on the new item. Because the item really has focus, the browser scrolls it into view and draws the focus ring, :focus-visible styles apply, and when the traveller tabs away and back she lands on the same item.
aria-activedescendant leaves focus where it is. The container (or, for a combobox, the text input) keeps DOM focus, and the attribute holds the id of the item assistive technology should treat as current. Nothing about the item changes for the browser, so the code must draw the highlight itself and scroll the item into view itself. What it buys is that the input keeps the caret: the traveller can type 'Ash', press Down, and type another letter without focus ever leaving the text box. That is why the combobox pattern requires it.
The From field, key by key
Tab lands in the input. The recent stations show, but none is active yet, so Enter would do nothing surprising.
Focus. Search box: empty, placeholder “From (station name)”. Suggestions open: 2 options. “Recent stations”: (recent) “Kellbridge Central”; (recent) “Otterfold”. Note on input: role="combobox" aria-expanded="true"
- Combobox input: keeps DOM focus2Station combobox
- Listbox: aria-controls target
The two techniques side by side
| Roving tabindex | aria-activedescendant | |
|---|---|---|
| Where DOM focus is | On the current item | On the container or input, all the time |
| Scrolling the item into view | Browser does it on focus() | Your code (scrollIntoView with block nearest) |
| Focus indicator | :focus-visible on the item | Your own class on the item the attribute names |
| Typing while navigating | Keys go to the item, not a text box | The input keeps the caret, so typing carries on |
| Where the item must live | Anywhere focusable | Inside the container, or in the popup the combobox controls |
| Railmoor uses it for | Seat map, date grid, Journey options menu | From and To comboboxes |
The Journey options menu button
The button says it opens a menu and that the menu is closed. It is one stop in the tab sequence like any button.
Focused, closed. Form “Plan your journey”. Fields: From = “Ashcombe Junction”; To = “Kellbridge Central”; Outbound date = “Wed 11 Nov”; Passengers = “1 adult”; Railcard = “None” [▾]. Button: “Find trains”. Secondary button: “Journey options ▾”. Note on secondary: aria-haspopup="menu" aria-expanded="false"
- Menu button1Journey search form
- role="menu": roving focus
The modal dialog's focus lifecycle
Where focus is while the booking summary opens and closes
States of5Booking-summary dialog
The lifecycle every modal needs, whether the browser or your code runs it. Notice the two exits back to Closed: one that returns focus to Review booking, and one for when that button no longer exists.
| From → To | Event | Guard | Action | Actor |
|---|---|---|---|---|
| Closed → Opening | activate Review booking | remember opener | ||
| Opening → Open | showModal() | focus heading | browser | |
| Open → Open | Tab / Shift+Tab | stay out of the inert page | ||
| Open → Closing | Escape | browser | ||
| Open → Closing | Change seats / Continue | |||
| Closing → Focus on the opener | close | opener still in DOM | browser | |
| Closing → Focus on a fallback target | close | opener removed | focus page heading | |
| Closing → Focus on the body | close | opener removed, no handler |
- Closedstart
- Focus somewhere on the page
- Opening
- Opener remembered
- Open
- Focus inside, page inert
- Closing
- Page no longer inert
- Focus on the openerend
- Focus on a fallback targetend
- Focus on the bodyerror
- Nobody chose a target
Twelve seconds with the booking summary
Scenario 1 of 3: As described.
Timeline as a list
Twelve seconds with the booking summary: 3 lanes, from 0 s to 12 s.
- 0–2 s · Focus (activeElement) · Review booking
- 0–2 s · Seat page behind · interactive
- 2–5 s · Focus (activeElement) · "Your booking" h2
- 2–9 s · Seat page behind · inert
- 2–9 s · Booking summary · open (top layer)
- 2 s · Booking summary · showModal()
- 5–9 s · Focus (activeElement) · Continue to payment
- 5 s · Focus (activeElement) · Tab (tick)
- 9–12 s · Focus (activeElement) · Review booking
- 9–12 s · Seat page behind · interactive
- 9 s · Booking summary · Escape
- 9 s · Focus (activeElement) · focus restored (ok)
What native dialog does for you
Calling showModal() on a dialog element runs most of the lifecycle. The dialog goes into the top layer, above every z-index on the page (why that works is in the stacking-contexts topic). Everything outside it becomes inert: not focusable, not clickable, not found by find-in-page and absent from the accessibility tree. Initial focus goes to the descendant with autofocus, otherwise to the first focusable descendant, otherwise to the dialog itself. Escape closes it by default, and when a modal dialog closes the HTML standard sends focus back to whatever was focused before it opened.
What it cannot know is your workflow. If the opener was removed, or the dialog's action created something new (a second passenger row, say), you pick the target in the close handler. And a hand-built dialog has to do every step itself: remember the opener, set inert on the page wrapper, move focus in, handle Escape, and restore focus afterwards.
Route changes and deletions
Where focus goes when the thing it was on goes away
| What happened | Focus goes to | Why there |
|---|---|---|
| SPA route change (Search to Results) | The new page's h1, given tabindex=-1 | Mirrors a full page load: the traveller starts reading the new page from its title, and a screen reader reads the heading |
| An item deleted from a list | The next item; if it was the last, the previous one; if the list is now empty, the list heading | Keeps her place in the list instead of sending her to the top |
| A dialog or menu closed | The control that opened it | She resumes exactly where she was |
| That control no longer exists | The nearest stable heading or container, tabindex=-1 | A deliberate choice beats the body |
| A step completed (Seats to Payment) | The next step's heading | The old step's controls are gone; the new heading says where she is |
| Content loaded below (more trains) | Nowhere: focus stays put | Moving focus on a background update yanks her away; announce it instead |
A full page load resets focus to the top of the new document, so a screen reader starts at the beginning. A client-side route change does not: the old link that was clicked is usually removed, focus drops to the body, and nothing announces that anything changed. Frameworks differ in what they do here, so decide it in the app shell. The Navigation API's intercept() resets focus after the transition by default (to the first autofocus element, else the body) and lets you take over with focusReset: 'manual'. It has been Baseline only since January 2026: Chromium has had it since version 102, so the kiosks are fine, but Safari before 26.2 and Firefox before 147 lack it, and the router hook below does the same job by hand for them.
In practice.
Railmoor's journey from date to seat to summary, with the code that keeps focus in hand, and how to say it in a frontend system design interview.
Picking a date from the keyboard
Enter on the field opens the picker and focus goes to the selected day, today. It is the grid's only tab stop; every other day is tabindex=-1.
Opened. Date picker, a popover under its fields: 1 month (November 2026), weeks start on Monday. Weekday row: Mo Tu We Th Fr Sa Su. Today is November 3, 2026; earlier days are past and can't be picked. Fields: Outbound date = “Tue 3 Nov” (focused). Keyboard focus on November 3, 2026. Note on day:2026-11-03: tabindex=0, aria-label="Tuesday 3 November 2026"
- Opener and return target1Journey search form
- Roving tab stop3Date picker dialog
A roving tab stop across the first four rows of coach C
- 1A, 1 item, Current seat (tabindex=0)
- 1B, 1 item, Free seat: tabindex=-1
- 1C, 1 item, Free seat: tabindex=-1
- 1D, 1 item, Free seat: tabindex=-1
- 2A, 1 item, Free seat: tabindex=-1
- 2B, 1 item, Taken (aria-disabled)
- 2C, 1 item, Free seat: tabindex=-1
- 2D, 1 item, Free seat: tabindex=-1
- 3A, 1 item, Taken (aria-disabled)
- 3B, 1 item, Free seat: tabindex=-1
- 3C, 1 item, Free seat: tabindex=-1
- 3D, 1 item, Free seat: tabindex=-1
- 4A, 1 item, Free seat: tabindex=-1
- 4B, 1 item, Free seat: tabindex=-1
- 4C, 1 item, Free seat: tabindex=-1
- 4D, 1 item, Free seat: tabindex=-1
- Current seat (tabindex=0)
- Free seat: tabindex=-1
- Taken (aria-disabled)
- Current seat, taken
As it starts. 6 steps follow.
Tab presses from the top of the seat page to Continue
Data
| Seat map built as | Tab presses |
|---|---|
| Every seat tabbable | 38 |
| Roving tabindex | 7 |
| Roving plus skip link | 4 |
The seat grid's roving tabindex
// Each seat is a <button role="gridcell"> inside role="row" inside role="grid".
// Exactly one seat has tabindex="0"; the rest have tabindex="-1".
const STEP: Record<string, [row: number, col: number]> = {
ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1],
};
export function wireSeatGrid(grid: HTMLElement) {
const rows = [...grid.querySelectorAll<HTMLElement>('[role="row"]')];
const cells = rows.map((r) => [...r.querySelectorAll<HTMLButtonElement>('[role="gridcell"]')]);
grid.addEventListener('keydown', (e) => {
const seat = (e.target as HTMLElement).closest<HTMLButtonElement>('[role="gridcell"]');
if (!seat) return;
const r = cells.findIndex((row) => row.includes(seat));
const c = cells[r].indexOf(seat);
let next: [number, number] | undefined;
if (e.key in STEP) next = [r + STEP[e.key][0], c + STEP[e.key][1]];
else if (e.key === 'Home') next = e.ctrlKey ? [0, 0] : [r, 0];
else if (e.key === 'End') next = e.ctrlKey ? [cells.length - 1, cells.at(-1)!.length - 1] : [r, cells[r].length - 1];
if (!next) return; // Tab, Enter, Space: leave them alone
const target = cells[next[0]]?.[next[1]];
e.preventDefault(); // stop the page scrolling
if (!target) return; // edge of the grid: no wrap
seat.tabIndex = -1;
target.tabIndex = 0;
target.focus(); // the browser scrolls it into view
});
}
// Taken seats keep tabindex=-1 like free ones, carry aria-disabled="true",
// and ignore activation, so they can be reached and heard but not booked.
The booking summary as a native dialog
<button type="button" id="review" aria-haspopup="dialog">Review booking</button>
<dialog id="summary" aria-labelledby="summary-title">
<!-- A heading, not a button, takes first focus: the dialog is mostly text to read. -->
<h2 id="summary-title" tabindex="-1" autofocus>Your booking</h2>
<p>Ashcombe Junction to Kellbridge Central · Wed 11 Nov · 08:42</p>
<p>Coach C, seat 3D · Senior card applied</p>
<form method="dialog">
<button value="seats">Change seats</button>
<button value="pay">Continue to payment</button>
</form>
</dialog>
const dialog = document.querySelector<HTMLDialogElement>('#summary')!;
let opener: HTMLElement | null = null;
document.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('#review');
if (!btn) return;
opener = btn;
dialog.showModal(); // top layer, page inert, focus to the autofocus heading
});
dialog.addEventListener('close', () => {
// The browser has already tried to refocus whatever was focused before.
// If a re-render replaced the opener, that node is detached: pick a target.
if (!opener?.isConnected) {
document.querySelector<HTMLElement>('#seat-page-title')?.focus();
}
if (dialog.returnValue === 'pay') router.go('/booking/payment'); // focus moves with the route
opener = null;
});
Visible focus that sticky bars cannot hide
/* Ring what keyboard users land on, not every mouse click. */
:where(a, button, input, [role="gridcell"], [tabindex="-1"]):focus-visible {
outline: 3px solid var(--rm-focus); /* 3:1 or better against both neighbours */
outline-offset: 2px;
}
/* Headings focused by script are targets, not controls: a quieter ring, never none,
so a sighted keyboard user can still see where focus went. */
:is(h1, h2, h3)[tabindex="-1"]:focus-visible { outline-width: 1px; outline-offset: 4px; }
/* The basket bar is position: sticky at the bottom and 72px tall. Without this,
tabbing to row 8 of coach C scrolls the seat right under the bar (WCAG 2.4.11). */
html {
scroll-padding-bottom: 88px; /* 72px bar + 16px breathing room */
scroll-padding-top: 64px; /* the sticky header */
}
Removing a passenger
Focus is on the Remove button in Tomas's row. Pressing Enter will delete the very element that has focus.
On Remove. List “Passengers”: 3 items. 1. Priya Nair (Adult · Access card) Edit, Remove. 2. Tomas Nair (Adult) Edit, Remove. 3. Ada Nair (Child (5-15)) Edit, Remove. Note on item:p2:count:remove: document.activeElement
- The element being deleted
- Delete handler picks next, previous, heading6Focus manager
Focus after a route change, with and without the Navigation API
function focusPageTitle() {
const h1 = document.querySelector<HTMLElement>('main h1');
if (!h1) return;
h1.tabIndex = -1; // focusable by script, never a Tab stop
h1.focus({ preventScroll: false });
}
if ('navigation' in window) {
// Chromium 102+, Firefox 147+, Safari 26.2+ (Baseline since January 2026).
navigation.addEventListener('navigate', (e) => {
if (!e.canIntercept || e.hashChange || e.downloadRequest) return;
e.intercept({
focusReset: 'manual', // we choose the h1, not the default (autofocus or body)
async handler() {
await renderRoute(new URL(e.destination.url));
focusPageTitle();
},
});
});
} else {
// Older Safari and Firefox: the router's own hook does the same job.
router.afterEach((to, from) => {
if (to.path !== from.path) queueMicrotask(focusPageTitle);
});
}
The virtualised timetable
Railmoor's results page shows a day of departures as a virtualised list, so only the rows near the viewport exist in the DOM. The keyboard risk is specific: if the focused departure scrolls far enough to be recycled, the focused node disappears and focus drops to the body, exactly like a deletion. The fix belongs to virtualisation (keep the focused row rendered even outside the window, and restore focus by item id rather than by node); the full treatment is in Anchoring and accessibility in virtual lists.
Saying it in a frontend system design interview
Railmoor's keyboard review before a release
Trade-offs.
Three choices you make on almost every product, and one you should make before anyone ships a shortcut.
- Pro:Real focus, so the browser scrolls row 8 into view and :focus-visible draws the ring
- Pro:Screen magnifiers follow the focused seat
- Pro:Tabbing away and back returns to the same seat with no extra state
- Con:Two attributes to swap on every arrow key
- Con:The current seat must survive re-renders (keep it by seat id)
The highlight, the scrolling and the ring are all yours to draw; Tools that follow DOM focus, such as some magnifiers, see only the container
- Pro:Top layer, inert background, Escape and focus restore from the browser
- Pro:Nested dialogs close one at a time
- Pro:Supported by every browser Railmoor targets, kiosks included
- Con:Styling the backdrop and open animation takes some care
- Con:Workflow-specific focus targets are still your code
You write the focus move, Escape handling and restore; The dialog must sit outside the wrapper you make inert
The page behind is still in the accessibility tree, so screen reader browse mode walks straight out of the dialog; Every new focusable element in the dialog must be found by a selector list
- Pro:Screen readers read the page title, which confirms the navigation
- Pro:The next Tab continues from the top of the new content, past the header
- Con:Every page needs exactly one h1 in main
Nothing is announced, and where the next Tab starts is up to the browser
Some screen readers read the whole region, which is long on a results page
Before adding keyboard shortcuts
| Shortcut idea | What it collides with | Railmoor's rule |
|---|---|---|
| "s" to focus station search | Screen reader browse mode uses single letters to jump between headings, links and landmarks; speech input users trigger it by dictating words | Allowed only if it can be turned off or remapped, or works only while the widget has focus (WCAG 2.1.4) |
| Ctrl+F for "find a train" | The browser's find in page | Not used; the search field is reachable with the skip link |
| Alt+number to switch coaches | Browser tab switching on some platforms and menu access keys | Arrow keys inside the coach picker instead |
| Escape to clear the whole form | Escape already closes the nearest popup or dialog | Escape only ever closes the innermost open thing |