WCAG 2.1.2 · Level A · ADA Title II

Keyboard traps and the ADA

If a keyboard user can tab into a component, they must be able to tab out. A look at forms, popovers and embedded widgets on public service sites.

When focus gets stuck

Success Criterion 2.1.2 says that if keyboard focus can move into a component using a keyboard interface, it must also be possible to move it out again using only the keyboard. If getting out takes more than unmodified arrow or Tab keys, or another standard exit method, the user must be told how.

Two points matter in practice. First, confining focus is fine when it is appropriate, for example inside an open modal dialog, as long as people know how to leave. Second, the criterion applies to all content on the page, including third-party widgets that were never meant to satisfy any other criterion. One trapped widget can make the rest of the page unreachable.

The trap is rarely deliberate. It usually comes from a script that was written to "help": it pulls focus back, swallows Tab, or loops focus without any exit.

Traps you find on government sites

  • A bill-pay form that forces focus back into a field until a valid value is typed.
  • A help popover on a school enrollment form that wraps Tab and ignores Escape.
  • An embedded parcel or zoning map from a GIS vendor that keeps keyboard focus inside the map.
  • A full-screen dialog in an app that cannot be dismissed without a tap.

The people who hit these are the same ones who cannot work around them with a mouse. A trap in a payment or application step blocks the whole service, not just one control.

Validation that holds focus hostage

Some forms re-focus a field on blur when the value is invalid. A keyboard user can then neither correct the value in another way nor leave the field. Validate on submit or after the user moves on, show the problem as text, and let focus go where the user sends it.

Incorrect
<input id="pay-date" name="pay-date">
<script>
  const payDate = document.getElementById("pay-date");
  payDate.addEventListener("blur", () => {
    if (!isValidDate(payDate.value)) payDate.focus();
  });
</script>
Correct
<label for="pay-date">Payment date (MM/DD/YYYY)</label>
<input id="pay-date" name="pay-date" aria-describedby="pay-date-err">
<p id="pay-date-err" role="alert" hidden>
  Enter a date like 04/15/2026.
</p>
<script>
  const form = document.querySelector("form");
  const payDate = document.getElementById("pay-date");
  const payDateErr = document.getElementById("pay-date-err");
  form.addEventListener("submit", e => {
    if (!isValidDate(payDate.value)) {
      e.preventDefault();
      payDateErr.hidden = false;
      payDate.focus();   // only on submit, and an error is shown
    }
  });
</script>

Popovers and embedded widgets

When a popover contains focus on purpose, give it a close button, make Escape close it, and return focus to the control that opened it.

Incorrect
<div id="help-panel" class="popover">…</div>
<script>
  const helpPanel = document.getElementById("help-panel");
  helpPanel.addEventListener("keydown", e => {
    if (e.key !== "Tab") return;
    const f = [...helpPanel.querySelectorAll("a[href], button")];
    if (e.shiftKey && document.activeElement === f[0]) {
      e.preventDefault(); f.at(-1).focus();
    } else if (!e.shiftKey && document.activeElement === f.at(-1)) {
      e.preventDefault(); f[0].focus();
    }
  });   // no Escape, no close button
</script>
Correct
<div id="help-panel" class="popover" hidden>
  <button type="button" id="help-close">Close</button>
  …
</div>
<script>
  const helpPanel = document.getElementById("help-panel");
  let opener;
  function openHelp() {
    opener = document.activeElement;
    helpPanel.hidden = false;
    helpPanel.querySelector("button").focus();
  }
  function closeHelp() { helpPanel.hidden = true; opener.focus(); }
  document.getElementById("help-close").addEventListener("click", closeHelp);

  helpPanel.addEventListener("keydown", e => {
    if (e.key === "Escape") { closeHelp(); return; }
    if (e.key !== "Tab") return;
    const f = [...helpPanel.querySelectorAll("a[href], button")];
    if (e.shiftKey && document.activeElement === f[0]) {
      e.preventDefault(); f.at(-1).focus();
    } else if (!e.shiftKey && document.activeElement === f.at(-1)) {
      e.preventDefault(); f[0].focus();
    }
  });
</script>

Embedded vendor content is the hardest case because you do not control its code. Test it by tabbing in and out. If it cannot be fixed, tell users the exit key and offer the same function in a form that is not trapped:

Incorrect
<iframe src="https://gis.vendor.example/parcels"></iframe>
Correct
<iframe src="https://gis.vendor.example/parcels"
        title="Parcel map"></iframe>
<p>Keyboard focus stays in the map? Press Escape, then Tab.
   You can also <a href="/parcel-search">search by parcel number</a>.</p>

Dialogs in mobile apps

On a phone or tablet with a switch or keyboard, a full-screen presentation with no visible dismiss control can become a dead end. Always give a Close action. A SwiftUI full-screen cover has no built-in dismissal, and a Compose dialog should honor the back action. NavigationStack needs iOS 16 or later.

SwiftUI:

Incorrect
.fullScreenCover(isPresented: $showHelp) {
    HelpView()   // no way to dismiss
}
Correct
.fullScreenCover(isPresented: $showHelp) {
    NavigationStack {
        HelpView()
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") { showHelp = false }
                }
            }
    }
}

Jetpack Compose:

Incorrect
if (showHelp) {
    Dialog(onDismissRequest = { }) {   // back and outside taps ignored
        HelpContent()
    }
}
Correct
if (showHelp) {
    Dialog(onDismissRequest = { showHelp = false }) {
        Column {
            HelpContent()
            TextButton(onClick = { showHelp = false }) { Text("Close") }
        }
    }
}

Tab in, tab out: how to test

  1. Tab through every page of a key task, then through every component that opens: popovers, date pickers, chat boxes, embedded maps, document viewers.
  2. In each one, note the key that gets you out. If it is not Tab, Shift+Tab, an arrow key or Escape, check that the page says what it is.
  3. Enter bad values in forms and move away with the keyboard. Focus should go where you sent it.
  4. Repeat with every third-party script switched on, as it runs in production.

Tools such as axe can catch some patterns, but a trap often depends on runtime script behavior, so human testing comes first.

Mistakes that create traps

  • Calling preventDefault() on Tab without a defined way out.
  • Re-focusing a field on blur or focusout.
  • Looping focus inside a dialog with no close button and no Escape handling.
  • Embedding a plug-in or vendor widget and never tabbing through it.
  • Documenting the exit key only in a help page nobody finds.

The ADA angle, and a ship-it list

WCAG 2.1.2 applies to all content on the page, including third-party widgets, so a trapped embedded map has to be fixed or replaced, not footnoted.

Ship it when:

  • Every component that holds focus can be left with the keyboard.
  • Any non-standard exit key is stated next to the component.
  • Validation never moves focus back on its own.
  • Each dialog has a visible Close action and responds to Escape or back.

For vendor content in general, see vendors and third-party content.

Sources

Standard version: WCAG 2.1 (W3C Recommendation, 5 June 2018), as named in the Title II web rule. This page is for informational purposes only and is not legal advice. For binding requirements, refer to the official ADA statute and regulations, and ask a lawyer how they apply to you.

Corpowid is recognized by Gartner

Corpowid has been recognized by Gartner, a leading global research and advisory firm, for our innovation and performance in digital accessibility. These badges reflect our commitment to creating inclusive, AI-powered web experiences.

Have questions about Corpowid?

Let’s connect.

We will get back to you as soon as possible.