Accessibility and compatibilityKeyboard access and focus management

100%

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.

Intermediate34 minUpdated 2 Oct 2026

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 ▾”.

  1. From: combobox, 1 stop2Station combobox
  2. To: combobox, 1 stop2Station combobox
  3. Date: opens a picker dialog3Date picker dialog
  4. Passengers: 1 stop
  5. Railcard: native select, 1 stop
  6. Find trains: 1 stop1Journey search form
  7. Journey options: menu button, last stop

The two movements on Railmoor's journey

WidgetTab stopsMoving inside it
From / To station1 eachType to filter; Down and Up move the highlighted station; Enter accepts; Escape closes the list
Date picker1 (the focused day)Arrows move a day or a week; Page Up and Page Down a month; Enter picks; Escape cancels
Railcard (native select)1The browser's own keys: nothing for Railmoor to write
Journey options menu1 (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 summaryits own set, while openTab never reaches the page behind (a native dialog lets it pass through the browser toolbar and back); Escape closes it
Was this section helpful?

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

ValueReachable by TabFocusable by scriptUse it for
(none) on button, a, input, selectYes, in source orderYesEverything interactive. Native controls come with focus and keyboard activation built in
0Yes, in source orderYesA custom widget's one tab stop: the grid's current seat, a listbox container
-1NoYesTargets you move focus to in code: a page h1 after a route change, a dialog heading, the seats that are not current
1 and upYes, before every 0, lowest firstYesNothing. 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"

  1. Combobox input: keeps DOM focus2Station combobox
  2. Listbox: aria-controls target

The two techniques side by side

Roving tabindexaria-activedescendant
Where DOM focus isOn the current itemOn the container or input, all the time
Scrolling the item into viewBrowser does it on focus()Your code (scrollIntoView with block nearest)
Focus indicator:focus-visible on the itemYour own class on the item the attribute names
Typing while navigatingKeys go to the item, not a text boxThe input keeps the caret, so typing carries on
Where the item must liveAnywhere focusableInside the container, or in the popup the combobox controls
Railmoor uses it forSeat map, date grid, Journey options menuFrom and To comboboxes

The dropdown case: a menu button

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"

  1. Menu button1Journey search form
  2. role="menu": roving focus

The modal dialog's focus lifecycle

Where focus is while the booking summary opens and closes

States of5Booking-summary dialog

Where focus is while the booking summary opens and closes. 7 states, 8 transitions. The table below lists them.
Where focus is while the booking summary opens and closesThe states of Booking-summary dialog. 7 states, 8 transitions. The table below lists them.

activate Review booking / remember opener

showModal() / focus heading

Tab / Shift+Tab / stay out of the inert page

Escape
Change seats / Continue

close [opener still in DOM]

close [opener removed] / focus page heading

close [opener removed, no handler]

Closed

Opening

Open

Closing

Focus on the opener

Focus on a fallback target

Focus on the body

5 steps. The ordinary path, which native dialog handles for you.

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.

Transitions of Where focus is while the booking summary opens and closes
From → ToEventGuardActionActor
Closed → Openingactivate Review bookingremember opener
Opening → OpenshowModal()focus headingbrowser
Open → OpenTab / Shift+Tabstay out of the inert page
Open → ClosingEscapebrowser
Open → ClosingChange seats / Continue
Closing → Focus on the openercloseopener still in DOMbrowser
Closing → Focus on a fallback targetcloseopener removedfocus page heading
Closing → Focus on the bodycloseopener 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.

  1. 0–2 s · Focus (activeElement) · Review booking
  2. 0–2 s · Seat page behind · interactive
  3. 2–5 s · Focus (activeElement) · "Your booking" h2
  4. 2–9 s · Seat page behind · inert
  5. 2–9 s · Booking summary · open (top layer)
  6. 2 s · Booking summary · showModal()
  7. 5–9 s · Focus (activeElement) · Continue to payment
  8. 5 s · Focus (activeElement) · Tab (tick)
  9. 9–12 s · Focus (activeElement) · Review booking
  10. 9–12 s · Seat page behind · interactive
  11. 9 s · Booking summary · Escape
  12. 9 s · Focus (activeElement) · focus restored (ok)
One traveller's session on the seat page, in seconds. Read down a column: where focus is, whether the page behind can take focus, and whether the dialog is open. The tabs show a hand-built overlay without inert, and an opener that was replaced while the dialog was open.

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 happenedFocus goes toWhy there
SPA route change (Search to Results)The new page's h1, given tabindex=-1Mirrors 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 listThe next item; if it was the last, the previous one; if the list is now empty, the list headingKeeps her place in the list instead of sending her to the top
A dialog or menu closedThe control that opened itShe resumes exactly where she was
That control no longer existsThe nearest stable heading or container, tabindex=-1A deliberate choice beats the body
A step completed (Seats to Payment)The next step's headingThe old step's controls are gone; the new heading says where she is
Content loaded below (more trains)Nowhere: focus stays putMoving 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.

Was this section helpful?

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"

  1. Opener and return target1Journey search form
  2. Roving tab stop3Date picker dialog

A roving tab stop across the first four rows of coach C

Row 14Seat-map grid
  1. 1A, 1 item, Current seat (tabindex=0)
  2. 1B, 1 item, Free seat: tabindex=-1
  3. 1C, 1 item, Free seat: tabindex=-1
  4. 1D, 1 item, Free seat: tabindex=-1
Row 2
  1. 2A, 1 item, Free seat: tabindex=-1
  2. 2B, 1 item, Taken (aria-disabled)
  3. 2C, 1 item, Free seat: tabindex=-1
  4. 2D, 1 item, Free seat: tabindex=-1
Row 3
  1. 3A, 1 item, Taken (aria-disabled)
  2. 3B, 1 item, Free seat: tabindex=-1
  3. 3C, 1 item, Free seat: tabindex=-1
  4. 3D, 1 item, Free seat: tabindex=-1
Row 4
  1. 4A, 1 item, Free seat: tabindex=-1
  2. 4B, 1 item, Free seat: tabindex=-1
  3. 4C, 1 item, Free seat: tabindex=-1
  4. 4D, 1 item, Free seat: tabindex=-1
  • Current seat (tabindex=0)
  • Free seat: tabindex=-1
  • Taken (aria-disabled)
  • Current seat, taken
Start

As it starts. 6 steps follow.

Each row is a seat row, each cell a seat. Exactly one seat holds tabindex=0 at any moment. Step through the keys: taken seats stay focusable (with aria-disabled) so the traveller hears that 2B is taken instead of the arrows silently skipping it, and after leaving the grid and coming back she lands on the seat she left.

Tab presses from the top of the seat page to Continue

Tab presses from the top of the seat page to ContinueMaking every seat a tab stop costs 38 presses to reach Continue; a roving tab stop brings it to 7, and a skip link to 4.010203040Every seat tabbableRoving tabindexRoving plus skip link38 presses7 presses4 pressesSeat map built asTab presses (presses)Tab presses from the top of the seat page to ContinueMaking every seat a tab stop costs 38 presses to reach Continue; a roving tab stop brings it to 7, and a skip link to 4.010203040Every seat tabbab…Every seat tabbableRoving tabindexRoving plus skip …Roving plus skip link38 presses7 presses4 pressesSeat map built asTab presses (presses)
Railmoor's seat page: 4 header links, the coach picker, coach C's 32 seats, then Continue. The first two bars are the page before it had a skip link. Every seat tabbable: 4 + 1 + 32 + 1 = 38. Roving: 4 + 1 + 1 + 1 = 7. With a skip link: 1 Tab to the link, Enter, then coach picker, seat, Continue = 4 Tabs.
Data
Seat map built asTab presses
Every seat tabbable38
Roving tabindex7
Roving plus skip link4

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

  1. The element being deleted
  2. 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

Q1
RequirementsThe interviewer asks for non-functional requirements of a booking flow. Where does keyboard access go?
Say it as a requirement with a standard behind it: WCAG 2.2 AA, which means every action works from the keyboard with no traps, focus is always visible and never fully hidden, and the order follows the reading order. Then name the two or three widgets where that is hard (the station combobox, the seat grid, the summary dialog) so the deep dive has somewhere to go.
Q2
Component designYou are designing the seat map component. What goes in its API or its state for keyboard users?
The current seat (the roving tab stop) is state the component owns and keeps across re-renders and data refreshes, keyed by seat id rather than by DOM node. Taken seats are aria-disabled, not removed or skipped. Expose an onSeatFocus or a controlled activeSeat if the page needs to sync a details panel.
Q3
ModalsHow does your checkout modal handle focus?
Native dialog with showModal(): the rest of the page is inert, focus starts on the heading (the dialog is mostly reading), Tab never reaches the page behind, Escape closes, and focus returns to the opener. The close handler covers the case where the opener was re-rendered away. For a stack of dialogs, Escape closes only the top one.
Q4
SPA routingWhat happens to focus when your SPA navigates from search to results?
The app shell moves focus to the new page's h1 (tabindex=-1) after render, which also gets it read by screen readers. With the Navigation API that is intercept() with focusReset set to manual; older Safari and Firefox fall back to the router's after-navigation hook.
Q5
ListsA user deletes a row in your list. Where does focus go?
Next row, else previous row, else the list heading, decided in the delete handler, with a polite status message for screen readers. The same rule covers a row that virtualisation recycles.

Railmoor's keyboard review before a release

KB-01Every flow is completed from the keyboard alone, Tab, Shift+Tab, arrows, Enter, Space and Escape, with the mouse unplugged.
KB-02Tab order matches reading order on every breakpoint; no positive tabindex anywhere (a lint rule catches it).
KB-03Each composite widget is one tab stop; arrows move inside it and it remembers its current item.
KB-04Focus is visible on every stop and never fully under the sticky header or basket bar.
KB-05Every dialog and menu returns focus to its opener, or to a named fallback when the opener is gone.
KB-06Every route change, step change and deletion moves focus to a chosen target, never the body.
KB-07No widget holds focus hostage; embedded maps and third-party widgets can be left with Tab or a documented key.
Was this section helpful?

Trade-offs.

Three choices you make on almost every product, and one you should make before anyone ships a shortcut.

01
Roving tabindex or aria-activedescendant for the seat map
Chosen:Roving tabindex on the seats
  • 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
Downside we accept:
  • Con:Two attributes to swap on every arrow key
  • Con:The current seat must survive re-renders (keep it by seat id)
Ruled out:aria-activedescendant on the grid container

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

02
Keeping focus inside the booking summary
Chosen:Native dialog with showModal()
  • 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
Downside we accept:
  • Con:Styling the backdrop and open animation takes some care
  • Con:Workflow-specific focus targets are still your code
Ruled out:A div with inert set on the page wrapper

You write the focus move, Escape handling and restore; The dialog must sit outside the wrapper you make inert

Ruled out:A script focus trap (Tab and Shift+Tab wrapped by a keydown handler)

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

03
Where focus lands after a route change
Chosen:The new page's h1, tabindex=-1
  • 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
Downside we accept:
  • Con:Every page needs exactly one h1 in main
Ruled out:The body (the default if you do nothing)

Nothing is announced, and where the next Tab starts is up to the browser

Ruled out:The main landmark or a wrapper

Some screen readers read the whole region, which is long on a results page

Before adding keyboard shortcuts

Shortcut ideaWhat it collides withRailmoor's rule
"s" to focus station searchScreen reader browse mode uses single letters to jump between headings, links and landmarks; speech input users trigger it by dictating wordsAllowed 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 pageNot used; the search field is reachable with the skip link
Alt+number to switch coachesBrowser tab switching on some platforms and menu access keysArrow keys inside the coach picker instead
Escape to clear the whole formEscape already closes the nearest popup or dialogEscape only ever closes the innermost open thing
This topic
Tab order, tabindex and skip links
Roving tabindex and aria-activedescendant
Combobox, menu button, grid and dialog keyboard behaviour
Focus after dialogs, deletions and route changes
Visible and unobscured focus
Elsewhere
Roles, names and the accessibility tree behind each widgetSemantics and the accessibility tree
Status messages after a deletion or a new searchAnnouncing changes and respecting preferences
Feature detection and fallbacks for the kiosks' older engineBrowser and device support
Keeping focus on recycled rows in a virtual listAnchoring and accessibility in virtual lists
Why the top layer beats every z-indexStacking contexts and the top layer
Power-user shortcut systems and command palettescovered in the system flows that need them
Was this section helpful?
Builds on this
Search with autocomplete
Read next