Accessibility checklists list focus management as one item. In practice it covers many separate decisions, and it's often what separates an app that passes an automated audit from one a keyboard user can get through.
The model that helped me is this: focus is a cursor, and there is only one. For someone using a keyboard or a screen reader, focus is where they are in your app. Mouse users don't think about it, because their eyes and pointer move independently. Everyone else has one position, and every time your UI changes what's on screen, you need to answer one question:
Where does focus go now?
If the answer is wrong, the user ends up somewhere useless. Usually that's the top of the page or <body>, and they have to Tab through all of your navigation to get back. Automated tools won't tell you, because where focus went after a state change isn't in a DOM snapshot. Below are four places where Angular apps commonly get it wrong.
1. Route changes
This is the most common focus bug in Angular apps, and the hardest to notice. When you navigate a server-rendered site, the browser loads a new document and focus starts at the top. In a single-page app, the router swaps part of the DOM and focus stays on whatever you clicked, which is usually a link that no longer exists. Screen-reader users get no sign that the page changed.
The fix is to move focus to the new page's heading after each 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 more things are needed. The <h1> needs tabindex="-1" so it can take focus from script without joining the tab order. And you want a skip link as the first focusable element, so people can jump past the navigation to <main>:
<a class="skip-link" href="#main-content">Skip to main content</a>
With one subscription, every route change now moves focus to the new page and a screen reader reads its heading.
2. Dialogs: trap focus, then return it
A modal has two focus jobs, and many hand-written dialogs only do one of them.
While the dialog is open, focus must stay inside it. Tab shouldn't reach the page behind. The CDK handles this correctly, including wrapping from the last element to the first:
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog" aria-modal="true" aria-labelledby="title">
<h2 id="title">Delete this project?</h2>
<!-- … -->
</div>
When the dialog closes, focus must go back to the element that opened it. Otherwise it falls to <body> and the user starts again from the top of the page. This is the part people forget:
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();
}
If you're opening real modals, the Dialog service in @angular/cdk/dialog does both jobs for you. restoreFocus is on by default.
3. Drawers, menus and disclosures
A cart drawer, a navigation flyout and a filter panel follow the same rule as a modal: move focus in when it opens, and return it to the trigger when it closes. If a drawer opens but focus stays behind the overlay, the user tabs through content they can't see.
For menus with arrow-key navigation, ListKeyManager and FocusKeyManager from @angular/cdk/a11y give you roving focus. Arrow keys move between items and Tab leaves the widget, without you writing the keydown handling yourself.
4. Deleting the focused item
If you delete the row that has focus and do nothing else, focus falls to <body>. Send it somewhere sensible instead: the next row, or the list heading if you deleted the last row. In Angular the difficulty is timing. You have to wait for the signal update to reach the DOM before the new target exists. Use afterNextRender:
private readonly host = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
removeAt(index: number) {
this.items.update((list) => list.filter((_, i) => i !== index));
afterNextRender(
() => {
const rows = this.host.nativeElement.querySelectorAll<HTMLElement>('[data-row]');
const next = rows[Math.min(index, rows.length - 1)];
(next ?? this.host.nativeElement.querySelector<HTMLElement>('h2'))?.focus();
},
{ injector: this.injector },
);
}
The same pattern works when adding an item (focus the new input), loading more results (focus the first new one) and a failed submit (focus the first error).
Don't move focus when nobody asked
Managing focus doesn't mean moving it all the time. Avoid these two patterns:
-
autofocuson page load. Moving focus into a search box on every load gets in the way of people who navigate by heading or landmark. Only autofocus inside a modal or on a page with a single purpose. - Moving focus on scroll or hover. Focus should follow something the user did on purpose, such as a click, a key press or an explicit action.
Keep the focus indicator visible. :focus-visible shows it to keyboard users without showing it on every mouse click:
:focus-visible {
outline: 2px solid var(--ngbr-color-focus);
outline-offset: 2px;
}
Don't set outline: none without a replacement. Users can't follow a focus they can't see.
How to test it
Put your mouse down. Go through the whole flow with Tab, Enter, Space and the arrow keys, and after each interaction ask where focus went. Open a dialog: is focus inside it? Close it: did focus return to the button? Change route: are you on the new heading? Delete something: are you on a real element, or back at the top?
Most of these bugs show up within a couple of minutes. They're easy to miss because you only see them when you use the app without a pointer.
NgBracket is a set of Angular component packs built to support WCAG 2.2 AA. Its dialogs and drawers trap focus while open and return it to the trigger when they close. Its menus use the arrow keys and return focus to the trigger on Escape. The conformance report lists how each criterion is tested and the known limitations. You can start with the free packs without a card.
Top comments (0)