A keyboard user opened the modal, tabbed forward to read it, and walked straight out the back. The focus moved from the last button in the dialog to a link in the page behind it — the page the dialog was supposed to be covering. The overlay was still there, dimming everything, and the cursor was somewhere underneath it, navigating a form the user couldn't see.
I knew exactly what was wrong, because I had written it. The trap was a keydown handler that watched for Tab, found the first and last focusable elements in the dialog, and looped focus between them. It worked in the demo. It worked until someone added a button to the dialog footer and the cached "last focusable element" pointed at the wrong node. The trap had a list of what it was guarding, and the list went stale the moment the markup changed.
This was the third time I had written that handler. Different app, same handler, same eventual leak. At some point repeating a mistake stops being bad luck and starts being a decision.
A focus trap is a list of things that goes out of date
The hand-rolled version is always some flavor of the same idea. You query the dialog for everything focusable — button, a[href], input, [tabindex] — you keep the first and last, and on Tab you decide whether to wrap. The bug isn't in the wrapping logic. It's that you're maintaining a snapshot of a live thing. A dialog with a disabled button, a lazily rendered section, an input that appears after validation — every one of those changes the real set of focusable elements, and your snapshot doesn't know.
So you start patching. Re-query on every keydown. Add a MutationObserver. Handle the case where focus is currently outside the dialog entirely. Each patch is reasonable and each one is another thing that has to stay correct forever, in a component whose actual job is to ask the user whether they're sure.
That's the moment I now treat as a signal: when the supporting machinery for a feature is more complicated than the feature, I'm probably reimplementing something the platform already does.
The fix was deleting code, not adding it
A native <dialog> opened with showModal() does the part I kept getting wrong. The browser traps focus inside the dialog, wires Escape to close, marks the rest of the document inert so it can't be reached by keyboard or screen reader, exposes aria-modal to assistive tech, and — the detail my handler never handled — returns focus to whatever was focused when the dialog opened. None of that is mine to maintain. It can't go stale, because there's no list; the platform tracks the live tree.
Here is the whole control surface in an Angular component — a signal reconciled into the element:
@Component({
selector: 'app-confirm',
template: `
<button type="button" (click)="open.set(true)">Delete</button>
<dialog #dlg (close)="open.set(false)">
<p>Delete this report? This can't be undone.</p>
<button type="button" (click)="dlg.close()">Cancel</button>
<button type="button" (click)="confirm()">Delete</button>
</dialog>
`,
})
export class Confirm {
protected readonly open = signal(false);
private readonly dlg = viewChild.required<ElementRef<HTMLDialogElement>>('dlg');
constructor() {
effect(() => {
const el = this.dlg().nativeElement;
if (this.open() && !el.open) el.showModal();
if (!this.open() && el.open) el.close();
});
}
}
The consumer writes a button and a <dialog>. What they get at runtime is the behavior I used to hand-write: Tab cycles inside the dialog and cannot escape it, Escape closes it, the page behind is inert, and when it closes the focus lands back on the Delete button that opened it — not at the top of the document, not lost. The one thing the platform doesn't give you is the only thing left in the code: reflecting the open state back onto a signal, so the template can both drive the dialog and observe when the user closes it. Everything else, I deleted.
You can tab through the live version — open it, try to escape it with the keyboard, watch where focus returns — on the accessibility demo, with the source beside it.
There's a fair objection: not every modal can be a native <dialog>. A positioned popover, a non-modal side panel, a design that needs the background interactive — those are real, and that's exactly when the CDK's FocusTrap earns its place. The point isn't that <dialog> is always the answer. It's that reaching for the hand-rolled trap first, before checking whether the platform or a maintained primitive already solves it, is how you end up maintaining the same bug in three codebases.
Accessibility Is Mostly Knowing What Not to Build
The instinct that got me here was treating accessibility as code I had to author — more handlers, more ARIA, more guarding. The senior version of the skill turned out to be the opposite: knowing which behaviors the platform already owns, and refusing to re-own them. A focus trap you wrote is a focus trap you now maintain, and it will go stale at the worst time, in front of the one user who depends on it most. The most accessible modal I ship is the one where I let the browser do the part it has always done better than my keydown handler — and I kept only the two lines it left me.


