
The library supports focus trapping, focus restoration, Escape key handling, outside-click detection, and modal stacking through a single API.
It is written in pure vanilla JS and works with any tech stack, including React, Vue, or Vanilla JavaScript.
Features:
- Cycles Tab and Shift+Tab navigation through all visible, non-disabled focusable elements inside the modal.
- Auto-returns keyboard focus to the triggering element on modal close.
- ESC key support with modal stacking awareness.
- Registers Outside-click detection to prevent accidental auto-closes immediately after a modal opens.
- Closes layered dialogs in the correct reverse order based on LIFO (Last In, First Out).
- Supports overlayless modal that bypasses stacking order on click close and tracks popups in a separate internal registry.
- A Reset method for SPA route changes and component unmounts that clears all lingering event listeners, focus references, and modal key state in one call.
How To Use It:
1. Install the package via npm:
# NPM $ npm install vanilla-aria-modals
2. Import and instantiate ModalHandler.
import ModalHandler from 'vanilla-aria-modals'; // Always returns the same instance, regardless of how many files import it const modalHandler = new ModalHandler(); // Turn on console debug logs during development to trace stacking and close events modalHandler.setDebug(true);
2. Open and close a modal window.
// Cache DOM references once, before the modal ever opens
const triggerBtn = document.getElementById('launch-dialog-btn');
const dialogWrapper = document.getElementById('user-profile-modal');
const dialogContent = document.getElementById('user-profile-modal__content');
const dialogClose = document.getElementById('user-profile-modal__close');
function openProfileModal() {
// Generate a unique key to identify this specific modal instance
const modalKey = modalHandler.generateKey();
const closeProfileModal = () => {
// Hide the modal container from the viewport
dialogWrapper.style.display = 'none';
// Return keyboard focus to the button that originally triggered the modal
modalHandler.restoreFocus({ modalKey });
// Remove all ARIA event listeners registered under this key
modalHandler.removeA11yEvents({ modalKey });
};
// Reveal the modal
dialogWrapper.style.display = 'flex';
// Store the current active element and shift focus into the modal
modalHandler.addFocus({
modalKey,
firstFocusableLm: dialogClose // First focusable element the user should land on
});
// Register ESC key close, outside-click close, focus trap, and close-button events
modalHandler.addA11yEvents({
modalKey,
modalLm: dialogContent, // The element that receives the focus-trap listener
modalLmOuterLimits: dialogContent, // The boundary used for outside-click detection
closeLms: [dialogClose], // Elements that call closeHandler on click
closeHandler: closeProfileModal
});
}
triggerBtn.addEventListener('click', openProfileModal);3. Handle stacked modals. When a second modal opens on top of the first, call generateKey again for a new unique key. The library manages both in its internal LIFO stack and routes Escape key events to the correct modal.
Pressing Escape now closes only the confirmation dialog. The parent modal stays open and retains its focus state.
function openDeleteConfirmation() {
const confirmKey = modalHandler.generateKey(); // e.g., "modal-2"
const confirmWrapper = document.getElementById('delete-confirm-modal');
const confirmContent = document.getElementById('delete-confirm-modal__content');
const confirmClose = document.getElementById('delete-confirm-modal__close');
const closeConfirmModal = () => {
confirmWrapper.style.display = 'none';
// Restores focus to whichever element triggered the confirmation (inside the parent modal)
modalHandler.restoreFocus({ modalKey: confirmKey });
modalHandler.removeA11yEvents({ modalKey: confirmKey });
};
confirmWrapper.style.display = 'flex';
// addFocus stores focus on the parent modal's button, then moves it to the confirmation close
modalHandler.addFocus({
modalKey: confirmKey,
firstFocusableLm: confirmClose
});
modalHandler.addA11yEvents({
modalKey: confirmKey,
modalLm: confirmContent,
modalLmOuterLimits: confirmContent,
closeLms: [confirmClose],
closeHandler: closeConfirmModal
});
}4. Call reset() on route changes or component unmounts to prevent stale event listeners from carrying over into the next view.
// Call this in a React useEffect cleanup, Vue beforeUnmount hook, or a router guard
function onRouteChange() {
// Clears body event listeners, empties the modal stack, wipes the focus registry,
// resets the key counter to 0, and empties the popup registry
modalHandler.reset();
// You can also call each step individually for more surgical cleanup:
// modalHandler.clearDocumentBodyEvents(); // Removes listeners from document.body
// modalHandler.clearActiveModals(); // Empties the LIFO stack
// modalHandler.clearFocusRegistry(); // Deletes stored pre-modal focus references
// modalHandler.resetKeys(); // Resets the internal counter to 0
// modalHandler.clearPopups(); // Empties the overlayless popup registry
}5. The library calls closeHandler with (e, modalKey) automatically. To pass additional arguments, wrap the handler in a closure.
// The outer function accepts any custom arguments you need
const buildCloseHandler = (recordId, onAfterClose) => {
// The returned function matches the exact signature the library expects
return (e, modalKey) => {
console.log(`Archiving record ${recordId}`);
onAfterClose(recordId);
modalHandler.removeA11yEvents({ modalKey });
};
};
modalHandler.addA11yEvents({
modalKey,
modalLm: formContent,
modalLmOuterLimits: formContent,
closeHandler: buildCloseHandler('rec-4821', syncArchiveView)
});6. Set auto to false in addFocus when you need to handle focus restoration yourself.
// auto: false returns the last focused element instead of storing it internally
const previousFocus = modalHandler.addFocus({
modalKey,
firstFocusableLm: panelCloseBtn,
auto: false
});
// Later, restore focus manually using the returned element
modalHandler.restoreFocus({
modalKey,
lastFocusedLm: previousFocus,
auto: false
});Public API Reference
setDebug(bool)
Enables or disables console debug logs for modal registration, stacking, and close events.
Parameters:
bool(boolean): Passtrueto turn debug mode on;falseturns it off.
Returns void.
generateKey(prefix?)
Creates a unique string identifier for a modal instance. The key increments an internal counter and combines it with the prefix.
Parameters:
prefix(string, optional): A string prepended to the generated key. Defaults to"modal", producing keys like"modal-1","modal-2".
Returns string.
addA11yEvents(options)
Registers all ARIA event listeners for a modal: Escape key close, outside-click close, focus trapping, close-button click handling, and modal stack registration.
Parameters (passed as a single object):
modalKey(string): The unique key fromgenerateKey(). Required.modalLm(HTMLElement | null, optional): The element that receives the focus-trapkeydownlistener. Omit if no focus trapping is needed.modalLmOuterLimits(HTMLElement | null, optional): The element whose boundary defines what counts as “outside the modal” for click detection. Omit to register the modal as overlayless.closeLms(HTMLElement[] | null, optional): An array of elements that callcloseHandleron click (e.g., close buttons, cancel links).exemptLms(HTMLElement[], optional): Elements whose clicks should not trigger outside-click close, even when clicked outsidemodalLmOuterLimits.closeHandler((e: Event, modalKey: string) => void): The function to call when any registered close event fires. The library passeseandmodalKeyautomatically; you do not pass them manually.
Returns void.
removeA11yEvents(options)
Removes every event listener registered for the given modal key and cleans up internal handler storage.
Parameters (passed as a single object):
modalKey(string): Must match the key used inaddA11yEvents().
Returns void.
addFocus(options)
Moves keyboard focus to the specified element inside the modal. Stores the previously active element for later restoration when auto is true.
Parameters (passed as a single object):
modalKey(string): The unique modal key.firstFocusableLm(HTMLElement): The element that should receive focus on modal open.lastFocusedLm(HTMLElement | null, optional): A custom element to store as the pre-modal active element. Defaults todocument.activeElementat the moment of the call.auto(boolean, optional): Defaults totrue. Set tofalseto receive the last focused element as a return value rather than storing it internally.
Returns HTMLElement | void.
restoreFocus(options)
Returns keyboard focus to the element that was active before the modal opened.
Parameters (passed as a single object):
modalKey(string): The unique modal key.lastFocusedLm(HTMLElement | null, optional): The element to focus whenautoisfalse.auto(boolean, optional): Defaults totrue. Set tofalseto focuslastFocusedLmdirectly rather than the internally stored reference.
Returns void.
rebindTrapFocus(modalKey)
Re-attaches the focus-trap keydown listener for a specific modal. The internal trap logic re-queries focusable children on every keypress, so this method is rarely needed. Use it only if you replace modalLm itself with a new DOM element after the modal has already opened.
Parameters:
modalKey(string): The key of the modal whose focus trap to rebind.
Returns void.
Cleanup Methods
All individual cleanup methods return this for chaining. reset() calls all five in sequence and returns void.
// Remove lingering event listeners registered on document.body modalHandler.clearDocumentBodyEvents(); // Empty the LIFO modal stack modalHandler.clearActiveModals(); // Delete stored focus references for all registered modals modalHandler.clearFocusRegistry(); // Reset the internal modal key counter back to 0 modalHandler.resetKeys(); // Empty the overlayless popup registry modalHandler.clearPopups(); // Run all five cleanup steps at once (use on route changes or unmounts) modalHandler.reset();
The post Accessible Modal Stack Manager in JavaScript – vanilla-aria-modals appeared first on CSS Script.
Discover more from RSS Feeds Cloud
Subscribe to get the latest posts sent to your email.
