Categories: CSSScriptWeb Design

Accessible Modal Stack Manager in JavaScript – vanilla-aria-modals

vanilla-aria-modals is a JavaScript utility that manages ARIA accessibility events in modal and modal-like UIs like notification popups.

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): Pass true to turn debug mode on; false turns 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 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.

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 in addA11yEvents().

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 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.

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 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.

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.

rssfeeds-admin

Share
Published by
rssfeeds-admin

Recent Posts

A Look Back, March 24

50 Years Ago Seven women students at Smith’s Vocational High School filed a grievance yesterday…

5 minutes ago

Level services or deep cuts? Southampton to present multi-option override discussion on Thursday

SOUTHAMPTON — Facing a $2.6 million deficit next year, officials are outlining three budget scenarios this…

6 minutes ago

Director shares how travel restrictions are impacting Green River Festival lineup

GREENFIELD — The lineup of the 2026 Green River Festival is evolving in the wake…

6 minutes ago

Proposed ‘character zones’ aim to reshape downtown Amherst housing

AMHERST — A series of proposed zoning changes and design criteria for downtown Amherst are…

6 minutes ago

Building the future: Southampton unveils designs for a potential new town center

SOUTHAMPTON — Designs for the potential redevelopment of 0 College Highway have been released, aiming to…

6 minutes ago

Agritourism, local food access at center of Senate farm bill

BOSTON — The Senate budget committee is seeking to define and support the “agritourism” sector…

6 minutes ago

This website uses cookies.