Development

Focus Management: The Accessibility Skill Most Developers Skip

Where keyboard focus goes after a click, a route change, or a closed dialog decides whether your app is usable. A practical guide to focus order, focus-visible, focus trapping, roving tabindex, and restoring focus — with copy-paste patterns.

Khushwant Parihar
Khushwant Parihar
12 min read

Most accessibility work is about what the user can see and read. Focus management is about something quieter: where the keyboard is right now. When someone navigates with the Tab key, a screen reader, a switch device, or voice control, the focused element is their cursor, their pointer, and their sense of place all at once. Move it carelessly — or lose track of it entirely — and the interface becomes a maze.

This guide covers the focus decisions that come up in real applications: styling the focus indicator, ordering focusable elements, sending focus into and out of dialogs, building composite widgets, and putting focus somewhere sensible after a single-page route change. Every pattern here is something you can apply the same afternoon.

What "focus" actually is

Exactly one element in a document holds keyboard focus at any time — you can read it in JavaScript as document.activeElement. Focus is what receives key events, what a screen reader announces as you move, and what a browser scrolls into view. Interactive HTML elements (links, buttons, inputs, selects, textareas) are focusable by default and appear in the tab order. Everything else is not, unless you opt in with tabindex.

The three rules of tabindex are short and rarely bent:

  • tabindex="0" adds an element to the natural tab order, in DOM position. Use it for custom interactive components built from non-interactive elements.
  • tabindex="-1" makes an element programmatically focusable — you can call .focus() on it — but keeps it out of the Tab sequence. This is the workhorse of focus management.
  • A positive tabindex ("1" or higher) forces an element earlier in the tab order than the DOM would suggest. It almost always creates surprises for other elements on the page. Treat it as an anti-pattern.

Rule one: never delete the focus indicator

The single most common accessibility regression is the CSS reset that removes the focus outline. WCAG 2.2 Success Criterion 2.4.7 Focus Visible requires a visible indicator on any keyboard-focusable element, and 2.4.11 Focus Not Obscured means that indicator must not be hidden behind sticky headers, cookie bars, or other overlays.

The fix is not to keep the browser default forever; it is to design a deliberate indicator and show it when it matters. The :focus-visible pseudo-class lets you show a strong ring for keyboard users while suppressing it for mouse clicks, which is usually why teams removed the outline in the first place.

focus.css
/* Never do this on its own */
:focus { outline: none; }

/* Do this instead: a clear, non-color-only indicator */
:focus-visible {
  outline: 3px solid #1a56db;
  outline-offset: 2px;
  border-radius: 3px;
}

/* Keep a fallback for browsers without :focus-visible */
:focus:not(:focus-visible) { outline: none; }

Give the ring enough contrast against both the component and the page background, and at least a 3:1 contrast ratio to satisfy SC 1.4.11 Non-text Contrast. An outline plus an offset reads clearly on light and dark surfaces without relying on color alone.

Rule two: keep the tab order logical

SC 2.4.3 Focus Order requires that focus moves in an order that preserves meaning and operability. In practice, that means your DOM order should match your visual reading order. When you reposition content with CSS — flexbox order, grid placement, absolute positioning — the tab sequence still follows the DOM, so a visually top-right button might receive focus last.

  • Order elements in the DOM the way they should be read and operated, then style position with CSS.
  • Avoid the CSS order and grid-placement properties for anything interactive unless the visual and DOM order still agree.
  • Never use positive tabindex to patch a broken order — fix the DOM instead.
  • When new content appears (an expanded panel, an inserted row), make sure it lands in a sensible place in the sequence rather than at the end of the page.

Skip links: the fastest win

A keyboard user should not have to tab through fifty navigation links to reach the main content on every page. A skip link is the first focusable element on the page; it is visually hidden until focused, and it moves focus to the main region when activated.

skip-link.html
<a class="skip-link" href="#main">Skip to main content</a>
<!-- ... header, nav ... -->
<main id="main" tabindex="-1">
  <!-- page content -->
</main>

The tabindex="-1" on the target matters: without it, some browsers move the document position but not keyboard focus, so the next Tab press starts from the top again. Style the link to sit off-screen and animate into view on :focus so sighted keyboard users can see where they are jumping.

Focus trapping in dialogs

When a modal dialog is open, focus must stay inside it. If Tab can escape to the page behind the dialog, a screen-reader user can silently wander into content they cannot see, with no way to know the dialog is still there. A correct dialog does four things:

  • Moves focus into the dialog when it opens — to the first meaningful control, the heading, or the close button, but not into an empty container.
  • Loops Tab and Shift+Tab within the dialog so focus cannot leave while it is open.
  • Closes on the Escape key.
  • Restores focus to the element that opened it when it closes — usually the trigger button.

The last point is the one teams forget. If you open a dialog, close it, and drop focus back to the top of the document, the user loses their place completely. Capture the trigger before opening and return to it after closing.

useReturnFocus.ts
function useReturnFocus(isOpen: boolean) {
  const triggerRef = useRef<HTMLElement | null>(null)

  useEffect(() => {
    if (isOpen) {
      // Remember what had focus before we opened
      triggerRef.current = document.activeElement as HTMLElement
    } else if (triggerRef.current) {
      // Return focus when we close
      triggerRef.current.focus()
      triggerRef.current = null
    }
  }, [isOpen])
}

Native <dialog> with showModal() gives you trapping, Escape handling, and an inert background for free, and is the recommended starting point today. If you build a custom dialog, follow the modal dialog pattern and pair it with the ARIA Authoring Practices dialog guidance rather than reinventing the keyboard behavior.

Composite widgets: one tab stop, arrow keys inside

Menus, tabs, toolbars, radio groups, tree views, and grids should not put every child in the tab order. A toolbar with twelve buttons should be a single tab stop; the user reaches it once with Tab, then moves between the buttons with arrow keys. There are two accepted techniques for this.

Roving tabindex

At any moment, exactly one item in the group has tabindex="0" and the rest have tabindex="-1". Arrow keys move the tabindex="0" to a new item and call .focus() on it. This keeps DOM focus on a real element, which is the most robust approach across assistive technology.

Toolbar.tsx
function Toolbar({ actions }: { actions: Action[] }) {
  const [active, setActive] = useState(0)
  const refs = useRef<(HTMLButtonElement | null)[]>([])

  function onKeyDown(e: React.KeyboardEvent) {
    const last = actions.length - 1
    let next = active
    if (e.key === 'ArrowRight') next = active === last ? 0 : active + 1
    else if (e.key === 'ArrowLeft') next = active === 0 ? last : active - 1
    else return
    e.preventDefault()
    setActive(next)
    refs.current[next]?.focus()
  }

  return (
    <div role="toolbar" aria-label="Formatting" onKeyDown={onKeyDown}>
      {actions.map((a, i) => (
        <button
          key={a.id}
          ref={(el) => (refs.current[i] = el)}
          tabIndex={i === active ? 0 : -1}
          onClick={a.run}
        >
          {a.label}
        </button>
      ))}
    </div>
  )
}

aria-activedescendant

The alternative keeps real DOM focus on a container (a listbox or combobox input) and points aria-activedescendant at the id of the "virtually" focused child. It suits large lists and comboboxes where moving real focus per item is awkward. Roving tabindex is the safer default for most menus, tabs, and toolbars; reach for aria-activedescendant when a text input must retain focus while a related list is navigated.

Focus after single-page navigation

In a multi-page site, clicking a link loads a new document and the browser resets focus to the top — screen readers announce the new page title automatically. Single-page apps break that contract: the URL changes, the view swaps, but focus stays on the now-removed link, and nothing is announced. Users are stranded on stale content.

After a client-side route change, move focus to a sensible landing point — usually the new page’s H1 or main region — so the next Tab continues from the right place and screen readers register the change.

RouteFocus.tsx
function RouteFocus() {
  const pathname = usePathname()
  const headingRef = useRef<HTMLHeadingElement>(null)

  useEffect(() => {
    // Focus the new page heading after navigation
    headingRef.current?.focus()
  }, [pathname])

  return (
    <h1 ref={headingRef} tabIndex={-1}>
      {/* page title */}
    </h1>
  )
}

Prefer focusing the heading or main landmark over a generic live-region announcement; moving real focus both announces the change and repositions the keyboard. Do not scroll the page unexpectedly — pass { preventScroll: true } to .focus() when you manage scrolling separately.

Common focus-management mistakes

  • Removing outlines globally with outline: none and never adding a replacement.
  • Calling .focus() on a container that has no accessible name, so the screen reader announces nothing useful.
  • Setting focus on page load into a search box or ad, hijacking the user before they have oriented.

Triggering a context change purely because an element received focus, which fails SC 3.2.1 On Focus — focus should never, by itself, submit a form, open a new window, or move focus elsewhere.

Creating a keyboard trap where focus enters a widget and cannot leave with standard keys, which fails SC 2.1.2 No Keyboard Trap. A dialog traps focus on purpose and releases it on close; an accidental trap has no exit.

  • Leaving focus on a button that gets removed or disabled after a click, dropping focus to the body.
  • Rendering off-screen or hidden content that is still in the tab order — hide it with display:none, the hidden attribute, or inert so it leaves the sequence.

How to test focus management

Focus problems are invisible in a static screenshot and often invisible to automated scanners, so test by driving the keyboard yourself.

  • Put the mouse away. Tab through the whole page from the top and confirm you can see the focus indicator on every stop.
  • Check that the order matches the visual reading order and that focus never jumps somewhere surprising.
  • Open every dialog, menu, and popover; confirm focus enters, cannot escape while open, closes on Escape, and returns to the trigger.
  • Operate composite widgets with arrow keys and verify they are a single tab stop.
  • Navigate between routes and confirm focus lands somewhere sensible each time.

Add a screen reader to the pass — NVDA, VoiceOver, or JAWS — because focus that looks fine visually can still announce the wrong thing. Then run the interaction through the WCAG 2.2 checklist alongside your broader keyboard accessibility review.

Frequently asked questions

Is it ever acceptable to use outline: none?

Yes — when you replace it with an equally visible indicator, such as a box-shadow ring or a border change with sufficient contrast. What fails WCAG is removing the indicator with nothing in its place. Scope removal to :focus:not(:focus-visible) so keyboard users still get a ring.

What is the difference between :focus and :focus-visible?

:focus matches whenever an element is focused, including after a mouse click. :focus-visible matches only when the browser heuristically decides an indicator should be shown — typically keyboard and other non-pointer interactions. Use :focus-visible for the visible ring so mouse users are not distracted while keyboard users are supported.

Should I move focus to the modal container or to a control inside it?

Move focus to a meaningful element: the first interactive control, the dialog heading, or the close button. Avoid focusing an empty wrapper. If you focus the dialog element itself, give it an accessible name with aria-labelledby so the screen reader announces its purpose.

Do I still need focus management with a framework component library?

Mostly the library handles trapping and roving tabindex for you, but you still own the seams: restoring focus after your own custom flows, focusing after route changes, and not deleting the focus indicator in your global styles. Verify the behavior rather than assuming it.

The bottom line

Focus management is a small set of habits: keep a visible indicator, keep the DOM order honest, trap focus in dialogs and give it back on close, make composite widgets a single tab stop, and land focus somewhere sensible after every navigation. None of it requires new tools — just tabbing through your own interface with the mouse set aside and fixing what you find.

Sources

W3C WAI: Understanding SC 2.4.3 Focus Order

W3C WAI: Understanding SC 2.4.7 Focus Visible

ARIA Authoring Practices Guide: Developing a Keyboard Interface

MDN: :focus-visible pseudo-class

Khushwant Parihar
Written by
Khushwant Parihar

Khushwant Parihar is the founder of Accessibility.build and an accessibility specialist, consultant, and developer with more than four years of professional accessibility and software testing experience. His work covers WCAG auditing, keyboard and screen-reader testing with NVDA, JAWS, and VoiceOver, accessible frontend implementation, remediation workflows, and team enablement.

Found this useful? Share it.

Essential Accessibility Resources

Comprehensive tools, checklists, and guides to help you create inclusive digital experiences

Top Pick

Focus Management Guide

tabindex, :focus-visible, focus traps, restoration, roving tabindex, skip links, and route-change focus, mapped to WCAG 2.2
focus management
tabindex
focus-visible
+2 more
View guide
Top Pick

Complete Keyboard Accessibility Guide

Definitive guide to keyboard accessibility with interactive demos, code examples, and testing checklists
keyboard accessibility
focus management
roving tabindex
+1 more
View guide
Top Pick

WCAG 2.4.7 Focus Visible Guide

Complete guide to visible keyboard focus indicators: why never to remove the outline, :focus-visible, contrast and thickness, forced-colors support, code examples, and testing
focus-visible
keyboard accessibility
focus management
View guide

Other posts filed under Development.