PDFAccess PDF to accessible web content
← Journal
Accessibility

Tab Key Navigation in Depth: Focus Order, tabindex, and Active States

The Tab key is the cornerstone of keyboard navigation — but correct implementation requires considerably more than letting the browser decide. This article covers natural focus order, the three variants of tabindex, what happens when focus lands on a button, a link, or a tab panel, and what WCAG requires of the visible focus indicator — with concrete code examples throughout.

What the Tab Key Does in the Browser

The Tab key moves keyboard focus forward through a sequence of focusable elements on the page. Shift+Tab moves in the reverse direction. For many users, this is the only navigation method available: people with motor disabilities, blind users navigating with a screen reader, and users with temporary injuries rely on Tab as their primary input.

The browser maintains an internal focus index and tracks the current position. Each Tab keypress transfers focus to the next focusable element — provided it is visible, not disabled, and has a tabindex value of 0 or above.

The following HTML elements are natively focusable and participate in the tab sequence without any additional attributes:

  • <a> with an href attribute
  • <button> (unless disabled)
  • <input>, <select>, <textarea> (unless disabled)
  • <details> and <summary>

Elements such as <div>, <span>, and <p> are not natively focusable. They require an explicit tabindex attribute to participate in the tab sequence — and this is precisely where many implementations introduce accessibility failures.

Natural Tab Order and DOM Order

Without explicit tabindex values, tab order follows DOM order — the sequence in which elements appear in the HTML source. For most pages, DOM order corresponds to visual order: top-left, downward, and to the right. This is also exactly what WCAG 2.4.3 (Focus Order, Level A) requires: focus must move in an order that is logical and meaningful.

Problems arise when CSS is used to alter the visual presentation without changing DOM order. A common example is flex or grid layouts where elements are repositioned visually, but the DOM order remains unchanged:

<!-- DOM order: "Button 3" → "Button 2" → "Button 1" -->
<!-- Visually reversed due to flex-direction: row-reverse -->
<div style="display: flex; flex-direction: row-reverse;">
  <button>Button 3</button>
  <button>Button 2</button>
  <button>Button 1</button>
</div>

Here, the Tab key will move focus from “Button 3” to “Button 2” to “Button 1” — contrary to the visual sequence. This is a violation of WCAG 2.4.3. The solution is always to align DOM order with visual logic — not to attempt to compensate with tabindex.

tabindex — Three Values, Three Purposes

The tabindex attribute controls whether and when an element participates in the tab sequence. There are three distinct values with very different behaviour, and it is important to understand exactly what each one does.

tabindex=“-1”

An element with tabindex="-1" can receive focus programmatically via JavaScript (.focus()), but does not participate in the natural tab sequence — a user cannot Tab to it with the keyboard. This is the correct choice when an element needs to be focusable under certain conditions but should not be included in the general tab flow:

// Open modal and set focus on its first interactive element
function openModal() {
  const modal = document.getElementById('dialog');
  modal.removeAttribute('hidden');

  const firstFocusable = modal.querySelector(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  if (firstFocusable) firstFocusable.focus();
}

tabindex="-1" is typically used for: modals and dialogs, skip links activated programmatically, and elements inside composite widgets (such as tab lists) that are navigated with arrow keys rather than Tab.

tabindex=“0”

tabindex="0" adds an element to the natural tab sequence at its position in DOM order. It is primarily used for custom interactive elements built on semantically neutral tags such as <div> or <span>:

<!-- Custom button-like element -->
<div
  role="button"
  tabindex="0"
  onclick="handleClick()"
  onkeydown="handleKeyDown(event)"
>
  Open menu
</div>
function handleKeyDown(event) {
  // Buttons must be activated with both Enter AND Space
  if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault();
    handleClick();
  }
}

Important: tabindex="0" only solves focusability. It does not add keyboard handling, semantic meaning, or any other accessibility property. Use native HTML elements (<button>, <a>) wherever possible — they have keyboard handling and semantic role built in.

tabindex with a Positive Value

Positive tabindex values (tabindex="1", tabindex="2", and so on) place an element before all others in the tab order, regardless of DOM position. In practice, this is almost always a mistake:

<!-- Avoid this consistently -->
<input tabindex="3" type="text" placeholder="Field A">
<input tabindex="1" type="text" placeholder="Field B"> <!-- Reached first! -->
<input tabindex="2" type="text" placeholder="Field C">

Positive tabindex values create unpredictable navigation, are extremely difficult to maintain in dynamic applications, and are a frequent cause of the “logically incorrect” focus sequences identified in supervisory reports. The correct solution is to adjust DOM order instead.

Buttons and links are the most common interactive elements — and there are precise, distinct rules for how Tab behaves in relation to each.

Buttons

A <button> is natively focusable and participates in the tab sequence without additional attributes. When Tab lands on a button, it receives focus and can be activated with Enter or the Space bar. Pressing Tab again moves focus to the next element in the sequence.

A button with the disabled attribute is removed entirely from the tab sequence and is not announced by screen readers. If a button is temporarily unavailable but users should be aware of its existence and the reason for its unavailability, aria-disabled="true" is the correct approach:

<!-- Button that remains in the tab sequence and is announced as disabled -->
<button
  type="button"
  aria-disabled="true"
  onclick="preventIfDisabled(event)"
>
  Submit form (complete all required fields first)
</button>
function preventIfDisabled(event) {
  if (event.currentTarget.getAttribute('aria-disabled') === 'true') {
    event.preventDefault();
  }
}

An <a> element is only focusable and tabbable if it has an href attribute. <a> without href is an anchor — not a link — and is not included in the tab sequence or announced as interactive:

<a href="/about">About us</a>       <!-- Focusable — correct ✓       -->
<a>Heading anchor</a>               <!-- Not focusable ✗              -->
<a href="#">Empty destination</a>   <!-- Focusable, but avoid ✗      -->

Links are activated with Enter — not with the Space bar. This is an important distinction from buttons, which respond to both keys. Screen reader users learn these conventions and expect them to be observed consistently.

Tab Panels and Roving tabindex

Tab components — tab panels — are a special case with their own navigation rules defined in the WAI-ARIA Authoring Practices. This is where many implementations fail significantly and introduce unpredictable behaviour for keyboard users.

A correctly implemented tab panel pattern uses a single tabstop for the entire tab list and arrow keys for navigation within the list. This is called “roving tabindex” — a pattern where only one element in a group has tabindex="0" at any given time, while the others are set to tabindex="-1":

<div role="tablist" aria-label="Document categories">
  <button
    role="tab"
    id="tab-1"
    aria-selected="true"
    aria-controls="panel-1"
    tabindex="0"
  >
    Guidance
  </button>
  <button
    role="tab"
    id="tab-2"
    aria-selected="false"
    aria-controls="panel-2"
    tabindex="-1"
  >
    Templates
  </button>
  <button
    role="tab"
    id="tab-3"
    aria-selected="false"
    aria-controls="panel-3"
    tabindex="-1"
  >
    Reports
  </button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  Content for Guidance
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
  Content for Templates
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
  Content for Reports
</div>

JavaScript handles the arrow key logic and updates tabindex and aria-selected dynamically:

const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');

tabs.forEach((tab, index) => {
  tab.addEventListener('keydown', (e) => {
    let targetIndex;

    if (e.key === 'ArrowRight') {
      targetIndex = (index + 1) % tabs.length;
    } else if (e.key === 'ArrowLeft') {
      targetIndex = (index - 1 + tabs.length) % tabs.length;
    } else if (e.key === 'Home') {
      targetIndex = 0;
    } else if (e.key === 'End') {
      targetIndex = tabs.length - 1;
    } else {
      return; // All other keys behave normally
    }

    e.preventDefault();
    activateTab(targetIndex);
  });
});

function activateTab(targetIndex) {
  // Reset all tabs
  tabs.forEach((t) => {
    t.setAttribute('tabindex', '-1');
    t.setAttribute('aria-selected', 'false');
  });
  panels.forEach((p) => (p.hidden = true));

  // Activate the selected tab
  tabs[targetIndex].setAttribute('tabindex', '0');
  tabs[targetIndex].setAttribute('aria-selected', 'true');
  tabs[targetIndex].focus();
  panels[targetIndex].hidden = false;
}

This implementation ensures:

  • A single tabstop for the entire tab list — the Tab key passes the list as a single unit and moves on to the next interactive element on the page
  • Arrow keys navigate between tab panels within the list
  • Home and End jump to the first and last tab
  • aria-selected is updated correctly so screen readers announce the active tab

The Focus Indicator: WCAG Requirements and Implementation

The focus indicator is the visual signal that shows keyboard users which element currently has focus. It is the keyboard user’s equivalent of the mouse cursor — without it, meaningful navigation is impossible.

WCAG 2.4.7 (Focus Visible, Level AA) requires that the focus indicator is visible. WCAG 2.2 added the more specific success criterion 2.4.11 (Focus Appearance, Level AA), which sets minimum requirements for size and contrast: the focus indicator must cover an area equivalent to a 2 px border around the component, and the contrast against adjacent colours must be at least 3:1.

The most common failure is removing the browser’s default focus outline without providing an alternative — typically via:

/* Error — removes focus indicator entirely */
* {
  outline: none;
}

button:focus {
  outline: 0;
}

A correct and robust implementation uses :focus-visible, a modern CSS pseudo-class that activates the focus style only during keyboard navigation — not on mouse clicks. This addresses the design problem of visible focus rings appearing on mouse clicks, without removing them for keyboard users:

/* Global: preserve browser outline as fallback */
:focus {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* Advanced: differentiate keyboard and mouse */
:focus:not(:focus-visible) {
  outline: none; /* No ring on mouse click */
}

:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 3px;
  border-radius: 2px;
}

Browser support for :focus-visible is broad (Chrome 86+, Firefox 85+, Safari 15.4+) and covers all current browsers.

Active State When Tab Lands on a Button

When the Tab key places focus on a button, the button is in :focus-visible state — not :active. These CSS states are distinct and should be styled independently:

button {
  background-color: #1a56db;
  color: #ffffff;
  border: 2px solid transparent;
  padding: 0.5rem 1.25rem;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

/* Mouse hover */
button:hover {
  background-color: #1e429f;
}

/* Keyboard focus — visible and contrast-checked */
button:focus-visible {
  outline: 3px solid #fbbf24;  /* Yellow — high contrast against blue */
  outline-offset: 3px;
  background-color: #1e429f;
}

/* Active (button being pressed) */
button:active {
  background-color: #1e3a8a;
  transform: translateY(1px);
}

The focus outline colour #fbbf24 (yellow) against the button’s blue background #1e429f produces a contrast ratio of approximately 7:1 — well above the 3:1 minimum required by WCAG 2.4.11.

Toggle Buttons and aria-pressed

For buttons that switch between states — activate/deactivate, on/off — use the aria-pressed attribute. Screen readers announce aria-pressed="true" as “pressed” and aria-pressed="false" as “not pressed”, giving users information about the button’s current state:

<button type="button" id="toggle-notifications" aria-pressed="false">
  Notifications
</button>
document.getElementById('toggle-notifications').addEventListener('click', function () {
  const isPressed = this.getAttribute('aria-pressed') === 'true';
  this.setAttribute('aria-pressed', String(!isPressed));
  // Handle business logic here
});
/* Visual state when the button is active */
[aria-pressed="true"] {
  background-color: #1e3a8a;
  border-color: #93c5fd;
}

[aria-pressed="true"]:focus-visible {
  outline-color: #fbbf24;
}

Note that aria-pressed communicates state — it is not the same as aria-selected, which is used in tab panels and listboxes. Use the correct attribute for the relevant context.

Focus Management in Modals and Dialogs

A modal dialog is one of the most complex scenarios for tab focus management. When a modal opens, three things must happen correctly:

  1. Focus moves into the modal to the first interactive element
  2. While the modal is open, focus is contained within it (intentional focus trap — a permitted exception to WCAG 2.1.2)
  3. When the modal closes, focus returns to the element that opened it
let lastFocusedElement = null;

function openModal(modalId) {
  lastFocusedElement = document.activeElement;

  const modal = document.getElementById(modalId);
  modal.removeAttribute('hidden');
  modal.setAttribute('aria-modal', 'true');

  // Move focus to the first interactive element inside the modal
  const focusableSelectors =
    'button:not([disabled]), [href], input:not([disabled]), ' +
    'select:not([disabled]), textarea:not([disabled]), ' +
    '[tabindex]:not([tabindex="-1"])';

  const firstFocusable = modal.querySelector(focusableSelectors);
  if (firstFocusable) firstFocusable.focus();

  // Activate focus trap
  modal.addEventListener('keydown', handleModalKeyDown);
}

function handleModalKeyDown(e) {
  const modal = e.currentTarget;

  // Escape closes the modal
  if (e.key === 'Escape') {
    closeModal(modal.id);
    return;
  }

  if (e.key !== 'Tab') return;

  const focusableSelectors =
    'button:not([disabled]), [href], input:not([disabled]), ' +
    'select:not([disabled]), textarea:not([disabled]), ' +
    '[tabindex]:not([tabindex="-1"])';

  const focusable = Array.from(modal.querySelectorAll(focusableSelectors));
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  if (e.shiftKey) {
    // Shift+Tab from first element → wrap to last
    if (document.activeElement === first) {
      e.preventDefault();
      last.focus();
    }
  } else {
    // Tab from last element → wrap to first
    if (document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }
}

function closeModal(modalId) {
  const modal = document.getElementById(modalId);
  modal.setAttribute('hidden', '');
  modal.removeEventListener('keydown', handleModalKeyDown);

  // Return focus to the element that opened the modal
  if (lastFocusedElement) lastFocusedElement.focus();
}

The focus trap in modals is an intentional and specification-compliant exception to WCAG 2.1.2. The WAI-ARIA specification permits and requires focus traps in modal dialogs, because aria-modal="true" signals to the screen reader that the rest of the page is blocked — and users expect focus to remain inside.

Relevance for PDF Documents

PDF documents present particular challenges for tab navigation. A PDF/UA-compatible document must be navigable by keyboard in Adobe Acrobat Reader — but the behaviour is markedly different and far less predictable than in HTML.

Tab order in PDF documents depends on the document’s internal tag structure and is not always testable using the methods applicable to HTML. Screen reader users frequently encounter focus jumping to unexpected locations, or interactive elements such as form fields and links not being reached in a logical sequence.

The HTML format offers far more predictable and verifiable tab navigation. Converting existing PDF documents to semantic HTML — for example via PDFAccess directly in the browser — provides a format that supports the tab patterns described in this article and can be tested and validated using standard browser tools.

Checklist: Tab Navigation

Use this checklist as a starting point for a technical review of tab implementation on your website:

Focus Order and tabindex

  • DOM order corresponds to visual order (WCAG 2.4.3)
  • No positive tabindex values — use DOM order instead
  • Custom interactive elements have tabindex="0" and the correct role
  • Elements navigated with arrow keys (such as tab lists) use roving tabindex

Focus Indicator

  • No outline: none without a visual alternative (WCAG 2.4.7)
  • The focus indicator has a contrast ratio of at least 3:1 against adjacent colours (WCAG 2.4.11)
  • :focus-visible is used to differentiate keyboard focus from mouse clicks
  • Buttons are activated with Enter and Space
  • Links are activated with Enter
  • aria-disabled is used instead of disabled when the button should remain in the tab sequence
  • Toggle buttons use aria-pressed to communicate state

Tab Panels

  • The tab list has a single tabstop (roving tabindex)
  • Arrow keys navigate between tabs within the list
  • Home and End are supported
  • aria-selected is updated correctly on change

Modals and Dialogs

  • Focus moves into the modal on opening
  • Focus is contained within the modal (intentional focus trap)
  • The Escape key closes the modal
  • Focus returns to the opening button on close

For a broader overview of WCAG 2.1 requirements for keyboard navigation — including success criteria 2.1.1, 2.1.2, and 2.4.7 — see Keyboard Navigation and WCAG 2.1: What Is Required, and How Do You Test It?.