Skip to content
Chapter 27Lesson 4

Managing keyboard focus

Moving keyboard focus on modals, route changes, and form submissions in a React and Next.js app.

A keyboard user is working down a list of invoices. They Tab to a row, press Enter on its link, and the next page loads. Their focus is still on the link they just clicked, a link that belonged to the page that no longer exists. The next Tab jumps somewhere arbitrary, and the user has lost their place. A mouse user would never notice, which is exactly why the bug ships.

On a plain multi-page site, every navigation is a full page load, and a full page load resets focus to the top of the new document. You got that reset for free, on every link, without thinking about it. Single-page apps swap content without reloading, which is faster, but they never hand the reset back. So a question the browser used to answer is now yours: where is focus, and where should it be?

This lesson answers that for the three situations a web app creates: a modal opening, a route changing, and a form submitting. For each, you will learn which the platform solves for you and which it leaves entirely to you.

First, one rule: you cannot find any of these bugs with a mouse. A click sets focus wherever it lands, hiding every focus mistake in this lesson. So unplug your mouse, or commit to Tab and Shift+Tab only, for the rest of this page.

This whole lesson rests on two primitives. Get them clear and the three situations are just applications.

At any moment, exactly one element on the page has focus: a single cursor with a position you can read. The browser exposes it as document.activeElement , the element holding the cursor. Tab moves the cursor forward through focusable elements in DOM order; Shift+Tab moves it back.

Native interactive elements, <button>, <a href>, <input>, <select>, and <textarea>, join that walk for free. Everything else, a <div>, an <h1>, a <section>, is not focusable by default, and Tab skips it. This is “semantic HTML first” seen from the focus side: a <button> is reachable by keyboard for the same reason you reached for it.

So you have two jobs, with two different tools.

Verb one: make a thing targetable. The value that matters here is tabindex="-1": focusable by script, skipped by Tab. It makes a non-interactive element such as an <h1> a legal focus target without dropping it into the user’s Tab sequence. The user never tabs to it; your code can still send focus there.

Verb two: move the cursor. element.focus() moves focus programmatically. For any page-level focus move, default to element.focus({ preventScroll: true }). Without it, the browser scrolls the focused element into view, which fights whatever owns your scroll position, such as the framework’s scroll restoration on a route change. With preventScroll: true, you move focus and leave scrolling to whoever governs it.

To focus a non-interactive element like a heading you need both verbs at once: tabindex="-1" so it can receive focus, and .focus({ preventScroll: true }) to move there.

const headingRef = useRef<HTMLHeadingElement>(null);
const focusHeading = () => {
headingRef.current?.focus({ preventScroll: true });
};
<h1 tabIndex={-1} ref={headingRef}>Invoices</h1>

This pairing is the source of the most common focus bug: “I called .focus() on my heading and nothing happened.” Nothing happened because a bare <h1> is not a focus target. .focus() is half the move and tabindex="-1" is the other half; you almost always need both.

The first situation is the one the platform handles for you. Your job is not to build anything, but to recognize what you already get and not break it.

Every overlay primitive shadcn ships is a Radix primitive underneath, and when a Radix Dialog opens it fulfills a four-part contract. This is the focus trap you met by name in the lesson on the four commitments, now at full depth:

  1. Focus moves into the dialog when it opens, to the first focusable element or the close button.
  2. Tab and Shift+Tab cycle within the dialog and cannot escape to the page behind it. Tab off the last element wraps to the first, and Shift+Tab off the first wraps to the last.
  3. Esc closes the dialog and returns focus to the trigger that opened it, so the user lands back exactly where they were.
  4. The content behind the dialog is made inert , neither focusable nor announced, so Tab and the screen reader both stay inside the dialog.

Every overlay in components/ui/ ships all four: Dialog, AlertDialog, Sheet, Drawer, Popover, DropdownMenu, and Command. Your job is to leave them intact: don’t strip the markup that carries the behavior, and don’t override the close-on-Esc focus return without a reason.

That four-part contract is the argument for never hand-writing a focus trap. A correct trap cycles Tab and Shift+Tab at both ends, copes with content that changes while the dialog is open so the focused element can vanish, contains focus even when it tries to escape through paths you don’t control like autofill or the address bar, and restores focus to the right trigger on close. A hand-rolled trap usually gets the cycling and none of the rest; Radix gets all four right, as does a dedicated library like focus-trap or react-focus-lock if you ever build a portal-rooted surface shadcn has no primitive for. You do not write a focus trap by hand.

One case the primitive can’t guess shows up constantly in a web app. Guarantee 3 assumes the trigger still exists, but picture a delete-confirmation AlertDialog opened from a table row where confirming deletes that row. The trigger was in the row, so once the row is gone there is nothing to return focus to and the cursor lands nowhere. Radix can’t know your mutation removed the trigger, so give the close an explicit destination: focus a stable nearby anchor, such as the list heading, in the dialog’s onCloseAutoFocus, where you can intercept the default return.

<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={deleteAccount}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

The common case, with nothing for you to do. The “Delete account” button lives on a settings page, so it stays mounted after the account data is gone. The dialog closes and Radix returns focus to the trigger for free — guarantee 3, handled.

So situation one is mostly recognition: the primitive owns the trap, you learn the contract so you trust it, and you keep the deleted-trigger move ready. The next two situations are where the platform stops helping.

Missing focus management on route changes is the most common accessibility regression in single-page apps, it is invisible to an automated audit, and it is where the “focus is state” thread pays off in a copyable pattern.

Here is the gap. On a <Link> click or a router.push, the Next.js App Router does a soft navigation and moves focus nowhere. It updates the URL and swaps the page content, but leaves the focus cursor where it was, which after the swap means the cursor points at an element that just unmounted. This is a known, long-standing limitation of the App Router: the full page-load reset the static web gave you is gone, and nothing replaced it.

That is the bug from the top of this lesson, now as a mechanism. Focus is stranded on the unmounted link, the next Tab is arbitrary, and nothing announced that the page changed, so a screen-reader user is left on a dead node with no idea where they are.

The fix is the pairing you already know, applied at the page level: focus the new page’s main heading when the route changes. That takes a small client component, mounted once in the layout, that watches the path and moves focus on every change.

'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export const RouteFocus = () => {
const pathname = usePathname();
useEffect(() => {
const frame = requestAnimationFrame(() => {
const heading = document.getElementById('page-heading');
heading?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [pathname]);
return null;
};

This runs in the browser and needs the current path, so it’s a client component. usePathname is the Next.js hook that hands us the route-change signal.

'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export const RouteFocus = () => {
const pathname = usePathname();
useEffect(() => {
const frame = requestAnimationFrame(() => {
const heading = document.getElementById('page-heading');
heading?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [pathname]);
return null;
};

The effect re-runs whenever pathname changes, so on every navigation. This is a legitimate effect: it synchronizes React with an external system, the router, which is exactly what effects are for.

'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export const RouteFocus = () => {
const pathname = usePathname();
useEffect(() => {
const frame = requestAnimationFrame(() => {
const heading = document.getElementById('page-heading');
heading?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [pathname]);
return null;
};

Focus must land after the new route paints, so we defer one frame and query the heading fresh rather than hold a ref to a node from the previous route that no longer exists. Get this wrong and the focus call silently does nothing.

'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export const RouteFocus = () => {
const pathname = usePathname();
useEffect(() => {
const frame = requestAnimationFrame(() => {
const heading = document.getElementById('page-heading');
heading?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [pathname]);
return null;
};

Scroll position on a route change is the framework’s job, through scroll restoration. preventScroll: true moves focus to the heading without fighting it, so scroll goes where the framework decides.

1 / 1

The target is an <h1 tabIndex={-1}> (or <main id="main-content" tabIndex={-1}>), the pairing from “the two verbs.” The tabindex="-1" makes the heading a legal focus target without putting it in anyone’s Tab order, and .focus({ preventScroll: true }) moves there. Every page has exactly one <h1>, so “the heading” is never ambiguous: there is one, and it’s the landing spot.

The bug ships because it is invisible, so it helps to see it. The diagram below follows the single focus cursor across a navigation, drawn as a marker you can see. Scrub through: the cursor sits on a link, the link unmounts under it, then the two endings part ways, the bug where the cursor is stranded and the fix where it snaps to the new heading.

app.acme.com/invoices
Invoices
Invoice #1042 Northwind Traders $4,200
focus
Invoice #1041 Globex Corp $1,980
Invoice #1040 Initech LLC $3,510
On the list page, focus is on the “Invoice #1042” link.
app.acme.com/invoices/1042
Invoice
Invoice #1042
Billed to
Northwind Traders
Amount due
$4,200
focus — on an unmounted node
Enter triggers a soft navigation. The link unmounts — focus is now on a node that no longer exists.
app.acme.com/invoices/1042
Invoice
Invoice #1042
Billed to
Northwind Traders
Amount due
$4,200
the bug
Without RouteFocus: the next Tab lands somewhere arbitrary — the browser chrome, or the top of the body. The bug.
app.acme.com/invoices/1042
Invoice
Invoice #1042
the fix
Billed to
Northwind Traders
Amount due
$4,200
With RouteFocus: focus snaps to the new page’s heading. The fix.

A second pattern shares this mechanism, so it’s cheap to add now: the skip link. It is a link that focuses <main tabindex="-1">, the same target shape and the same tabindex="-1". It exists for keyboard users who don’t want to Tab through your entire nav on every page, so you make it the first focusable element in the layout. It stays visually hidden until it receives focus, then reveals itself, and activating it jumps the cursor straight past the nav into the main content.

<a href="#main-content" className="sr-only focus:not-sr-only ...">
Skip to content
</a>
...
<main id="main-content" tabIndex={-1}>
{children}
</main>

sr-only is the visually-hidden utility from the lesson on ARIA, and focus:not-sr-only reveals it the moment a keyboard user Tabs to it. The skip link is cheap to build and a concrete differentiator on a 2026 accessibility audit, the kind of thing that surfaces in a procurement review. Automated tools won’t flag a missing one.

Now build one. The exercise below hands you a layout with a header, a nav, and a main region, and no skip link. Add one, and watch the catch: the anchor pointing at #main-content does nothing unless <main> is actually a focus target.

Add a skip link as the first focusable element in this layout. It must point at the main region, stay visually hidden until it's focused, and the main region must be a valid focus target. Tab into the preview to watch the link reveal itself.

Preview
    Reference solution

    The skip link is the layout’s first child, sr-only until it receives focus, and it points at <main>, which carries id="main-content" and tabIndex={-1} so the cursor has somewhere to land.

    export function App() {
    return (
    <>
    <a href="#main-content" className="sr-only focus:not-sr-only">
    Skip to content
    </a>
    <header>
    <nav>
    <a href="/">Home</a>
    <a href="/invoices">Invoices</a>
    </nav>
    </header>
    <main id="main-content" tabIndex={-1}>
    <h1>Invoices</h1>
    </main>
    </>
    );
    }

    The third situation is form submission. To reason about it, forget forms for a second and ask only: after the submit resolves, what happened to the screen? There are three answers, and focus goes somewhere different in each.

    It succeeded and navigated. A create flow that redirects you to the new resource, say. You do nothing: the route-change pattern from the last section already fired, and focus is on the new page’s <h1>.

    It succeeded and stayed put. An inline edit, or a posted comment. Move focus to an anchor you choose deliberately, usually the action that started the submit (the edit button you can press again), the next field, or the success indicator. If you render a success message, give it role="status" (from the lesson on ARIA) so a screen reader hears it. The focus move and the announcement are two halves of one event.

    It failed with errors. Focus the first field with an error, and announce that field’s error through the live region. Wire the input to an error element with aria-describedby, give the error role="alert", and the screen reader reads the label, the value, and the error in one pass: “Email, not-an-email, Enter a valid email address.” One focus move, one coherent announcement, everything the user needs.

    Here is the focus-and-announce skeleton for that third case, stripped of the form machinery so the two moves stand out.

    const firstErrorRef = useRef<HTMLInputElement>(null);
    const errorId = useId(); // stable id for the label/error wiring — useId is the forms chapter's job
    // Form submission + validation live in the forms chapter — this is the focus + announce half.
    const focusFirstError = () => {
    firstErrorRef.current?.focus();
    };
    <input ref={firstErrorRef} aria-describedby={errorId} />
    <p id={errorId} role="alert">Enter a valid email address.</p>

    This is a sketch. The real machinery around it, the Server Action, useActionState, generating ids with useId, aria-invalid, and rendering the error tree, belongs to the forms chapter (Chapter 44). This lesson owns one half: move focus to the first error, and make sure something announces it.

    All three cases share one rule, the one genuinely new idea in this lesson.

    That rule has a sharp edge. Once you can move focus, it’s tempting to move it on every state change: every keystroke, every re-render, every small update. That is over-focusing, and each move interrupts the screen reader, so a UI that re-focuses constantly cuts off its own announcements.

    React’s autoFocus prop focuses an element when it mounts. It’s right in a narrow set of cases and wrong in a couple of important ones.

    It’s right on single-purpose landing screens where the user’s intent is clear the instant the page appears: a sign-in screen’s email field, a one-input search page, a command palette that just opened. There’s only one thing to do, so focusing it for the user is a kindness.

    It’s wrong on multi-section forms, where it takes focus before the screen reader has read the surrounding context the user needs. It’s also wrong on dialogs, where Radix already manages initial focus and autoFocus only fights it: let the primitive own initial focus. One note: autoFocus fires per mount, so a component that remounts fires it again, which can surprise you if the element comes and goes.

    Two focus mistakes fall outside the three situations but come up constantly. In both, the obvious move is the wrong one.

    Tab order is DOM order, full stop. Visual reordering with CSS, whether flex-direction: row-reverse, order: -1, grid-template-areas, or a flex-wrap reflow, moves pixels, not the cursor. So a user can see your sidebar on the right and your main content on the left, press Tab, and land in the sidebar first, because the sidebar comes first in the DOM. The order they read and the order they Tab through have diverged.

    The fix is not the tempting one. If the focus order is wrong, the markup order is wrong, so reorder the DOM. Don’t patch it with a positive tabindex: that scrambles the rest of the page to cover up one spot. When you need a mirrored layout (right-to-left, say), reach for the logical properties from Chapter 020, which flip the layout without touching source order, so the DOM order stays the reading order.

    disabled vs aria-disabled: discoverability decides

    Section titled “disabled vs aria-disabled: discoverability decides”

    To disable a control, you reach for the native disabled attribute. Usually that’s right: disabled removes the control from the Tab order and announces “disabled,” so a keyboard user tabs straight past it.

    But sometimes skipping it is the problem. Picture a submit button that’s disabled until a form validates. A native disabled button is skipped by Tab, so a screen-reader user working down the form never lands on it, never discovers it, and never hears why they can’t submit. The control you disabled is invisible to the user who most needs the explanation.

    That’s the case for aria-disabled="true". It keeps the element focusable and discoverable: the user can Tab to it, hear it, and hear the reason if you pair it with a role="status" message. But it does not block activation the way native disabled does, so you take on one job in exchange. Guard the handler yourself, early-returning from onClick while the disabled state holds.

    Default to native disabled, and reach for aria-disabled only when the disabled control must stay discoverable. The walker works through the decision.

    Disabling a control: which attribute?

    Most of the time you move focus to native elements or shadcn primitives, which already show a focus ring. But the moment you make something focusable that isn’t natively interactive, like a clickable card or a custom row with tabindex="0", you’ve created a target a keyboard user can land on but cannot see. A focus cursor you can’t see is its own bug, so give it a ring:

    <div
    tabIndex={0}
    className="rounded-lg border p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
    >
    ...
    </div>

    These are the canonical classes. focus-visible:outline-none drops the default outline, and focus-visible:ring-2 focus-visible:ring-ring draws a ring in shadcn’s semantic --ring token, the same one the rest of your app uses. :focus-visible rather than :focus shows the ring for keyboard focus and not for mouse clicks. This is the other half of a rule you met earlier: never remove a focus ring to tidy a design, restyle it, because a removed ring leaves a keyboard user with no idea where they are.

    The platform solves one of the three situations for you, the modal trap owned by Radix, and leaves you the other two. On a route change focus moves nowhere, so you send it to the new heading; on a submission you move focus by outcome and pair every move with an announcement. Underneath both is one cursor you can read and move, and two verbs: tabindex="-1" to make a target and .focus({ preventScroll: true }) to move there.

    Each claim is about where focus is — and whose job it is to put it there. Mark each statement True or False.

    Next.js App Router automatically moves focus to the new page on client-side navigation.

    It moves focus nowhere — the cursor stays on the now-unmounted element. Restoring focus to the new page’s heading is the engineer’s job, and it’s the most-shipped SPA accessibility regression.

    tabindex="-1" puts an element in the Tab order.

    It makes an element focusable by script while keeping it out of the Tab order — the user can’t Tab to it, but your code can .focus() it. That’s exactly what you want for a heading you focus on a route change.

    When a custom modal needs a focus trap, the right move is to hand-write one with keydown handlers.

    A correct trap handles cycling at both ends, content changing while open, focus escaping via autofill/devtools, and return-focus restoration. Use the Radix primitive, or a library for a custom surface — never your own handlers.

    Moving focus to an input whose error is in a role="alert" element (via aria-describedby) lets a screen reader announce the label and the error together.

    This is the submission pattern to reach for — one focus move produces one coherent announcement of label, value, and error.

    Reordering elements visually with CSS order also changes the Tab order.

    Tab follows DOM order; CSS moves pixels, not the focus cursor. If the focus order is wrong, the markup order is wrong — reorder the DOM, never patch it with tabindex.

    A button disabled with the native disabled attribute is still reachable by Tab.

    Native disabled removes it from the Tab order entirely. That’s exactly why aria-disabled exists — for the case where a disabled control must stay discoverable so the user can hear why it’s inert.

    element.focus({ preventScroll: true }) moves focus without scrolling the element into view.

    It’s the default to reach for on page-level focus moves — focus goes where you send it while scroll stays governed by the framework’s scroll restoration, so the viewport doesn’t jump.

    You now know where focus is and how to move it, but a screen that’s loading, empty, or broken has nothing to focus yet. The next lesson is about those four states, loading, empty, error, and populated, and designing all four instead of only the happy path.