Skip to content
Chapter 27Lesson 4

Where focus belongs

Managing keyboard focus in a React and Next.js app, moving the focus cursor on modals, route changes, and form submissions so keyboard and screen-reader users never lose their place.

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 now sitting on the link they just clicked, except that link belonged to the page that no longer exists. The next Tab does something arbitrary: it jumps to the browser’s address bar, or to the top of <body>, or somewhere else entirely depending on the engine. The user has lost their place. A mouse user would never notice this, which is exactly why the bug ships: it is invisible to the person who wrote it.

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

This lesson answers that question for the three situations a SaaS app creates, a modal opening, a route changing, and a form submitting. Just as important, it tells you which of those the platform already solves and which it leaves entirely to you. By the end you will be able to spot where focus becomes your responsibility, rather than just memorizing three snippets.

One thing has to come first: you cannot find any of these bugs with a mouse. A mouse click sets focus wherever it lands, so it hides every focus mistake in this lesson. The detection tool is the one from the lesson on the four commitments. Unplug your mouse, or commit to Tab and Shift+Tab only, for the rest of this page. Without that, none of what follows will feel real.

Before the three situations, you need two primitives. Once these are clear, the rest of the lesson is just applying them.

Start with the model. At any moment, exactly one element on the page has focus: there is a single cursor, and it has a position. You can read where it is, because the browser exposes it as document.activeElement , the one element holding the cursor. Tab moves that cursor forward through the focusable elements in DOM order, and Shift+Tab moves it back. That is the whole model: one cursor, walking the DOM.

Not everything is in that walk. Interactive native elements, <button>, <a href>, <input>, <select>, and <textarea>, join the Tab order for free, which is part of what you bought when you chose them over a <div>. Everything else, a <div>, an <h1>, a <section>, is not focusable by default, and Tab skips right over it. This is the same “semantic HTML first” reflex from the lesson on the four commitments, seen from the focus side: a <button> is reachable by keyboard for the same reason you reached for it in the first place.

That leaves two things you can do to the cursor. They are different jobs, and they use different tools.

Verb one: make a thing targetable. You already met tabindex and its three values in the lesson on the four commitments, so this just names the one value this lesson actually uses. tabindex="0" joins the Tab order in DOM position, which is rare in a shadcn codebase because a native control almost always fits. Positive values are the anti-pattern that scrambles tab order globally, so leave them alone. The value that matters here is tabindex="-1": focusable by script, skipped by Tab. It makes a non-interactive element such as an <h1> or a <main> a legal focus target without dropping it into the user’s Tab sequence. The user never tabs to it, but your code can still send focus there.

Verb two: move the cursor. element.focus() moves focus to an element programmatically. For any page-level focus move, treat one option as the default: element.focus({ preventScroll: true }). Without it, the browser scrolls the focused element into view as a side effect, which fights whatever owns your scroll position (the framework’s scroll restoration, on a route change) and can pull the viewport somewhere the user didn’t ask to go. With preventScroll: true, you move focus and leave scrolling to whoever governs it, so the page stays put.

Together those two verbs add up to one pairing you will use throughout the lesson. 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 actually move there.

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

Hold on to this pairing, because it is the source of the most common focus confusion there is: “I called .focus() on my heading and nothing happened.” Nothing happened because a bare <h1> is not a focus target. .focus() alone is half the move, and tabindex="-1" is the other half, and you almost always need both. You will see this exact pairing twice more before the lesson is over.

Modals already solve it: the focus-trap contract

Section titled “Modals already solve it: the focus-trap contract”

The first situation is the one the platform handles for you completely, which makes it a good place to start. The job here is not to build anything. It is to recognize what you already get, trust it, and not break it.

When a Radix Dialog opens (and every overlay primitive shadcn ships is Radix underneath), 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. The four guarantees are these:

  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.

That is the shadcn dividend applied to focus: every overlay in components/ui/, Dialog, AlertDialog, Sheet, Drawer, Popover, DropdownMenu, and Command, ships all four guarantees. 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.

It is worth seeing how much that primitive is doing, because that is the argument for never hand-writing it. A correct focus trap has to handle four things at once. It cycles Tab and Shift+Tab at both ends of the dialog. It copes with content that changes while the dialog is open, so the element you focused disappears. It contains focus even when it tries to escape through paths you don’t control, like browser autofill, devtools, or Cmd+L to the address bar. And it restores focus to the right trigger when the dialog closes. A hand-rolled trap usually gets the first one and none of the rest. Radix gets all four right, and so does a dedicated library such as focus-trap or react-focus-lock. Reach for a library only in the rare case where you are building a portal-rooted custom surface shadcn has no primitive for, and even then use the library rather than your own keydown handlers. You do not write a focus trap by hand.

There is one case the primitive can’t guess, and it shows up constantly in a SaaS app. Guarantee 3, return focus to the trigger, assumes the trigger still exists. Picture a delete-confirmation AlertDialog opened from a table row, where confirming deletes that row. The trigger button was in the row, so when the row is gone there is no element to return focus to, and the cursor lands nowhere the moment the dialog closes. Radix can’t know your mutation removed the trigger, so this case is yours: give the close an explicit destination instead. Focus a stable nearby anchor, such as the table’s heading, the “New invoice” button, or the list container, and do it in the dialog’s onCloseAutoFocus, where you can intercept the default return.

The two versions sit side by side below. The first is the everyday case where the trigger survives and Radix handles everything. The second is the delete flow where you redirect focus yourself.

<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’s still mounted after the account data is gone. The dialog closes, and Radix returns focus to the trigger for free. That is guarantee 3, handled.

So situation one is mostly recognition. The primitive owns the trap, you learn the contract so you trust it and don’t undo it, and you keep one custom move ready for the deleted-trigger case. 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 in an automated audit, and it is where this chapter’s “focus is state” thread pays off in a copyable pattern.

Here is the gap, stated precisely. 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, swaps the page content, and leaves the focus cursor exactly where it was, which after the swap means the cursor points at an element that just unmounted. This is not a hack you’re working around; it 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.

So the bug is the one from the very top of this lesson, now explained as a mechanism. Focus is stranded on the unmounted link, the next Tab is arbitrary, and the screen-reader user is hit twice over, because nothing announced that the page even changed. They are left on a dead node with no idea where they are.

The fix is small, and it is the same pairing you already know, applied at the page level. You focus the new page’s main heading when the route changes. That takes a tiny 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 every time pathname changes, which means on every navigation. This is a legitimate effect: it synchronizes React with an external system, the router, which is exactly what effects are for, so it doesn’t violate “you probably don’t need an effect.”

'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 inside the effect rather than holding a ref to a node from the previous route that no longer exists. This is the one subtlety here: get it 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 without fighting it: the cursor goes to the heading, and the scroll goes where the framework decides.

1 / 1

Notice what the target is: an <h1 tabIndex={-1}> (or <main id="main-content" tabIndex={-1}>), the exact pairing from “the two verbs,” now in its first real application. The tabindex="-1" makes the heading a legal focus target without putting it in anyone’s Tab order, and the .focus({ preventScroll: true }) moves there. Every page has exactly one <h1>, the heading-hierarchy rule you own from the lesson on semantic structure, so “the heading” is never ambiguous. There is one, and it’s the landing spot.

It helps to make the invisible bug visible, since being invisible is the reason it ships in the first place. The diagram below follows the single focus cursor across a navigation, drawn as a marker you can actually see. Scrub through it: the cursor sits on a link, the link unmounts out from under it, and 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 exact mechanism, so it’s cheap to add right 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 single page. You make it the first focusable element in the layout. It sits 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>

The sr-only is the visually-hidden utility from the lesson on ARIA, and focus:not-sr-only is the reveal-on-focus trick: hidden until focused, visible the moment a keyboard user Tabs to it. The skip link is cheap to build, and it is 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, true to the “tools catch a minority” point from the lesson on the four commitments.

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 for 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>
    </>
    );
    }

    After a submission: move focus and announce

    Section titled “After a submission: move focus and announce”

    The third situation is form submission. The clean way to reason about it is to 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 don’t do anything, because the route-change pattern from the last section already fired and focus is on the new page’s <h1>. Situation three collapses into situation two.

    It succeeded and stayed put. An inline edit, or a posted comment. Focus has to go to a sensible anchor that you choose deliberately, usually the action that started the submit (the edit button you can now press again), or 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 actually hears it. The focus move and the announcement are two halves of one event.

    It failed with errors. Focus moves to the first field with an error, and that field’s error is announced through the live region. Here focus and announcement compose into the most useful submission pattern there is. Focus an input that is aria-describedby an error element with 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 you can see the two moves clearly.

    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>

    That is deliberately a sketch. The real machinery around it, the Server Action, useActionState, generating the ids with useId, aria-invalid, and rendering the whole error tree, is a large topic that belongs to the forms chapter (Chapter 44). This lesson owns one half of it: move focus to the first error, and make sure something announces it. The boundary is intentional, not an unfinished example.

    Pull the three cases together and they share one rule, the one genuinely new idea in this lesson.

    That rule has a sharp edge, and it’s the mistake this section invites. 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 it makes things worse, because each focus move interrupts the screen reader. A UI that re-focuses constantly cuts off its own announcements and leaves an assistive-technology user unable to hear any of them to the end.

    There’s a related shortcut worth placing precisely. 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 focusing the first field 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. (That’s the modal section again: let the primitive own initial focus.) One mechanical note to remember: autoFocus fires per mount, so a component that remounts fires it again, which can surprise you if the element comes and goes.

    Two more focus mistakes don’t fit the three situations but come up constantly. They share a shape, which is why they’re grouped here: in both, the obvious move is the wrong one.

    You met this rule back in the lesson on the four commitments, and here is where it bites. 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 in the DOM the sidebar comes first. The reading order they see and the Tab order they get have quietly diverged.

    The fix is the one you already know, and it 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 tabindex. A positive tabindex to “fix” visual order is the anti-pattern from the lesson on the four commitments, and it scrambles the rest of the page to cover up this one spot. When you need a layout that mirrors (right-to-left, say), the correct tool is logical properties from the lesson on logical layout. They flip the layout without touching source order, so the DOM order stays the reading order. The fix is always the DOM, never the CSS.

    disabled vs aria-disabled: discoverability decides

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

    When you disable a control, you reach for the native disabled attribute. Usually that’s correct: disabled removes the control from the Tab order entirely and announces “disabled,” so a keyboard user tabs right past it, which is what you want for most disabled things.

    But removing it from the Tab order is sometimes 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’s there, and never hears why they can’t submit. The control you disabled is now invisible to the user who most needs an explanation of it.

    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 responsibility in exchange. Guard the handler yourself by early-returning from onClick while the disabled state holds, since aria-disabled won’t stop the click for you.

    The decision comes down to a single question, and the walker below makes you work through it in order. Default to native disabled, and reach for aria-disabled only when the disabled control must stay discoverable.

    Disabling a control: which attribute?

    One closing reminder, because a lesson about moving focus is incomplete without it. Most of the time you’re moving 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, such as a clickable card or a custom selectable row with tabindex="0" (rare, but it happens), you’ve created a target a keyboard user can land on but, without a style of your own, 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>

    Those 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 token is theme-owned, so it’s the same ring the rest of your app uses, and the “audit the token” reflex from the lesson on the four commitments applies. :focus-visible rather than :focus is what you met in the lesson on focus styles: it shows the ring for keyboard focus and not for mouse clicks. This closes the loop on one rule from the lesson on the four commitments: never remove a focus ring to tidy up a design. Restyle it instead. A removed ring leaves a keyboard user with no idea where they are.

    Here is the whole lesson in one place. The platform solves one of the three situations completely, the modal trap owned by Radix, and leaves you the other two. On a route change it moves focus nowhere, so you send focus to the new heading. On a submission you move focus by outcome, to the new page, to a sensible anchor, or to the first error, and you pair every move with an announcement. Underneath all three is one model, a single cursor you can read and move, and two verbs, tabindex="-1" to make a target and .focus({ preventScroll: true }) to move there. Underneath that is the reflex the whole lesson is really about: after any disruptive change, ask where the cursor landed, because the platform won’t warn you when it landed nowhere.

    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.

    This lesson leaves one loose thread, the one the previous lesson handed it: 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.