JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Project: a modal dialog with focus management
Build a modal dialog that moves focus in on open, keeps Tab inside it, closes on Escape, and hands focus back to the button that opened it.
What you will learn
- Save document.activeElement before opening and call focus() on it after closing
- Wrap the tab ring with (i + step + n) % n so Shift+Tab never produces -1
- Intercept Tab on keydown at the first and last control, never on keyup
- Use dialog.showModal() for the top layer, an inert background and Escape
Understanding Project: a modal dialog with focus management
A modal dialog is a claim about the whole page: while it is open, nothing behind it can be used. The visible overlay only communicates that claim to sighted mouse users; keyboard and screen reader users learn it from where focus is and from what Tab can reach. So the real work is four transitions rather than a box: focus moves in on open, stays contained while open, goes back to the trigger on close, and the background becomes unreachable in the meantime.
Focus is a single cursor that the browser owns. There is exactly one document.activeElement at a time, and rendering a new element does not move it, which is why you call focus() yourself and why you must read document.activeElement before opening, while the trigger is still known. The same single-cursor model explains the classic bug: hide the element that currently has focus and the browser has nowhere to keep the cursor, so it falls back to <body> and the user's next Tab restarts at the top of the document.
Tab order follows DOM order, not visual stacking, so a dialog appended at the end of <body> sits after everything else in the ring and Shift+Tab from its first control walks straight into the page behind it. There are two honest ways out: let the browser do it with <dialog> and showModal(), which promotes the dialog to the top layer, makes the rest of the document inert, fires cancel on Escape and returns focus to the opener on close; or build the trap by hand, marking the outside inert and intercepting Tab at the two edges of your tabbable list. Prefer the native element, because a hand-written trap has to keep up with controls that appear, disappear or become disabled while the dialog is open.
// The wrap rule of a focus trap, isolated from the DOM so it can be reasoned about.
function nextIndex(count, current, shiftKey) {
if (count === 0) return -1; // nothing tabbable: focus the dialog itself
if (current === -1) return shiftKey ? count - 1 : 0;
const step = shiftKey ? -1 : 1;
return (current + step + count) % count; // + count keeps the result positive
}
const focusable = ['btn-close', 'input-name', 'btn-cancel', 'btn-save'];
const presses = [false, false, false, false, false, true]; // false = Tab, true = Shift+Tab
let current = -1; // focus is still outside the dialog
for (const shiftKey of presses) {
current = nextIndex(focusable.length, current, shiftKey);
console.log(`${shiftKey ? 'Shift+Tab' : 'Tab'} -> ${focusable[current]} (index ${current})`);
}A modal is a focus contract — focus starts inside it, cannot leave it, and returns to the trigger — and the overlay is only how that contract is drawn.
Worked examples
Remember and restore the trigger
Shows what closing a dialog does to focus when you keep a reference to the opener and when you do not.
// A stand-in for the browser's single global focus cursor.
const doc = { activeElement: null };
const el = (id) => ({ id, focus() { doc.activeElement = this; } });
const body = el('body');
const trigger = el('open-settings');
const closeButton = el('dialog-close');
let previouslyFocused = null;
function openDialog() {
previouslyFocused = doc.activeElement;
closeButton.focus();
}
function closeDialog({ restore }) {
doc.activeElement = body; // hiding the focused element drops focus to <body>
if (restore) previouslyFocused.focus();
}
trigger.focus();
console.log('before open:', doc.activeElement.id);
openDialog();
console.log('after open:', doc.activeElement.id);
closeDialog({ restore: true });
console.log('closed with restore:', doc.activeElement.id);
openDialog();
closeDialog({ restore: false });
console.log('closed without restore:', doc.activeElement.id);Example explained
Line 1previouslyFocused = doc.activeElement runs before focus moves, the only moment the trigger is still identifiable.
Line 2closeButton.focus() is what announces the new region; making the dialog visible does not move the cursor.
Line 3Assigning body to activeElement mirrors the browser: a focused element that is hidden or removed leaves focus on <body>.
Line 4The last line is the cost of skipping the restore step: the user's next Tab starts from the top of the page.
Not everything the selector matches is tabbable
Filters a raw focusable-element query down to the elements Tab can actually reach.
// What a focusable-element selector matches, and what still has to be filtered out.
const matched = [
{ id: 'close', disabled: false, hidden: false, tabIndex: 0 },
{ id: 'coupon', disabled: true, hidden: false, tabIndex: 0 },
{ id: 'advanced', disabled: false, hidden: true, tabIndex: 0 },
{ id: 'help', disabled: false, hidden: false, tabIndex: -1 },
{ id: 'save', disabled: false, hidden: false, tabIndex: 0 }
];
const tabbable = matched.filter(el => !el.disabled && !el.hidden && el.tabIndex >= 0);
console.log('tab ring:', tabbable.map(el => el.id).join(' -> '));
console.log('matched but skipped:', matched.length - tabbable.length);
console.log('first:', tabbable[0].id, '| last:', tabbable[tabbable.length - 1].id);Example explained
Line 1A disabled control still matches selectors like button or input, but the browser skips it, so leaving it in puts your boundary on the wrong element.
Line 2A display:none element is not focusable at all; calling focus() on it is a silent no-op that leaves focus where it was.
Line 3tabIndex -1 means script-focusable but not Tab-reachable, so help must not count as a stop in the ring.
Line 4first and last are the only two positions the trap needs, which is why this list must be recomputed whenever the dialog reveals or disables controls.
Intercept Tab only at the edges
Demonstrates that the handler should cancel Tab at the first and last control and otherwise leave the browser alone.
const ring = ['btn-close', 'input-email', 'btn-save'];
let focusedIndex = 0; // in the browser a focusin listener keeps this current
function onKeydown(event) {
if (event.key !== 'Tab') return;
const atLast = !event.shiftKey && focusedIndex === ring.length - 1;
const atFirst = event.shiftKey && focusedIndex === 0;
if (!atLast && !atFirst) {
console.log('inside the ring: let the browser move focus');
return;
}
event.preventDefault();
focusedIndex = atLast ? 0 : ring.length - 1;
console.log('edge reached: focus forced to', ring[focusedIndex]);
}
function press(key, shiftKey, indexBefore) {
focusedIndex = indexBefore;
const event = {
key,
shiftKey,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; }
};
onKeydown(event);
console.log(' prevented:', event.defaultPrevented, '| focusedIndex:', focusedIndex);
}
press('Tab', false, 1);
press('Tab', false, 2);
press('Tab', true, 0);Example explained
Line 1The key !== 'Tab' guard lets typing, arrow keys and Escape reach the controls untouched.
Line 2The middle case deliberately does nothing: re-implementing the browser's order would break composite stops such as a radio group, which is one Tab stop and not one per input.
Line 3preventDefault() on keydown is what cancels the browser's pending focus move; the same call on keyup would arrive after focus already left.
Line 4focusedIndex is set from observed focus rather than assumed, because a mouse click changes focus without any keydown at all.
Important notes
showModal() returns focus to the previously focused element on close, but keep your own reference anyway: focus() on a node that was re-rendered or removed silently does nothing, and you need a fallback target.
Only fully hidden elements leave the tab ring. Something faded with opacity: 0 or collapsed with height: 0 is still focusable, so an exit animation can leave invisible tab stops behind unless you add inert or hidden.
Common mistakes
Showing the dialog by toggling a class and never calling focus(): the keyboard user is still parked on the trigger, and because the dialog markup sits last in the document the first Tab walks into the page behind it.
Writing (current - 1) % count for Shift+Tab: -1 % 4 is -1 in JavaScript, so focusable[-1] is undefined and the following .focus() call throws a TypeError instead of wrapping to the last control.
Handling Tab in a keyup listener: keydown's default action has already moved focus out of the dialog, and preventDefault() on keyup cancels nothing, so the trap leaks on every edge press.
Try it yourself
Change, predict, then run
Build a <dialog> holding a close button, a text input and a Save button, open it from a page button, and add a focusin listener on document that logs event.target.id. Drive the whole thing with the keyboard only and confirm the log never contains an id from the page behind the dialog and ends with the trigger's id.
Open the JavaScript workspaceCheck your understanding
A custom modal built from a div appended to the end of <body> correctly focuses its first button on open, but Shift+Tab from that button moves focus to the page footer instead of the dialog's last button. What explains it?
- The dialog element is missing its dialog role, and without that role the browser will not confine focus to it.
- The overlay needs a higher z-index so the browser treats the dialog as being on top for keyboard purposes.
- Tab order follows DOM order and the dialog markup comes after the footer, so backward Tab from the first control continues into the page unless the handler intercepts it.
- Shift+Tab cannot be cancelled from JavaScript, so a manual trap can only hold the forward direction.
Show answer
Nothing about a plain div confines focus; only a top-layer modal dialog or inert siblings do that, and Tab simply walks the document in DOM order, which puts an appended dialog after the footer. The ARIA answer is tempting because role and aria-modal are genuinely required for the dialog to be announced as one, but they only describe the widget to assistive technology and never change keyboard order. Shift+Tab is an ordinary keydown default action and can be prevented like any other.