Frank Jamison dressed in medieval rogue attire sits at a wooden desk by candlelight, writing in an open journal filled with notes and diagrams, with books and warm lantern light in the background creating a focused, fantasy-inspired atmosphere.
Web Development Fundamentals

The Rogue Who Could Not Tab: Fixing Keyboard Navigation

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.

Frank Jamison is a web developer and educator who writes about the intersection of structure, systems, and growth. With a background in mathematics, technical support, and software development, he approaches modern web architecture with discipline, analytical depth, and long term thinking. Frank served on active duty in the United States Army and continued his service with the California National Guard, the California Air National Guard, and the United States Air Force Reserve. His military career included honorable service recognized with the National Defense Service Medal. Those years shaped his commitment to mission focused execution, accountability, and calm problem solving under pressure. Through projects, technical writing, and long form series such as The CSS Codex, Frank explores how foundational principles shape scalable, maintainable systems. He treats front end development as an engineered discipline grounded in rules, patterns, and clarity rather than guesswork. A longtime STEM volunteer and mentor, he values precision, continuous learning, and practical application. Whether refining layouts, optimizing performance, or building portfolio tools, Frank approaches each challenge with the same mindset that guided his years in uniform: understand the system, respect the structure, and execute with purpose.

Leave a Reply