Mert Özen Further With Every Line
Frontend Accessibility (a11y) — #03

Keyboard Navigation and Focus Management: Tab Order, :focus-visible, Skip Links, Focus Traps, inert and Roving Tabindex

Mert Özen Nov 15, 2026 16 min 5 views
Keyboard Navigation and Focus Management: Tab Order, :focus-visible, Skip Links, Focus Traps, inert and Roving Tabindex

For keyboard users focus must always be visible, move in a sensible order and be placed somewhere on purpose when the focused element disappears. We use Bookstore with the keyboard only and measure: the base href trap of skip links in an SPA, a sticky header hiding focus and scroll-padding, the native dialog's focus trap and focus after a deleted row, roving tabindex with native radios, and focus and page titles on route changes with the Angular router.

Version note: The examples in this post were run with WCAG 2.2, Angular 22.2 (TypeScript 6.0), Node.js 24 and Playwright 1.65 (Chromium 158); the keyboard measurements were taken in headless Chromium.

Guess First

You use the catalog of Bookstore, the online bookstore used as the shared example across this site's series, without touching the mouse. After the page loads, how many times do you have to press Tab to reach Dune's "Add to cart" button? Then you press Enter on Dune's link and go to the book page: where is focus? At the top of the new page, on the page's first link, or nowhere?

There is also a request from Selin, the Product Owner: the cart count should stay visible while scrolling, so the header should be sticky. What does this one-line CSS change break for a keyboard user? Two open rows in the previous post's audit table are the subject of this post: 2.4.2 (Page Titled) and 2.4.3 (Focus Order).

Using Bookstore with the Keyboard Only

You can test the keyboard by hand, but to measure the results again I put it in a script. In each mode keyboard.mjs does one task with the keyboard only and prints the role and name of the focused element at every step; that is the essence of what a screen reader would announce at that moment:

// Uses Bookstore with the keyboard only and prints where focus goes.
// Usage: node keyboard.mjs <tabs|route|skip|sticky|radios|dialog>
import { chromium } from 'playwright';
import { base, open } from './lib.mjs';

const browser = await chromium.launch();
const page = await (await browser.newContext({ viewport: { width: 1280, height: 600 } })).newPage();

// Role and name of the focused element, as a screen reader would get them
async function focused() {
  const el = page.locator(':focus');
  if (await el.count() === 0) return '(nothing: document.body)';
  return (await el.ariaSnapshot()).split('\n')[0].replace(/^- /, '').replace(/:$/, '');
}

async function tabUntil(text, max = 60) {
  for (let i = 1; i <= max; i++) {
    await page.keyboard.press('Tab');
    if ((await focused()).includes(text)) return i;
  }
  return -1;
}

const mode = process.argv[2] ?? 'tabs';

if (mode === 'tabs') {
  // How many Tab presses from page load to "Add to cart" for Dune?
  await open(page, 'catalog');
  const stops = [];
  for (let i = 0; i < 9; i++) {
    await page.keyboard.press('Tab');
    stops.push(await focused());
  }
  console.log(stops.map((s, i) => `${String(i + 1).padStart(2)} ${s}`).join('\n'));
  await open(page, 'catalog');
  const dune = page.locator('.book-card', { hasText: 'Dune' }).getByRole('button');
  for (let i = 1; i <= 60; i++) {
    await page.keyboard.press('Tab');
    if (await dune.evaluate(el => el === document.activeElement)) {
      console.log(`Tab presses to "Add to cart" for Dune: ${i}`);
      break;
    }
  }
  // Same task, but start with the skip link: Tab, Enter, then Tab from the top of main
  await open(page, 'catalog');
  await page.keyboard.press('Tab');
  if ((await focused()).includes('Skip to main content')) {
    await page.keyboard.press('Enter');
    for (let i = 1; i <= 60; i++) {
      await page.keyboard.press('Tab');
      if (await dune.evaluate(el => el === document.activeElement)) {
        console.log(`with the skip link: 1 Tab + Enter + ${i} Tab`);
        break;
      }
    }
  }
}
node keyboard.mjs tabs
node keyboard.mjs route
 1 link "Bookstore"
 2 link "Catalog"
 3 link "Cart (0)"
 4 searchbox "Search books"
 5 button "Search"
 6 combobox "Category"
 7 link "1984"
 8 button "Add to cart"
 9 link "Anna Karenina"
Tab presses to "Add to cart" for Dune: 18
url=/books/9780441172719 title="Book · Bookstore"
focus after Enter: (nothing: document.body)
next Tab:         link "← Back to catalog"

The answer to the first guess is 18. Focus order follows DOM order: the three links in the header, the search box, the Search button, the category picker and then the books, one link and one button per book. Dune is the sixth book, so ten stops from five books come before it. This natural order is correct; what would be wrong is separating the visual order from the DOM with a positive tabindex or with CSS. Why a positive tabindex breaks the order was measured in the "Accessible Markup Basics" post of the HTML5 and Semantic HTML series; we do not repeat it here.

The answer to the second guess is annoying: after Enter, focus is nowhere. The Angular router removed the old page's component from the DOM, the focused link went with it and focus fell back to body. The page title is "Book · Bookstore"; it does not say which book. A screen reader user hears nothing after pressing the link; they only find out the page changed by exploring again. In Chromium the next Tab went to "← Back to catalog"; another browser may go back to the very top of the page; neither is "the start of the new page".

Visible Focus: :focus-visible and the outline: none Trap

For users who see where focus is, the first requirement is that focus is visible (2.4.7 Focus Visible). The most common mistake is writing outline: none because the design does not like the browser's focus ring, and putting nothing in its place; from then on the keyboard user does not know where they are. Bookstore has no such line, but the browser's default ring is hard to see on the blue buttons. We wrote one style for every control:

a { color: #1d4ed8; }

/* One visible focus style for every control; 3px with an offset stays visible on the blue buttons too */
:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}

:focus-visible follows the browser's decision on "should this focus be shown?": the ring is drawn when you arrive with the keyboard, not when you click a button with the mouse; that distinction and the selector's browser support are in the "Modern Selectors" post of the CSS3 series. outline-offset moves the ring outside the button, so a blue ring on a blue background does not disappear. Using outline has one more advantage: in Windows contrast themes (forced colors) a focus ring drawn with box-shadow disappears, while outline stays.

2.4.1 (Bypass Blocks) asks for a way to skip blocks repeated on every page. Landmarks and headings do this job for screen reader users, but for someone using only a keyboard the only way is a skip link: the page's first focusable element, a "Skip to main content" link that appears when it receives focus. We added it to the header and gave main an id and tabindex="-1" so it can receive focus programmatically. In the first attempt the link's href was just #main-content. We measured on the cart page:

first Tab: link "Skip to main content"
after Enter: url=/#main-content focus=main
next Tab: searchbox "Search books"

The link took us from the cart to the catalog. The Angular app's index.html has <base href="/en/">, and a relative #main-content address is resolved against that base: not /cart#main-content but /#main-content. A classic trap in SPAs. The fix is to keep the link's href for meaning and handle the click ourselves:

<a class="skip-link" href="#main-content" (click)="skipToMain($event)">Skip to main content</a>
<header class="site-header">
  <a routerLink="/" class="brand">Bookstore</a>
  <nav>
    <a routerLink="/">Catalog</a>
    <a routerLink="/cart" data-testid="cart-link">Cart ({{ cartStore.itemCount() }})</a>
  </nav>
</header>
<main #main id="main-content" tabindex="-1">
  <router-outlet />
</main>
export class App implements OnInit {
  protected readonly cartStore = inject(CartStore);
  private readonly main = viewChild.required<ElementRef<HTMLElement>>('main');

  ngOnInit(): void {
    void this.cartStore.load();
  }

  // With <base href="/en/">, href="#main-content" would navigate to "/#main-content"; move focus ourselves.
  protected skipToMain(event: Event): void {
    event.preventDefault();
    this.main().nativeElement.focus();
  }
.skip-link {
  position: absolute;
  left: 1rem;
  top: -3rem;
  z-index: 20;
  padding: 0.5rem 1rem;
  background: #ffffff;
}

.skip-link:focus {
  top: 0.5rem;
}

/* main and h1 only receive focus from script (tabindex="-1"); they are not controls */
main:focus, h1:focus { outline: none; }
first Tab: link "Skip to main content"
after Enter: url=/cart focus=main
next Tab: link "1984"
node keyboard.mjs tabs
Tab presses to "Add to cart" for Dune: 19
with the skip link: 1 Tab + Enter + 15 Tab

Focus landed on main and the next Tab went to the first link in the cart. The skip link sits off screen and only appears when it receives focus; had we hidden it with display: none, Tab could never reach it. We removed the focus ring for main and h1: they are not controls, they only receive focus from script, and the next Tab goes to a control with a visible ring. Bookstore's header is small: since the skip link is a stop itself, reaching Dune now takes 19 Tabs, or with the skip link 1 Tab, Enter and 15 Tabs. The gain is a few key presses; on a site with a forty-link menu the difference is much bigger.

Sticky Headers and 2.4.11 Focus Not Obscured

For Selin's request we added three lines to the header:

.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #ffffff;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid #e5e7eb;
}

The header stays visible while scrolling. Tabbing down with the keyboard is fine: the browser scrolls the focused element to the bottom of the visible area. The problem appears on the way back. Going up with Shift+Tab, the browser scrolls the element to the very top of the visible area, which is now under the header. keyboard.mjs sticky goes up from the catalog's last button with Shift+Tab and measures, at every focus, how much of the element the header covers:

header height=56px, focused elements checked=42, fully hidden=8, partly hidden=0

At eight of forty-two stops the focused element was completely under the header: the user cannot see where they are. 2.4.11 (AA) forbids exactly this; it allows partial coverage but not complete coverage (2.4.12, at AAA, forbids partial coverage too). The fix is one line of CSS: scroll-padding-top tells the browser how much room to leave at the top when it scrolls an element into view.

html {
  /* Room for the sticky header when the browser scrolls a focused element into view (WCAG 2.4.11) */
  scroll-padding-top: 4.5rem;
}
header height=56px, focused elements checked=42, fully hidden=0, partly hidden=0

The header is 56 pixels; 4.5rem (72 pixels) leaves enough room for the header and the focus ring together. The same problem applies to cookie banners, "go to cart" bars stuck to the bottom of the page and chat widgets; for things at the bottom, use scroll-padding-bottom.

The "Remove" button in the cart no longer deletes right away; it asks for confirmation. We used a native dialog and showModal(); the basics (top layer, autofocus, method="dialog", Esc) were covered in the "Native Interactive Elements 1" post of the HTML5 and Semantic HTML series. Here we look at focus:

<dialog #removeDialog class="confirm" aria-labelledby="remove-title" (close)="onRemoveDialogClosed()">
  <h2 id="remove-title">Remove {{ pendingRemoval()?.title }} from your cart?</h2>
  <form method="dialog">
    <button value="keep" autofocus>Keep it</button>
    <button value="remove" class="danger">Remove</button>
  </form>
</dialog>

In the first version we only called store.remove() after the dialog closed. keyboard.mjs dialog puts two books in the cart and removes the first with the keyboard:

trigger: button "Remove" (row 1 of 2)
dialog open, focus: button "Keep it"
Tab x3: button "Remove" -> (nothing: document.body) -> button "Keep it"
page link .focus() while open: button "Keep it"
Escape: focus=button "Remove"
reopen + Tab: button "Remove"
confirmed, rows left=1, focus=(nothing: document.body)

What the native dialog does is visible line by line. When it opened, focus went to "Keep it", which has autofocus. Tab cycles inside the dialog; after the two buttons it steps out once to the browser's own interface (the "nothing" line, the address bar in a real browser) and comes back to "Keep it"; it never goes to the rest of the page. Even calling focus() on a background link from script did not work: while the modal is open, everything outside the dialog is inert. Esc closed the dialog and gave focus back to the "Remove" button that opened it. So the browser does most of the focus trapping and focus returning; in a modal built from a div you would have to code all of it by hand.

The last line is our problem: when "Remove" was confirmed, the row was deleted, the button that should get focus back went with it and focus fell back to body. The browser cannot know about this; we have to decide where to go. The rule is simple: the button of the row that takes the deleted row's place, otherwise the previous row's, and the page heading if the cart is now empty:

  protected readonly pendingRemoval = signal<CartLine | null>(null);

  private readonly injector = inject(Injector);
  private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
  private readonly heading = viewChild.required<ElementRef<HTMLElement>>('heading');
  private readonly removeDialog = viewChild.required<ElementRef<HTMLDialogElement>>('removeDialog');

  protected askToRemove(line: CartLine): void {
    this.pendingRemoval.set(line);
    this.removeDialog().nativeElement.showModal();
  }

  protected async onRemoveDialogClosed(): Promise<void> {
    const dialog = this.removeDialog().nativeElement;
    const line = this.pendingRemoval();
    this.pendingRemoval.set(null);
    if (dialog.returnValue !== 'remove' || !line) {
      return; // "Keep it" or Escape: the dialog puts focus back on the Remove button that opened it
    }
    dialog.returnValue = '';
    const index = this.store.cart()?.items.findIndex(l => l.isbn === line.isbn) ?? 0;
    await this.store.remove(line.isbn);
    this.focusAfterRemoval(index);
  }

  // The button that opened the dialog has been removed with its row. Without this, focus falls back to <body>.
  private focusAfterRemoval(index: number): void {
    afterNextRender(() => {
      const buttons = this.host.nativeElement.querySelectorAll<HTMLButtonElement>('tbody button');
      const next = buttons[Math.min(index, buttons.length - 1)];
      (next ?? this.heading().nativeElement).focus();
    }, { injector: this.injector });
  }
trigger: button "Remove" (row 1 of 2)
dialog open, focus: button "Keep it"
Tab x3: button "Remove" -> (nothing: document.body) -> button "Keep it"
page link .focus() while open: button "Keep it"
Escape: focus=button "Remove"
reopen + Tab: button "Remove"
confirmed, rows left=1, focus=button "Remove"

afterNextRender runs after Angular has removed the row from the DOM; if it ran earlier, it could pick the button that is about to disappear. Focus is now on the remaining row's "Remove" button. In the next post we will fix the fact that all these buttons have the same name, so a user who hears "Remove" does not know which book's button they are on.

When do you use the inert attribute yourself? A native dialog opened as modal already makes the background inert. If you build your own drawer menu or full-screen panel without dialog, you need to give inert to the main and header behind it while it is open; otherwise Tab escapes to the links behind. How inert differs from aria-hidden and hidden was measured in the "Accessible Markup Basics" post of the HTML5 and Semantic HTML series.

Roving Tabindex and aria-activedescendant: the Native Radio Example

Selin's second request is that the category picker becomes always-visible "chips" instead of a dropdown. Had we made five chips five separate buttons, we would have added five stops to the Tab order. The right model is a composite widget: the group is one Tab stop, and you move inside it with the arrow keys. There is already an element that gives this behavior natively: radio buttons with the same name. We wrote the chips as visually hidden radios and their labels:

  <fieldset class="chips">
    <legend>Category</legend>
    <label><input type="radio" name="category" value="" [checked]="category() === ''" (change)="category.set('')" /> All</label>
    @for (c of categories.value(); track c.slug) {
      <label>
        <input type="radio" name="category" [value]="c.slug" [checked]="category() === c.slug" (change)="category.set(c.slug)" />
        {{ c.name }}
      </label>
    }
  </fieldset>
.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; border: 0; padding: 0; margin: 0; }
.chips legend { float: left; margin-right: 0.5rem; padding: 0.3rem 0; }
.chips label { padding: 0.3rem 0.8rem; border: 1px solid #6b7280; border-radius: 999px; cursor: pointer; }
.chips input { position: absolute; opacity: 0; }
.chips label:has(:checked) { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
.chips label:has(:focus-visible) { outline: 3px solid #1d4ed8; outline-offset: 2px; }
Tab reaches: radio "All" [checked]
ArrowRight: radio "Classics" [checked]  14 books
ArrowRight: radio "Science Fiction" [checked]  3 books
Tab leaves the group: link "Dune"
Shift+Tab comes back to: radio "Science Fiction" [checked]

The group is one stop; the arrow key moves both focus and selection and the list filters immediately (14 books in Classics, 3 in Science Fiction). Tab leaves the group, Shift+Tab comes back in at the selected radio. We hid the radio with opacity: 0 rather than display: none so it can receive focus, and moved the focus ring to the label with :has(:focus-visible).

What the browser does here is called roving tabindex: only the active item in the group has tabindex="0", the others -1; when an arrow key is pressed, the 0 moves to the next item and focus goes with it. If you build composite widgets such as toolbars, listboxes, tabs, trees and grids yourself, you build this logic yourself. The alternative is the ARIA attribute aria-activedescendant: DOM focus stays on the container (for example a combobox's text box), the container's attribute points to the id of the item that is "active" at the moment, and the screen reader announces that item. In a combobox, where the user has to keep typing, you cannot move focus out of the text box, so aria-activedescendant is used; in toolbars and tabs roving tabindex is more common. The expected key behavior of both is described pattern by pattern in W3C's ARIA Authoring Practices Guide. Using Angular CDK's ListKeyManager or Angular Aria instead of rewriting this logic in every project is the subject of the series' Angular post.

Route Changes in an SPA: Focus and Page Title

On a classic website every link loads a new document; the browser puts focus at the start of the page and the screen reader reads the new title. In an SPA none of this happens, as we saw in the measurement. The Angular router does not move focus on route changes; we wrote a small two-part solution. A service remembers that a client-side navigation happened; a directive goes on the page's h1 and, when it renders, takes focus if a navigation is pending:

import { Injectable, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

// Remembers that a client-side navigation happened, so the next page heading can take focus.
@Injectable({ providedIn: 'root' })
export class RouteFocus {
  private pending = false;

  constructor() {
    inject(Router).events
      .pipe(filter(event => event instanceof NavigationEnd))
      // id 1 is the first load: the browser already starts at the top of the document
      .subscribe(event => this.pending = event.id > 1);
  }

  claim(): boolean {
    const pending = this.pending;
    this.pending = false;
    return pending;
  }
}
import { Directive, ElementRef, afterNextRender, inject } from '@angular/core';
import { RouteFocus } from './route-focus';

// <h1 appPageHeading>: after a route change, focus moves to the new page's main heading.
// Works for headings that render later too (for example after an httpResource has loaded).
@Directive({
  selector: 'h1[appPageHeading]',
  host: { tabindex: '-1' },
})
export class PageHeading {
  constructor() {
    const heading = inject<ElementRef<HTMLElement>>(ElementRef);
    const routeFocus = inject(RouteFocus);
    afterNextRender(() => {
      if (routeFocus.claim()) {
        heading.nativeElement.focus();
      }
    });
  }
}

Why move focus when the heading renders rather than at NavigationEnd? The book details page's h1 is created after the data arrives with httpResource; at NavigationEnd it is not in the DOM yet. Because the directive runs whenever the element is created, it covers both cases. We added the directive to the h1 of four pages (<h1 appPageHeading>). On the first load we do not touch focus; the browser already starts at the beginning of the document. For the title, we call the Title service once the book has loaded:

  constructor() {
    // The route title is only "Book · Bookstore"; name the book once it has loaded (WCAG 2.4.2)
    const title = inject(Title);
    effect(() => {
      const book = this.book.value();
      if (book) {
        title.setTitle(`${book.title} · Bookstore`);
      }
    });
  }
url=/books/9780441172719 title="Dune · Bookstore"
focus after Enter: heading "Dune" [level=1]
next Tab:         spinbutton "Quantity": "1"

After Enter, focus is on the "Dune" heading and the title is "Dune · Bookstore". Because the screen reader reads the heading that receives focus, the user hears right away that they are on a new page and which book it is; the next Tab goes to the page's first control, the quantity field. Some teams prefer to move focus to main instead of h1, or to announce the route change with a live region; these are valid approaches too. What matters is that the chosen approach is consistent across all routes and tried with a screen reader. We also removed the 2.4.2 and 2.4.3 entry from the statement page.

In Production

Put a keyboard test on the pre-merge checklist: use the new screen end to end with the keyboard only; is focus visible at every stop, is the order sensible, do you get trapped anywhere, does Esc work where expected? Scripts like this post's keyboard.mjs make this check repeatable and can be turned into E2E tests.

For every action that deletes, hides or replaces an element, ask "where will focus go?" A deleted row, a closed panel, a changed tab, a filter that returns to a results page; if the focused element disappears, put focus somewhere on purpose.

Measure 2.4.11 for everything with a fixed position: the header, cookie banners, a cart bar at the bottom, a chat button. If the header's height can change (for example it wraps to two lines on mobile), tie scroll-padding-top to a CSS variable and update it at breakpoints.

Solve focus management on route changes once, the same way, for the whole app. If every page does it its own way, some pages move focus and some do not; users cannot know what to expect.

Common Pitfalls

First, writing outline: none without putting a visible focus style in its place.

Second, leaving a skip link's href as a plain hash in an SPA. With <base href="/en/"> the link goes to another page.

Third, hiding a skip link or a visually hidden radio with display: none. The element also leaves the Tab order.

Fourth, building a modal from a div and forgetting the focus trap, Esc, an inert background and giving focus back. A native dialog provides most of these.

Fifth, removing the focused element from the DOM and leaving focus on body. A keyboard user goes back to the top of the page, a screen reader user hears nothing.

Sixth, putting every item of a composite widget in the Tab order. The group is one stop; inside it, the arrow keys.

A Small Experiment

★☆☆ Set scroll-padding-top in styles.css to 2rem and run node keyboard.mjs sticky. Are there no completely hidden elements left, or did the "partly hidden" count go up? Interpret the result in terms of 2.4.11 and 2.4.12.

★★☆ Remove the last remaining book from the cart with the keyboard too. Which element does focusAfterRemoval go to, and what would a screen reader announce there? Would it be better for focus to go to the "Browse the catalog" link when the cart is empty?

★★★ Write a "Grid / List" view toolbar for the catalog: two toggle buttons, role="toolbar", one Tab stop with roving tabindex and navigation with the left and right arrow keys. Add a mode to keyboard.mjs to measure the number of Tab stops and the arrow key behavior; then see whether writing the same behavior with native radios is shorter.

This post mentioned ARIA attributes such as role="toolbar", aria-labelledby and aria-activedescendant without going into what they do. In the next post we cover using WAI-ARIA correctly: roles, states and properties, how the accessible name is computed, the "Remove" and "Add to cart" buttons that all share a name, and the rule that no ARIA is better than bad ARIA.