Focus Management for Web Developers

When users navigate with keyboards or assistive technology, focus must move logically and be clearly visible.

Reading time: 10 minutes

Focus Fundamentals

Common Mistake

Never remove focus outlines without providing an alternative.outline: nonewithout a replacement makes your site unusable for keyboard users.

Visible Focus Indicators

Users must always be able to see where focus is. WCAG 2.4.13 (Level AAA) requires focus indicators with:

Default Browser Focus

/* Browsers provide default focus styles */
:focus {
 outline: auto; /* Usually a blue ring */
}

The default is accessible—only override it if you provide somethingbetter.

Custom Focus Styles

/* Clear, visible custom focus */
:focus {
 outline: 3px solid #005EA2;
 outline-offset: 2px;
}

/* High contrast double ring */
:focus-visible {
 outline: 2px solid #000000;
 box-shadow: 0 0 0 4px #ffffff, 0 0 0 6px #000000;
}

/* For dark backgrounds */
.dark-section :focus {
 outline: 3px solid #ffffff;
 outline-offset: 2px;
}

Using :focus-visible

The:focus-visiblepseudo-class shows focus only for keyboard navigation, hiding it for mouse clicks:

/* Hide focus for mouse users, show for keyboard */
:focus {
 outline: none;
}

:focus-visible {
 outline: 3px solid #005EA2;
 outline-offset: 2px;
}

Never Do This

/* Removes focus for everyone! */
:focus {
 outline: none;
}
/* No :focus-visible fallback */

Do This Instead

:focus-visible {
 outline: 3px solid #005EA2;
 outline-offset: 2px;
}

Tab Order

Focus order should match the visual reading order (left-to-right, top-to-bottom in English).

Natural Tab Order

Tabindex Values

Value Behavior Use Case
tabindex="0" Added to natural tab order Custom interactive widgets
tabindex="-1" Focusable via JavaScript only Modal containers, error messages
tabindex="1+" Jumps ahead in order Avoid—causes confusion

Fixing Tab Order Issues

  1. Reorder your HTML to match the visual layout
  2. Use CSS Grid/Flexbox for visual positioning (doesn't affect tab order)
  3. Test by tabbing through your entire page

CSS Order Property

CSSorderin Flexbox/Grid changes visual order butnot tab order. This can create a confusing disconnect.

Managing Focus in Dynamic Content

Modal Dialogs

When opening a modal:

  1. Store the element that triggered it
  2. Move focus into the modal
  3. Trap focus inside (can't Tab out)
  4. On close, return focus to the trigger
// Native dialog (recommended)
const dialog = document.querySelector('dialog');
dialog.showModal(); // Handles focus automatically!

// Custom modal implementation
let previousFocus;

function openModal(modal) {
 previousFocus = document.activeElement;
 modal.hidden = false;
 modal.setAttribute('aria-modal', 'true');
 
 // Focus first focusable element or the modal itself
 const focusable = modal.querySelector(
 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
 );
 (focusable || modal).focus();
}

function closeModal(modal) {
 modal.hidden = true;
 modal.removeAttribute('aria-modal');
 previousFocus?.focus(); // Return focus
}

Focus Trapping

Modal dialogs must trap focus—pressing Tab at the last element wraps to the first:

function trapFocus(container) {
 const focusableSelector = 
 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
 const focusable = [...container.querySelectorAll(focusableSelector)];
 const firstFocusable = focusable[0];
 const lastFocusable = focusable[focusable.length - 1];

 container.addEventListener('keydown', (e) =>{
 if (e.key !== 'Tab') return;

 if (e.shiftKey) {
 // Shift+Tab on first element go to last
 if (document.activeElement === firstFocusable) {
 e.preventDefault();
 lastFocusable.focus();
 }
 } else {
 // Tab on last element go to first
 if (document.activeElement === lastFocusable) {
 e.preventDefault();
 firstFocusable.focus();
 }
 }
 });
}

Single-Page App Navigation

When the route changes in a SPA, focus should move to the new content:

// After route change
function onRouteChange() {
 const main = document.getElementById('main-content');
 main.setAttribute('tabindex', '-1');
 main.focus();
 
 // Or announce via live region
 announcer.textContent = `Navigated to ${document.title}`;
}

Accordion/Disclosure Widgets

// Focus stays on the trigger button
button.addEventListener('click', () =>{
 const expanded = button.getAttribute('aria-expanded') === 'true';
 button.setAttribute('aria-expanded', !expanded);
 panel.hidden = expanded;
 // Focus stays on button—don't move it
});

Deleted Items in Lists

When removing an item, move focus to a logical location:

function deleteItem(item) {
 const nextItem = item.nextElementSibling || item.previousElementSibling;
 const listHeading = item.closest('section')?.querySelector('h2');
 
 item.remove();
 
 // Focus next item, or the section heading if list is empty
 if (nextItem) {
 nextItem.querySelector('button')?.focus();
 } else {
 listHeading?.setAttribute('tabindex', '-1');
 listHeading?.focus();
 }
}

Testing Focus Management

  1. Unplug your mouse(or move it aside)
  2. Tab through the entire page—can you reach everything interactive?
  3. Watch the focus indicator—is it always visible?
  4. Test modals—does focus trap? Does it return on close?
  5. Test route changes—does focus move appropriately?
  6. Test with a screen reader—NVDA, VoiceOver, or JAWS

Browser DevTools

Chrome DevTools can track focus:

  1. Open DevTools (F12)
  2. Go to Console
  3. Run:document.addEventListener('focusin', e =>console.log(e.target))
  4. Tab through the page to see focus logged

Focus Management Checklist