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.
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.
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.
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.
<input id="pay-date" name="pay-date">
<script>
const payDate = document.getElementById("pay-date");
payDate.addEventListener("blur", () => {
if (!isValidDate(payDate.value)) payDate.focus();
});
</script><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>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.
<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><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:
<iframe src="https://gis.vendor.example/parcels"></iframe><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>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:
.fullScreenCover(isPresented: $showHelp) {
HelpView() // no way to dismiss
}.fullScreenCover(isPresented: $showHelp) {
NavigationStack {
HelpView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { showHelp = false }
}
}
}
}Jetpack Compose:
if (showHelp) {
Dialog(onDismissRequest = { }) { // back and outside taps ignored
HelpContent()
}
}if (showHelp) {
Dialog(onDismissRequest = { showHelp = false }) {
Column {
HelpContent()
TextButton(onClick = { showHelp = false }) { Text("Close") }
}
}
}Tools such as axe can catch some patterns, but a trap often depends on runtime script behavior, so human testing comes first.
preventDefault() on Tab without a defined way out.blur or focusout.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:
For vendor content in general, see vendors and third-party content.