Skip to content

Keyboard Navigation and Focus Management

Aman Kumar Singh8 min read
Part 35 of 50From the React & Next.js Complete Guide series
Keyboard Navigation and Focus Management — article by Aman Kumar Singh

In Accessibility in React Apps, I covered semantic HTML, ARIA attributes, and how screen readers interpret the DOM tree you hand them. That article treats accessibility broadly. This one narrows in on a single piece of it that ends up being the most common source of production bugs: keyboard navigation and focus management.

This is part of the React & Next.js Complete Guide series, which builds up from fundamentals toward production-grade Next.js applications. Focus is one of those things that works by default. Then you build a modal, a dropdown, a multi-step form, or a client-side route transition, and the browser's built-in behavior stops being enough. You have to take over.

Most teams don't think about focus until a bug report shows up: "I opened the modal and now Tab does nothing useful," or "after I close this dialog, my keyboard focus vanished." These aren't edge cases affecting a tiny slice of users. Anyone navigating without a mouse, whether by choice, disability, or a broken trackpad, depends on focus being managed correctly.

Why the browser's default focus handling isn't enough

By default, the browser moves focus through the DOM in document order whenever you press Tab, following the tabbing sequence of naturally focusable elements: links, buttons, inputs, and anything with a non-negative tabindex. For a simple page, this works fine. The problem starts the moment your app renders content dynamically without a full page load.

When React mounts a modal on top of the page, the DOM order hasn't changed in any way the browser understands as "this is now the primary content." Focus stays wherever it was before the modal opened, usually on the button that triggered it. A sighted mouse user doesn't notice, because they'll click into the modal directly. A keyboard user tabs forward and lands on whatever was next in the underlying page, often invisible behind the modal overlay. They have no idea where they are.

The same problem shows up with client-side route transitions in Next.js. A full page navigation resets focus to the document body and a screen reader announces the new page title. A client-side transition (App Router navigation, a Link click that doesn't trigger a full reload) does neither by default. Focus stays on the link that was clicked, which is now on a page that no longer semantically exists in the same way. The user has moved to a new route with no indication that anything happened.

None of this is a bug in React or Next.js. It's a consequence of building an app that manipulates the DOM instead of navigating between server-rendered documents, and it means the responsibility for signaling those transitions to assistive technology moves from the browser to your code.

Trapping focus inside a modal

A modal has to do two things to be keyboard-usable: keep focus contained within it while it's open, and hand focus back to a sensible place when it closes. Here's a FocusTrap component built around a ref and a keydown listener, without pulling in a dependency:

import { useEffect, useRef } from "react";

type FocusTrapProps = {
  children: React.ReactNode;
  active: boolean;
  onClose: () => void;
};

const FOCUSABLE_SELECTOR = [
  "a[href]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  '[tabindex]:not([tabindex="-1"])',
].join(",");

export function FocusTrap({ children, active, onClose }: FocusTrapProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previouslyFocusedRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!active) return;

    previouslyFocusedRef.current = document.activeElement as HTMLElement;

    const container = containerRef.current;
    const focusables = container?.querySelectorAll<HTMLElement>(
      FOCUSABLE_SELECTOR
    );
    focusables?.[0]?.focus();

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        onClose();
        return;
      }

      if (event.key !== "Tab") return;

      const nodes = container?.querySelectorAll<HTMLElement>(
        FOCUSABLE_SELECTOR
      );
      if (!nodes || nodes.length === 0) return;

      const first = nodes[0];
      const last = nodes[nodes.length - 1];

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

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      previouslyFocusedRef.current?.focus();
    };
  }, [active, onClose]);

  return (
    <div ref={containerRef} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}

Three pieces make this correct rather than just plausible. First, focus moves into the modal the moment it opens, onto the first focusable element, so a keyboard user isn't left tabbing through a page they can't see. Second, the keydown handler wraps Tab and Shift+Tab at the boundaries, so the trap actually holds; without that check, Tab-ing past the last button in the modal would escape into the page underneath. Third, and this is the one people skip most often, the cleanup function restores focus to whatever had it before the modal opened. Without that, closing a modal drops focus onto document.body, and the user's next Tab press starts over from the top of the page.

If your app already depends on Radix UI, Headless UI, or a similar library, use their focus trap primitives instead of this one. The logic above is worth understanding, but reimplementing it across every modal is exactly the kind of complexity worth deferring until a library's implementation doesn't fit. Edge cases around dynamically added focusable elements, iframes, and shadow DOM are easy to get subtly wrong by hand.

Announcing route changes without a full page reload

Next.js App Router navigations don't trigger a browser-level page load, which means the two things a full navigation gives you for free, focus reset and a screen reader announcement of the new page, don't happen automatically. You have to do both yourself.

A common pattern is a visually hidden live region that announces the new page title after navigation completes, paired with moving focus to a heading or a skip-to-content landmark:

"use client";

import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";

export function RouteAnnouncer() {
  const pathname = usePathname();
  const liveRegionRef = useRef<HTMLDivElement>(null);
  const isFirstRender = useRef(true);

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }

    const heading = document.querySelector<HTMLElement>("h1");
    heading?.setAttribute("tabindex", "-1");
    heading?.focus();

    if (liveRegionRef.current) {
      liveRegionRef.current.textContent = document.title;
    }
  }, [pathname]);

  return (
    <div
      ref={liveRegionRef}
      role="status"
      aria-live="polite"
      style={{
        position: "absolute",
        width: "1px",
        height: "1px",
        overflow: "hidden",
        clip: "rect(0 0 0 0)",
      }}
    />
  );
}

This moves focus to the page's h1 on every route change and updates an aria-live="polite" region with the document title, which most screen readers pick up and read aloud. The tabindex="-1" on the heading is what allows a non-interactive element to receive programmatic focus at all; without it, calling .focus() on a heading silently does nothing.

Skipping the first render matters because you don't want the announcer firing on initial page load, when the browser has already handled focus and title correctly through its own mechanism. Mount RouteAnnouncer once, near the root layout, so it applies across every route rather than being wired into individual pages.

Managing focus in multi-step forms and wizards

Multi-step flows have a different focus problem: each step replaces the content of the previous one, but nothing signals to a keyboard or screen reader user that the step actually changed. If focus stays on the "Next" button while the form content underneath swaps out, a screen reader user has no cue that anything happened until they start tabbing and encounter unfamiliar fields.

The fix follows the same shape as the route announcer: move focus to something meaningful in the new step, usually its heading or its first input, and do it deliberately rather than relying on default behavior.

import { useEffect, useRef } from "react";

function WizardStep({
  step,
  title,
  children,
}: {
  step: number;
  title: string;
  children: React.ReactNode;
}) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, [step]);

  return (
    <div>
      <h2 ref={headingRef} tabIndex={-1}>
        {title}
      </h2>
      {children}
    </div>
  );
}

Whether you focus the heading or the first input is a judgment call. The heading works better when a step includes explanatory copy the user needs to read first; the first input is faster for repetitive, well-understood forms. Either is fine as long as it stays consistent across the whole wizard.

Production pitfalls worth knowing up front

A few mistakes show up often enough to call out directly.

Setting tabindex values greater than 0 creates a manual tab order that doesn't match the DOM's visual order and is fragile as the component tree changes. Avoid it almost entirely. tabindex="0" and tabindex="-1" cover nearly every real need.

Removing an element from the DOM while it has focus, a common outcome of conditionally rendering a component, silently drops focus to document.body with no warning in development. Check this explicitly in any component that conditionally unmounts based on user interaction: a delete button removing its own row, a tab panel switching content, and so on.

Suppressing the focus ring globally with outline: none and no replacement hides focus-management bugs from sighted developers testing by eye, since there's no visual cue that focus landed somewhere unexpected. Use :focus-visible to keep a visible ring for keyboard interaction without adding one on every mouse click.

Testing this by hand matters more than it seems worth. Unplug the mouse and complete your core flows: open and close a modal, submit a multi-step form, navigate between pages. It takes a few minutes and catches bugs that automated accessibility linters mostly miss, since they check for the presence of ARIA attributes, not whether focus actually lands where a user needs it to.

Key takeaways

  • The browser only manages focus automatically for full page navigations. Client-side rendering, modals, and route transitions all require you to move focus deliberately.
  • A correct focus trap moves focus in on open, wraps Tab/Shift+Tab at the container's boundaries, and restores focus to the trigger element on close.
  • Next.js App Router navigations don't reset focus or announce the new page. Move focus to a heading and use an `aria-live` region to fill that gap.
  • Multi-step forms need the same treatment per step: move focus to something meaningful in the new step so the transition isn't invisible to keyboard and screen reader users.
  • Reach for a maintained library (Radix UI, Headless UI) for focus traps in production rather than hand-rolling one everywhere; understand the logic, but don't reimplement it repeatedly.
  • Never suppress `:focus` styling without a `:focus-visible` replacement. A missing focus ring hides focus-management bugs from sighted testing.

Frequently asked questions

Why does Tab stop working correctly after I open a modal in React?

Because opening a modal only changes what's rendered, not where focus is. The browser doesn't know the modal is now the primary interactive surface, so Tab continues through the underlying page's DOM order unless you explicitly trap focus inside the modal container.

Do I need `aria-modal="true"` if I already trap focus with JavaScript?

Yes. `aria-modal="true"` tells assistive technology to treat everything outside the dialog as inert, which is a semantic signal separate from the mechanical focus trap. Screen readers use it to stop announcing background content even in cases where the visual focus trap alone wouldn't be enough.

Should I use `tabindex="0"` to make a `div` focusable instead of using a real button?

Only as a last resort. A `<button>` gives you focusability, keyboard activation, and the correct implicit role for free. A `div` with `tabindex="0"` gives you focusability alone; you'd still need `role="button"` and manual keydown handlers for Enter and Space, which is more code for a worse result.

How do I test keyboard navigation without a screen reader?

Unplug the mouse and complete your core user flows with Tab, Shift+Tab, Enter, and Escape alone. This catches most focus-trap and focus-restoration bugs. Pair it with a periodic screen reader pass (VoiceOver, NVDA), since some issues, like live region announcements, only surface with one running.

Does Next.js give me any of this for free?

Not much, beyond what the browser itself provides for full page loads. App Router client-side transitions are intentionally lightweight and don't include automatic focus management or route announcements, so it's on the application to add a route announcer and manage focus explicitly, as covered above.

What's the difference between `tabindex="-1"` and removing an element from the tab order with CSS?

`tabindex="-1"` removes an element from the natural Tab sequence while still allowing it to receive focus programmatically via `.focus()`, which is exactly what you need for headings and containers you want to focus after a route or step change. `visibility: hidden` or `display: none` remove an element from focus entirely, including programmatic focus. Use those for content that's genuinely absent, rather than merely skipped in tab order.

Related articles

Dark Mode Done Properly — Aman Kumar Singh
Building Tables and Data Grids — Aman Kumar Singh
Mutations and Optimistic Updates — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.