I have shipped features that looked beautiful and worked perfectly with a mouse, only to discover later that they were nearly impossible to use with a keyboard. It felt like building a grand stone keep with polished banners and glowing torches, then realizing I forgot to add doors. Users could admire it from afar, but they could not enter.
Fixing keyboard navigation after the fact is humbling. It forces me to examine every assumption I made about interaction. It also reminds me that accessibility is not an optional side quest. It is part of the main campaign.
When I return to an existing codebase to repair keyboard support, I approach it like a rogue retracing steps through a dungeon, searching for traps I once set without noticing.
The First Clue: Tab Order Gone Wrong
The first problem I usually encounter is broken tab order. The Tab key should move focus in a logical sequence that mirrors the visual layout. Instead, I often find focus jumping across the page like a wizard miscasting teleport.
Consider this markup:
<div class="quest-card" onclick="enterThievesGuild()">
<h2>Thieves Guild</h2>
<p>Accept stealth contracts and shadow work</p>
</div>
<div class="quest-card" onclick="enterWarRoom()">
<h2>War Room</h2>
<p>Review battle plans and active campaigns</p>
</div>
These look like interactive quest entries. They respond to clicks. But they are div elements. They are not focusable. A keyboard user presses Tab and nothing happens. The rogue cannot even approach the guild door.
The correct fix is to use the right weapon for the job.
<button class="quest-card" onclick="enterThievesGuild()">
<h2>Thieves Guild</h2>
<p>Accept stealth contracts and shadow work</p>
</button>
<button class="quest-card" onclick="enterWarRoom()">
<h2>War Room</h2>
<p>Review battle plans and active campaigns</p>
</button>
By switching to button elements, I automatically gain keyboard focus, Enter activation, and proper semantics. The dungeon door now has hinges and a handle.
If I absolutely must use a non-semantic element, I recreate the behavior manually.
<div
class="quest-card"
role="button"
tabindex="0"
onclick="enterThievesGuild()"
onkeydown="handleQuestKey(event)">
<h2>Thieves Guild</h2>
<p>Accept stealth contracts and shadow work</p>
</div>
function handleQuestKey(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
enterThievesGuild();
}
}
It works. But it is handcrafted steel. Native elements are enchanted gear forged long before I arrived.
The Invisible Trap: Focus Without a Torch
In my early styling attempts, I removed focus outlines because they looked untidy.
.quest-card:focus {
outline: none;
}
Visually cleaner. Functionally disastrous.
Keyboard users lost their only visual cue. The rogue stepped into darkness.
The fix is simple but intentional.
.quest-card:focus-visible {
outline: 3px solid #7c3aed;
outline-offset: 3px;
}
Now when I press Tab, I see exactly which guild hall or war chamber is selected. It is like relighting braziers along the corridor walls.
The Boss Fight: The Sealed Portal Modal
Modals are where most keyboard navigation fails. I have built many floating chambers that opened with drama and closed with elegance, yet left keyboard users wandering behind the overlay like ghosts.
Here is a typical implementation:
<button id="summonPortal" onclick="openPortal()">
Inspect Ancient Relic
</button>
<div id="portalChamber" class="hidden">
<div class="chamber-content">
<h2>Ancient Relic</h2>
<p>This artifact hums with unstable magic.</p>
<button onclick="closePortal()">Seal Portal</button>
</div>
</div>
function openPortal() {
document.getElementById("portalChamber").classList.remove("hidden");
}
function closePortal() {
document.getElementById("portalChamber").classList.add("hidden");
}
Visually correct. Mechanically flawed.
Focus does not move into the chamber.
Tab escapes behind it.
Focus is not restored when it closes.
So I manage focus deliberately.
First, I track the summoner.
let lastAdventurerPosition = null;
function openPortal() {
lastAdventurerPosition = document.activeElement;
const chamber = document.getElementById("portalChamber");
chamber.classList.remove("hidden");
const firstFocusable = chamber.querySelector(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
);
if (firstFocusable) {
firstFocusable.focus();
}
trapPortalFocus(chamber);
}
Then I trap focus within the chamber.
function trapPortalFocus(chamber) {
const focusableElements = chamber.querySelectorAll(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
chamber.addEventListener("keydown", function (event) {
if (event.key === "Tab") {
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
if (event.key === "Escape") {
closePortal();
}
});
}
Finally, I return the rogue to the original tile.
function closePortal() {
const chamber = document.getElementById("portalChamber");
chamber.classList.add("hidden");
if (lastAdventurerPosition) {
lastAdventurerPosition.focus();
}
}
Now the portal behaves like a sealed magical chamber. When entered, it contains the party. When dismissed, it restores them exactly where they stood.
The Subtle Puzzle: The Arcane Dropdown
Custom dropdowns are notorious. Replace a native select element with a stylized construct and you remove built in keyboard behavior.
A broken arcane selector might look like this:
<div class="spell-selector" onclick="toggleSpellMenu()">
Choose a Spell
<ul class="spell-menu hidden">
<li onclick="castSpell('fireball')">Fireball</li>
<li onclick="castSpell('invisibility')">Invisibility</li>
</ul>
</div>
No focus. No arrow key navigation. No Escape handling.
A proper version assigns clear roles.
<div
id="spellSelector"
role="button"
tabindex="0"
aria-haspopup="listbox"
aria-expanded="false"
onkeydown="handleSpellKey(event)">
Choose a Spell
</div>
<ul id="spellMenu" class="hidden" role="listbox">
<li role="option" tabindex="-1">Fireball</li>
<li role="option" tabindex="-1">Invisibility</li>
</ul>
Then I wire up the arcane logic.
function handleSpellKey(event) {
const selector = document.getElementById("spellSelector");
const menu = document.getElementById("spellMenu");
const spells = menu.querySelectorAll("[role='option']");
const expanded = selector.getAttribute("aria-expanded") === "true";
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
menu.classList.toggle("hidden");
selector.setAttribute("aria-expanded", !expanded);
if (!expanded) {
spells[0].focus();
}
}
if (event.key === "ArrowDown") {
event.preventDefault();
spells[0].focus();
}
if (event.key === "Escape") {
menu.classList.add("hidden");
selector.setAttribute("aria-expanded", "false");
selector.focus();
}
}
Now the spell selector behaves with intention. The rogue can open, navigate, and retreat without ever touching a mouse.
The Lesson the Rogue Taught Me
Every time I fix keyboard navigation after the fact, I pay for shortcuts taken earlier. I refactor structure. I reconsider semantics. I rethink how focus flows through the interface.
It is always more work to retrofit accessibility than to build it in from the start.
Now I design with the Tab key in mind from the beginning. I test early. I verify focus order. I ensure visible focus. I manage focus transitions with care.
Keyboard navigation is not decoration. It is movement. It is agency. It is the difference between a dungeon that can be admired and one that can be explored.
The rogue who could not tab once wandered my interfaces in frustration. Now I build with that rogue in mind. And when the mouse is set aside and only the keyboard remains, the path forward is still clear.


