DEV Community

Cover image for Managing Focus in Angular: Dialogs, Drawers and Route Changes Without Stranding Your Users
Duncan Faulkner
Duncan Faulkner

Posted on • Originally published at ngbracket.com

Managing Focus in Angular: Dialogs, Drawers and Route Changes Without Stranding Your Users

Every accessibility checklist mentions focus management, and then moves on — as if it were one item. It isn't. It's a discipline, and it's the single thing that most often separates an app that technically passes an audit from one a keyboard user can actually get through.

Here's the mental model that fixed it for me: focus is a cursor, and there is only ever one of it. For someone using a keyboard or a screen reader, focus is where they are in your app. Sighted mouse users don't think about it because their eyes and pointer move independently. Everyone else has exactly one position, and every time your UI changes what's on screen, you owe them an answer to one question:

Where does focus go now?

Get that answer wrong and the user is teleported somewhere useless — usually the top of the page, or worse, , from which they have to Tab through your entire chrome to get back. And no automated tool will tell you, because "where focus went after a state change" isn't in a DOM snapshot. Let's go through the four places Angular apps get it wrong.

  1. Route changes: the SPA focus black hole This is the most common and most invisible bug in the entire framework. When you navigate a server-rendered site, the browser loads a new document and puts focus back at the top — predictable. In an SPA, the router swaps some DOM and focus stays on whatever you clicked, which is usually a link that no longer exists. Screen-reader users get no signal that the page changed at all.

The fix is to move focus to the new page's heading on every navigation:

@Component({ /* … */ })
export class App {
private readonly router = inject(Router);
private readonly doc = inject(DOCUMENT);

constructor() {
this.router.events
.pipe(
filter((e) => e instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => {
const heading = this.doc.querySelector('main h1') as HTMLElement | null;
heading?.focus();
});
}
}
Two things make this work: the

needs tabindex="-1" so it's programmatically focusable without joining the tab order, and you want a skip link as the very first focusable element so people can jump past the nav to :

Skip to main content
That's it — one subscription, and every route change now announces itself.

  1. Dialogs: trap it, then give it back A modal has two focus responsibilities, and most hand-rolled dialogs get exactly one of them.

While open, focus must be trapped inside — Tab shouldn't escape to the page behind it. Don't build this yourself; the CDK does it correctly, including the wrap-around:

Delete this project?

When it closes, focus must return to the element that opened it — otherwise it falls to and the user is stranded at the top of the page. This is the half everyone forgets:

private returnFocusTo: HTMLElement | null = null;

open() {
this.returnFocusTo = this.doc.activeElement as HTMLElement;
this.isOpen.set(true);
}

close() {
this.isOpen.set(false);
this.returnFocusTo?.focus();
}
Better still, don't manage overlays by hand at all — @angular/cdk/dialog's Dialog service handles focus trapping and restoration for you (restoreFocus is on by default). If you're opening real modals, reach for it.

  1. Drawers, menus and disclosures: the same rule, smaller A cart drawer, a nav flyout, a filter panel — these are just modals with less drama, and the rule is identical: move focus in when it opens, return it to the trigger when it closes. A drawer that opens but leaves focus behind the overlay is a keyboard trap in reverse — the user is tabbing through content they can't even see.

For menus with arrow-key navigation, the CDK's cdk/a11y ListKeyManager / FocusKeyManager gives you roving focus (arrow keys move between items, Tab leaves the whole widget) without you writing keydown spaghetti.

  1. Destructive actions: don't drop focus into the void Delete the row that currently has focus and — if you do nothing — focus evaporates to . The fix is to send it somewhere sensible: the next row, or if you just deleted the last one, the list heading. The trick in Angular is timing: you have to wait for the signal update to flush to the DOM before the new target exists. Use afterNextRender:

private readonly host = inject(ElementRef);
private readonly injector = inject(Injector);

removeAt(index: number) {
this.items.update((list) => list.filter((_, i) => i !== index));

afterNextRender(
() => {
const rows = this.host.nativeElement.querySelectorAll('[data-row]');
const next = rows[Math.min(index, rows.length - 1)];
(next ?? this.host.nativeElement.querySelector('h2'))?.focus();
},
{ injector: this.injector },
);
}
The same pattern covers "add item" (focus the new input), "load more" (focus the first new result), and "submit failed" (focus the first error).

And the flip side: don't steal focus
Managing focus doesn't mean grabbing it constantly. Two anti-patterns to avoid:

autofocus on load. Yanking focus into a search box on every page load fights people who navigate by heading or landmark. Only autofocus inside a modal or a deliberate single-purpose page.
Focusing on scroll or hover. Focus should follow intent (a click, a key, an explicit action), never an incidental event.
And whatever you do, keep the focus ring visible. :focus-visible gives keyboard users a clear indicator without bothering mouse users:

:focus-visible {
outline: 2px solid var(--ngbr-color-focus);
outline-offset: 2px;
}
Never outline: none without a replacement. A focus you can't see is a focus you can't manage.

How to test it in two minutes
Put your mouse down. Now drive the whole flow with Tab, Enter, Space and arrows, and after every interaction ask the one question: where did my focus go? Open a dialog — is focus inside it? Close it — did it come back to the button? Change routes — are you on the new heading? Delete something — are you on a real element, or lost at the top?

You'll find your bugs in about ninety seconds. That's the whole reason focus management gets skipped: not because it's hard, but because it's invisible until you actually try to use the thing without a pointer.

This is the kind of detail that's genuinely tedious to get right on every component — so we got it right once. NgBracket is a set of accessible-by-default Angular component packs where focus trapping, restoration and route-change handling are built in, not bolted on. We're launching soon; join the waiting list for early access.*

Top comments (0)