Your Website Works With a Mouse, But Fails With a Keyboard
Picture a settings page. There's an Export control in the toolbar, a filter dropdown, and a Settings dialog. Everything works when you click. Now push your mouse aside and press Tab. The focus ring has vanished, Export n
Picture a settings page. There's an Export control in the toolbar, a filter dropdown, and a Settings dialog. Everything works when you click. Now push your mouse aside and press Tab. The focus ring has vanished, Export never receives focus, and the dialog opens while keyboard focus stays on the page behind it.
Nothing in this page throws an error, and a mouse-driven demo would pass. That gap is what this article is about, and the test that exposes it is one question: can someone use this interface without touching a mouse?
A clickable div is not a button
This is the most common version of the problem:
<div class="btn" onclick="exportReport()">Export</div>
A <div> isn't focusable, so Tab skips it. It has no button role, so assistive technology doesn't know it's a control. Enter and Space do nothing even if you could reach it. A mouse click works because onclick listens for pointer events, and that's the only input this element supports.
The fix is the element that already does all of this:
<button type="button" id="export">Export</button>
document.getElementById('export').addEventListener('click', exportReport);
A native <button> is focusable, exposes the right role, and activates with Enter and Space. The W3C's Using ARIA guidance puts this as its first rule: if a native element already has the semantics and behavior you need, use it instead of repurposing another element and adding ARIA.
It's tempting to think role="button" on the div solves the problem. It doesn't. The same guidance notes that changing an element's role doesn't add behaviors to it. A role changes what assistive technology is told about the element, and nothing else. If you truly can't use a <button>, you have to rebuild the behavior yourself:
<div role="button" tabindex="0" id="export">Export</div>
const el = document.getElementById('export');
el.addEventListener('click', exportReport);
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter') exportReport();
if (event.key === ' ') {
event.preventDefault(); // stop the page from scrolling
exportReport();
}
});
This is a sketch, not a replacement. A native button also handles the disabled state, form submission, and other details this version ignores. Links follow the same logic. If an action navigates somewhere, use <a href>, which gives you Enter activation, middle-click, and "open in new tab" for free.
Removing the focus outline removes the map
The usual culprit is a CSS reset:
:focus { outline: none; }
Keyboard users rely on the focus indicator to know where they are. WCAG 2.4.7 Focus Visible (Level AA) requires that any keyboard-operable interface has a mode of operation where the focus indicator is visible. The same page lists styling that removes or hides the indicator as a documented failure (F78).
You don't have to show a ring on every mouse click to comply. The Understanding document explicitly allows user agents to show the indicator only when the keyboard is in use, and :focus-visible is the CSS tool for that:
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 3px solid #0b57d0;
outline-offset: 2px;
}
Browsers' default styles already behave this way, so often the best fix is deleting the reset. If you replace the outline, remember it's also a graphical object, so it falls under 1.4.11 Non-text Contrast.
WCAG 2.2 added a related requirement. 2.4.11 Focus Not Obscured (Minimum) (Level AA) says a component that receives keyboard focus must not be entirely hidden by author-created content. The classic offender is a sticky header or cookie banner covering the control you just tabbed to. Reserving space with scroll-padding is a common mitigation:
html { scroll-padding-top: 5rem; } /* roughly the sticky header's height */
Test it rather than trusting the number. Tab through the page at a few viewport sizes and watch whether the focused element stays in view.
Dialogs need focus to go somewhere, and to come back
A dialog that opens visually but leaves focus behind is one of the most disorienting keyboard failures. The APG modal dialog pattern describes the expected behavior: focus moves into the dialog when it opens, Tab and Shift+Tab stay inside it, Escape closes it, and focus returns to the element that opened it. The pattern notes exceptions, such as when the invoking element no longer exists.
Building this by hand means managing focus, containment, and Escape yourself. The native <dialog> element does most of it when opened with showModal():
<button type="button" id="open-settings">Settings</button>
<dialog id="settings" aria-labelledby="settings-title">
<h2 id="settings-title">Settings</h2>
<form method="dialog">
<label for="name">Display name</label>
<input id="name" name="name" autocomplete="name">
<button value="cancel">Cancel</button>
<button value="save">Save</button>
</form>
</dialog>
const dialog = document.getElementById('settings');
document.getElementById('open-settings')
.addEventListener('click', () => dialog.showModal());
Per MDN's <dialog> documentation, showModal() makes content outside the dialog inert and sets focus on the first focusable element inside. The autofocus attribute lets you choose a better starting point. The APG suggests, for example, focusing the least destructive action when a dialog confirms something hard to undo. W3C's technique H102 also describes the browser returning focus to the invoking element on close.
Two cautions. Calling dialog.show() or setting the open attribute gives you a non-modal dialog without the inert background. And don't put tabindex on the <dialog> itself, since MDN notes it isn't an interactive element.
If you build a modal from divs instead, the APG says to set aria-modal="true" only when your code actually prevents interaction with everything outside it and the styling visually obscures that content. Marking something modal when it isn't can hurt screen reader users.
Menus and widgets don't share one key pattern
Tab moves between controls. Inside a composite widget such as a menu, tabs, or a listbox, arrow keys often move within the widget instead. But the right pattern depends on the widget, so check the APG patterns before choosing. Many site navigation dropdowns are better built as a button that toggles a list of links (the APG disclosure pattern) than as an application-style role="menu", which promises arrow-key behavior you then have to deliver.
This is where tabindex gets misused. tabindex="0" puts an element in the natural tab order, and tabindex="-1" makes it focusable by script but not by Tab. The APG describes values above zero as strongly discouraged, since they override document order and become hard to maintain. Adding tabindex="0" to non-interactive content doesn't make it accessible either. It adds a stop that does nothing.
ARIA attributes work the same way as roles. aria-expanded="false" on a button tells assistive technology the state, but your script has to flip it and show or hide the content. Keyboard accessibility and screen reader accessibility overlap, but they aren't the same thing. A custom control can have perfect ARIA and still be unreachable by Tab, or be fully keyboard operable and announce nothing useful.
Forms: labels and errors need a keyboard path
Start with <label for> tied to each input's id. Placeholder text isn't a label. For errors, WCAG 3.3.1 Error Identification (Level A) requires that a detected error be identified and described in text. The W3C WAI forms tutorial adds that moving focus to the first field with an error is convenient after a failed submission.
<form id="signup" novalidate>
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error" aria-invalid="false">
<p id="email-error" hidden></p>
<button type="submit">Create account</button>
</form>
const form = document.getElementById('signup');
form.addEventListener('submit', (event) => {
const field = form.elements.email;
if (field.validity.valid) return;
event.preventDefault();
const error = document.getElementById('email-error');
error.textContent = 'Enter an email address like [email protected].';
error.hidden = false;
field.setAttribute('aria-invalid', 'true');
field.focus();
});
The message is plain text, so sighted users see it. aria-describedby connects it to the input, so it's read when the field gains focus. aria-invalid flags the state, and focus() puts a keyboard user on the problem. W3C's ARIA21 technique notes that aria-invalid alone doesn't associate message text with the control. In a longer form, an error summary with links to each field is a documented approach too (G139). Reset aria-invalid once the field is fixed.
Focus order and keyboard traps
Tab order follows the DOM, not the visual layout. If you reorder content with CSS order or absolute positioning, focus can jump around the screen in a way that no longer matches the reading sequence. That's the territory of 2.4.3 Focus Order.
A real trap is different from a modal dialog that intentionally contains focus. 2.1.2 No Keyboard Trap is about focus that can enter a component and can't leave by keyboard. Embedded video players, rich text editors, and third-party widgets are common sources. A modal dialog has Escape and a close button as exits.
A keyboard test you can run in ten minutes
Unplug or ignore the mouse, then work through the page:
- Can you reach every link, button, and form control with Tab and Shift+Tab?
- Is the focus indicator always visible, including against dark backgrounds and under sticky headers?
- Does Enter activate links and buttons, and Space activate buttons?
- Does the tab order match the visual order?
- When a dialog or menu opens, does focus move into it? Does Escape close it, and does focus go back to the trigger?
- Can you leave every component, including embeds, without a mouse?
- After a failed form submission, can you tell what's wrong and get to the field?
This checks keyboard operability only. It won't tell you what a screen reader announces, so pair it with screen reader testing. Whether these criteria are legally required depends on your jurisdiction and context. WCAG Levels A and AA are the usual targets, and the APG is guidance on how to build things, not a set of conformance requirements.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.