Accessible Modal Stack Manager in JavaScript – vanilla-aria-modals
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.
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
}); Enables or disables console debug logs for modal registration, stacking, and close events.
Parameters:
bool (boolean): Pass true to turn debug mode on; false turns it off.Returns void.
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.
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 from generateKey(). Required.modalLm (HTMLElement | null, optional): The element that receives the focus-trap keydown listener. 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 call closeHandler on click (e.g., close buttons, cancel links).exemptLms (HTMLElement[], optional): Elements whose clicks should not trigger outside-click close, even when clicked outside modalLmOuterLimits.closeHandler ((e: Event, modalKey: string) => void): The function to call when any registered close event fires. The library passes e and modalKey automatically; you do not pass them manually.Returns void.
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 in addA11yEvents().Returns void.
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 to document.activeElement at the moment of the call.auto (boolean, optional): Defaults to true. Set to false to receive the last focused element as a return value rather than storing it internally.Returns HTMLElement | void.
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 when auto is false.auto (boolean, optional): Defaults to true. Set to false to focus lastFocusedLm directly rather than the internally stored reference.Returns void.
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.
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.
50 Years Ago Seven women students at Smith’s Vocational High School filed a grievance yesterday…
SOUTHAMPTON — Facing a $2.6 million deficit next year, officials are outlining three budget scenarios this…
GREENFIELD — The lineup of the 2026 Green River Festival is evolving in the wake…
AMHERST — A series of proposed zoning changes and design criteria for downtown Amherst are…
SOUTHAMPTON — Designs for the potential redevelopment of 0 College Highway have been released, aiming to…
BOSTON — The Senate budget committee is seeking to define and support the “agritourism” sector…
This website uses cookies.