JavaScript Focus Management Table
| Piece | What it does | Field note |
|---|---|---|
el.focus({ preventScroll }) | Focus without jump | The scroll jump killer - modal open without page teleport |
:focus-visible | Keyboard-only rings | Mouse clicks no ring, Tab does - the browser's heuristic, now CSS |
focus-trap pattern | Tab stays inside | Modals trap Tab - on last el, Tab wraps to first |
inert attribute | Subtree un-focusable | Behind-the-modal content INERT - focus cannot even land |
tabindex=0 vs -1 | Join vs skip | 0 = tabbable; -1 = programmatically only - panels get -1 |
Autofocus the right el | First INPUT not button | Dialogs autofocus the first interactive - content dictates |
focusout/focusin | Bubbling focus events | focus doesn't bubble; focusout/in do - delegation works |
Return focus on close | The closing courtesy | Store opener, restore on close - where the user LEFT |
Focus management is keyboard accessibility's core: focus({ preventScroll: true }) opens modals without teleporting the page, :focus-visible gives keyboard users their rings while sparing mouse clicks, the focus-trap pattern keeps Tab inside modals, and the inert attribute makes everything behind a modal natively un-focusable.
Bottom line: store the opener and restore focus on close (where the user LEFT), autofocus the first input rather than the button, and remember focus does not bubble - focusout/focusin do, which is why event delegation uses those.
The honest part: every custom widget with focus management is re-implementing what native elements ship free - the moment a focus trap or manual tabindex appears, ask whether a dialog, details or button could carry the semantics instead.
How to use
- Open without teleporting: modal.show(); previouslyFocused = document.activeElement; firstInput.focus({ preventScroll: true }) - and restore on close.
- Trap Tab in modals: on keydown Tab, if focus would leave the modal, move it to the first (Shift: last) focusable - or use inert on the background.
- Make everything behind a modal inert: main.inert = true - focus, clicks and screen-reader reach all skip it, natively.
Frequently asked questions
Why does focus({ preventScroll: true }) exist?
Because focusing SCROLLS by default: calling focus() on an offscreen element scrolls the page to reveal it - usually desirable, catastrophic on modal open (the page behind teleports to wherever the first input lives, and the user loses their reading position). preventScroll focuses the element in place. The companion discipline: when you prevent the scroll, YOU own making sure the focused element is actually in view - for modals that is trivial (they overlay the viewport); for inline reveals it means scrolling the container, not the page, yourself.
What does :focus-visible solve that :focus could not?
The mouse-click ring problem. :focus applies on ANY focus - including mouse clicks on buttons, which produced the infamous ring on click that designers kept deleting, which in turn destroyed keyboard users' only indicator. :focus-visible applies the ring only when the browser HEURISTIC thinks keyboard focus indication helps: Tab navigation shows it, mouse clicks do not. The result: keep the visible indicator, lose the click ring, satisfy WCAG 2.4.7 - one pseudo-class resolving the designer-versus-a11y war. Never remove focus indicators entirely; restyle them to fit the design if needed.
How does the focus-trap pattern work, and when is it required?
Tab is a cycle; the trap contains the cycle. In a modal, Tabbing past the last focusable element would escape to the page behind (invisible and unreachable) - the trap listens for Tab on the modal's last focusable and moves focus to the first, and Shift+Tab on the first moves to the last. It is REQUIRED for modal dialogs: keyboard users must not be able to Tab into content they cannot see or reach. The modern simplifications: the inert attribute on the background makes everything outside the modal unfocusable natively (Tab wraps automatically - nothing outside accepts focus), and the Popover API and dialog element handle the trap internally. Custom traps are for custom widget hostilities.
Why does returning focus on close matter so much?
Continuity of place. A keyboard user Tabbed to the 'Delete' button, hit Enter, confirmed in a dialog - and focus vanished to document.body: their place in the page is gone, and the next Tab starts from the top (or the browser re-inserts them somewhere random). The courtesy: store document.activeElement when the dialog opens, restore .focus() on close - the user continues where their action left them, with the deleted row's neighbor now focused. This is in the WAI-ARIA dialog pattern explicitly, and its absence is the most common keyboard-accessibility bug report in component libraries.