Skip to main content

How to Fix Keyboard Accessibility Issues

Every interactive element on a web page must be operable with a keyboard alone. This is not an edge case — it is a baseline requirement. Users who rely on keyboards include people with motor disabilities, power users, screen reader users, and anyone with a broken trackpad. Keyboard accessibility failures are among the most common WCAG violations, and most are straightforward to fix once you understand the patterns.

This guide covers the axe-core rules and WCAG success criteria related to keyboard access, with practical code fixes for each pattern.

WCAG Success Criteria

  • 2.1.1 Keyboard (Level A) — All functionality must be operable through a keyboard interface, without requiring specific timings for individual keystrokes.
  • 2.1.2 No Keyboard Trap (Level A) — If keyboard focus can be moved to a component, focus can also be moved away from that component using only the keyboard. If it requires more than standard arrow or Tab keys, the user must be informed of the method.
  • 2.4.3 Focus Order (Level A) — Focusable components must receive focus in an order that preserves meaning and operability. The visual order and the DOM order must align.
  • 2.4.7 Focus Visible (Level AA) — Any keyboard-operable user interface must provide a visible indication of the currently focused element.
  • 2.4.11 Focus Not Obscured (Minimum) (Level AA) — When a component receives focus, it must not be entirely hidden by author-created content such as sticky headers, cookie banners, or chat widgets.

Axe-core Rules

  • scrollable-region-focusable — Scrollable content areas must be keyboard accessible. If a container has overflow content that can be scrolled with a mouse, keyboard users must also be able to scroll it.
  • focus-order-semantics — Elements in the focus order should have an appropriate role. Non-interactive elements that receive focus (via tabindex) without a semantic role confuse assistive technology.
  • tabindex — Elements should not have a tabindex value greater than 0. Positive tabindex values override the natural DOM order and create unpredictable focus sequences.

Fix: Non-Interactive Elements with Click Handlers

The most common keyboard failure is attaching a click handler to a <div> or <span> instead of using a native interactive element. A <div> with an onclick handler is invisible to the keyboard — it cannot receive focus, does not respond to Enter or Space, and is not announced by screen readers.

Before — inaccessible:

<div class="card" onclick="openDetail(42)">
  View project details
</div>

After — accessible with a button:

<button type="button" class="card" onclick="openDetail(42)">
  View project details
</button>

If the action navigates to another page, use an anchor instead:

<a href="/projects/42" class="card">
  View project details
</a>

If you genuinely cannot change the element (third-party widget, legacy code), you must add three things: tabindex="0", a role, and a keydown handler. This is always the inferior option.

<div class="card"
     role="button"
     tabindex="0"
     onclick="openDetail(42)"
     onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openDetail(42)}">
  View project details
</div>

Fix: Remove tabindex Greater Than 0

A positive tabindex value (1, 2, 100, etc.) forces an element to the front of the tab order. This almost always creates confusion. Elements with tabindex="5" receive focus before all elements with tabindex="0" or no tabindex, regardless of their position in the page. The result is a focus order that jumps unpredictably around the page.

Before — broken focus order:

<input type="text" name="email" tabindex="2">
<input type="text" name="name" tabindex="1">
<button type="submit" tabindex="3">Submit</button>

After — natural focus order:

<input type="text" name="name">
<input type="text" name="email">
<button type="submit">Submit</button>

The fix is simply to remove all positive tabindex values and let the DOM order determine the focus sequence. If the visual order does not match the DOM order, fix the DOM order or use CSS (flexbox order, grid placement) rather than tabindex hacks.

Fix: Missing or Removed Focus Styles

Many stylesheets include a global outline: none or outline: 0 reset that strips the browser's default focus indicator. This violates WCAG 2.4.7 Focus Visible. If you remove the default outline, you must provide an alternative that is at least as visible.

Before — focus indicator removed with no replacement:

*:focus {
  outline: none;
}

button:focus {
  outline: 0;
}

After — custom focus styles using :focus-visible:

*:focus {
  outline: none;
}

*:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}

button:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.2);
}

The :focus-visible pseudo-class applies focus styles only when the user is navigating with a keyboard (not on mouse clicks), giving you the best of both worlds. All modern browsers support it.

Fix: Focus Indicators Hidden by overflow:hidden

A subtle but common issue: an element's focus outline is clipped by a parent container with overflow: hidden. The element receives focus, but the indicator is invisible because it extends outside the container boundary and is cut off.

Before — outline clipped:

<div style="overflow: hidden;">
  <button>Click me</button>
</div>

After — use outline-offset or inset box-shadow:

/* Option 1: negative outline-offset keeps it inside the element */
button:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: -2px;
}

/* Option 2: inset box-shadow is never clipped by overflow */
button:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px #4f46e5;
}

Fix: Keyboard Traps in Modals

A keyboard trap occurs when a user can Tab into a component but cannot Tab out of it. This is a Level A failure (WCAG 2.1.2). Modals are the most common offender, but it also happens with dropdown menus, date pickers, rich text editors, and embedded iframes.

A proper modal must do three things: trap focus inside the modal while it is open (so the user does not Tab into the page behind it), close on Escape, and return focus to the triggering element when it closes.

function openModal(triggerId, modalId) {
  const trigger = document.getElementById(triggerId);
  const modal = document.getElementById(modalId);
  modal.style.display = 'block';
  modal.setAttribute('aria-modal', 'true');
  modal.setAttribute('role', 'dialog');

  const focusable = modal.querySelectorAll(
    'a[href], button:not([disabled]), input:not([disabled]), ' +
    'select:not([disabled]), textarea:not([disabled]), [tabindex="0"]'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  first.focus();

  modal.addEventListener('keydown', function trap(e) {
    if (e.key === 'Escape') {
      closeModal();
      return;
    }
    if (e.key !== 'Tab') return;

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });

  function closeModal() {
    modal.style.display = 'none';
    modal.removeEventListener('keydown', trap);
    trigger.focus(); // return focus to trigger
  }
}

The native <dialog> element handles focus trapping automatically when opened with .showModal(). If browser support allows, prefer it over custom implementations.

Fix: Dropdown Menus

Dropdown menus that open on hover are inaccessible by default. A keyboard-accessible dropdown must open on Enter or Space (on the trigger button), support Arrow keys for navigating items, close on Escape, and return focus to the trigger.

<div class="dropdown">
  <button aria-expanded="false" aria-haspopup="true">Menu</button>
  <ul role="menu" hidden>
    <li role="menuitem"><a href="/profile">Profile</a></li>
    <li role="menuitem"><a href="/settings">Settings</a></li>
    <li role="menuitem"><a href="/logout">Log out</a></li>
  </ul>
</div>

The toggle button must update aria-expanded between "true" and "false". Arrow Down/Up should move focus between menu items. Escape should close the menu and return focus to the trigger button.

Fix: Scrollable Containers Need tabindex="0"

When a container has scrollable content (via overflow: auto or overflow: scroll) but is not natively focusable, keyboard users cannot scroll it. Mouse users can scroll with the wheel; keyboard users are stuck. This triggers the axe-core rule scrollable-region-focusable.

Before — scrollable but not keyboard accessible:

<div class="code-block" style="overflow: auto; max-height: 300px;">
  <pre><code>...long code block...</code></pre>
</div>

After — add tabindex and a label:

<div class="code-block"
     style="overflow: auto; max-height: 300px;"
     tabindex="0"
     role="region"
     aria-label="Code example">
  <pre><code>...long code block...</code></pre>
</div>

Adding tabindex="0" makes the container focusable, allowing keyboard users to use Arrow keys to scroll. The role="region" and aria-label provide screen reader context so users know what they are scrolling through.

Fix: Skip Links

A skip link allows keyboard users to jump past repeated navigation and go directly to the main content. Without one, a keyboard user must Tab through every navigation link on every page load. This is a WCAG 2.4.1 requirement (Level A).

<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <nav>...navigation...</nav>
  <main id="main-content" tabindex="-1">
    ...page content...
  </main>
</body>

The skip link is typically visually hidden but becomes visible on focus:

.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  z-index: 100;
  padding: 8px 16px;
  background: #000;
  color: #fff;
  text-decoration: none;
}

.skip-link:focus {
  top: 0;
}

Note the tabindex="-1" on the <main> element. This allows the skip link target to receive focus programmatically even though it is not in the natural tab order.

Fix: Focus Management After Dynamic Content Changes

When content changes dynamically — a form submits and shows a success message, a single-page app navigates to a new view, an item is deleted from a list — focus must be managed explicitly. If the focused element is removed from the DOM, focus falls back to the <body>, and the user loses their place entirely.

After deleting a list item, move focus to the next item or to a summary message:

function deleteItem(itemId) {
  const item = document.getElementById('item-' + itemId);
  const nextItem = item.nextElementSibling || item.previousElementSibling;

  item.remove();

  if (nextItem) {
    nextItem.focus();
  } else {
    document.getElementById('empty-state-message').focus();
  }
}

After a single-page app route change, move focus to the new page heading:

function navigateTo(route) {
  renderPage(route);
  const heading = document.querySelector('h1');
  if (heading) {
    heading.setAttribute('tabindex', '-1');
    heading.focus();
  }
}

Fix: Custom Keyboard Shortcuts

If your application implements custom keyboard shortcuts (single-key shortcuts like "s" to save, "d" to delete), WCAG 2.1.4 Character Key Shortcuts (Level A) requires that they can be turned off, remapped, or only activate when the relevant component has focus. Without this, keyboard and speech input users can accidentally trigger actions.

// Bad: global single-key shortcut with no way to disable
document.addEventListener('keydown', function(e) {
  if (e.key === 's') saveDocument();
});

// Good: require modifier key, or scope to focused component
document.addEventListener('keydown', function(e) {
  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
    e.preventDefault();
    saveDocument();
  }
});

Fix: Focus Not Obscured by Sticky Elements

Sticky headers, fixed footers, cookie banners, and chat widgets can cover focused elements, violating WCAG 2.4.11. The user presses Tab, focus moves to an element behind the sticky header, and they cannot see what is focused.

The fix is to add scroll-padding to account for fixed elements:

/* If your sticky header is 64px tall */
html {
  scroll-padding-top: 80px; /* header height + buffer */
}

/* For a fixed bottom bar */
html {
  scroll-padding-bottom: 80px;
}

You can also programmatically ensure focused elements are visible:

document.addEventListener('focusin', function(e) {
  const header = document.querySelector('.sticky-header');
  if (!header) return;
  const headerBottom = header.getBoundingClientRect().bottom;
  const elTop = e.target.getBoundingClientRect().top;
  if (elTop < headerBottom) {
    e.target.scrollIntoView({ block: 'center' });
  }
});

Testing Keyboard Accessibility

The most effective keyboard test requires no special tools. Unplug your mouse (or on a laptop, avoid the trackpad) and try to use your site with only the keyboard. Work through this checklist:

  • Tab through the entire page. Every interactive element (links, buttons, form fields, custom controls) should receive visible focus in a logical order. If you reach an element you cannot see, something is obscured or hidden.
  • Shift+Tab backwards. Focus should move in exact reverse order.
  • Activate controls. Press Enter on links and buttons. Press Space on buttons and checkboxes. Both should work.
  • Open and close modals. Can you open a modal with the keyboard? Does focus move into the modal? Can you Escape to close it? Does focus return to the trigger?
  • Navigate dropdown menus. Can you open them with Enter or Space? Can you navigate items with Arrow keys? Does Escape close the menu?
  • Check for traps. Can you Tab out of every component? Pay special attention to rich text editors, embedded maps, date pickers, and iframes.
  • Scroll content areas. If there is a scrollable region (code blocks, terms of service boxes, image carousels), can you focus the container and scroll with Arrow keys?
  • Look for invisible focus. If focus disappears at any point during tabbing, an element is either missing focus styles or is hidden behind another element.
  • Test dynamic content. Submit a form, delete a list item, or navigate in a single-page app. Does focus move to a logical location, or does it reset to the top of the page?

Run axe-core or Passiro's automated scanner to catch the machine-detectable issues (missing tabindex on scrollable regions, positive tabindex values, non-interactive elements in the focus order), then follow up with the manual Tab key walkthrough to catch the issues that automation cannot detect — keyboard traps, illogical focus order, missing focus styles, and broken focus management after dynamic changes.

Je vaše spletno mesto dostopno?

Brezplačno skenirajte svoje spletno mesto in prejmite svojo oceno WCAG v nekaj minutah.

Brezplačno skenirajte svoje spletno mesto