Why Dialog Accessibility Is Tricky

Dialogs pervade modern interfaces, yet many fail assistive technology users. If you're building one, the safe route is to use an established, audited library like a11y-dialog rather than rolling your own. But understanding what happens under the hood helps you make better choices—especially about whether a dialog is the right pattern at all.

Using a dialog trades screen-space problems for context switching, and it's tempting to overuse them because they're easy. Before committing to the pattern, ask whether there's another interaction model that doesn't yank users out of their current task.

Starting With A Minimal API

Our recreation tracks how such a library operates without the burden of real-world maintenance. We'll expose a constructor that takes the dialog's root element and supplies .show() and .hide() methods.

class Dialog {
  constructor(element) {}
  show() {}
  hide() {}
}

Given markup like this:

<div id="my-dialog">This will be a dialog.</div>

...we instantiate and control it like this:

const element = document.querySelector('#my-dialog')
const dialog = new Dialog(element)

Instantiation is where the heavy lifting begins. We need it to handle four things:

  • Start hidden via the hidden attribute.
  • Expose a role="dialog" to assistive tech.
  • Apply aria-modal="true" so the rest of the page is inert.
  • Handle any necessary event bindings.
constructor (element) {
  // Store a reference to the HTML element on the instance so it can be used
  // across methods.
  this.element = element
  this.element.setAttribute('hidden', true)
  this.element.setAttribute('role', 'dialog')
  this.element.setAttribute('aria-modal', true)
}

Putting these attributes in the initial HTML would also work, but applying them in JavaScript guarantees correctness regardless of authoring oversights.

Core State Management

The show() and hide() methods toggling the hidden attribute aren't just about visibility—they trigger side effects like tracking state with a boolean flag and firing custom events.

show() {
  this.isShown = true
  this.element.removeAttribute('hidden')
}

hide() {
  this.isShown = false
  this.element.setAttribute('hidden', true)
}

One practical tip: include hidden directly in the HTML from the start so the dialog never flashes before JavaScript loads.

<div id="my-dialog" hidden>This will be a dialog.</div>

For hiding on overlay click, we bind a click listener to any element marked with a data-dialog-hide attribute. This is generic enough that the close button can use the same hook—no need for separate logic per element.

<div id="my-dialog" hidden>
  <div data-dialog-hide></div>
  <div>This will be a dialog.</div>
</div>
constructor (element) {
  // … rest of the code
  // Bind our methods so they can be used in event listeners without losing the
  // reference to the dialog instance
  this._show = this.show.bind(this)
  this._hide = this.hide.bind(this)

  const closers = [...this.element.querySelectorAll('[data-dialog-hide]')]
  closers.forEach(closer => closer.addEventListener('click', this._hide))
}
<div id="my-dialog" hidden>
  <div data-dialog-hide></div>
  <div>
    This will be a dialog.
    <button type="button" data-dialog-hide>Close</button>
  </div>
</div>

Keyboard Handling And Focus Trapping

The Esc key closes the dialog, and the listener is only active while the dialog is open—bind on show, remove on hide. This keeps the page free of stray listeners.

show() {
  // … rest of the code
  // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide`
  document.addEventListener('keydown', this._handleKeyDown)
}

hide() {
  // … rest of the code
  // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide`
  document.removeEventListener('keydown', this._handleKeyDown)
}

handleKeyDown(event) {
  if (event.key === 'Escape') this.hide()
}

Focus trapping is the core mechanism. When the dialog is open, a Tab keydown handler cycles focus: hitting Tab past the last focusable element jumps to the first, and Shift+Tab from the first jumps backward to the last.

function trapTabKey(node, event) {
  const focusableChildren = getFocusableChildren(node)
  const focusedItemIndex = focusableChildren.indexOf(document.activeElement)
  const lastIndex = focusableChildren.length - 1
  const withShift = event.shiftKey

  if (withShift && focusedItemIndex === 0) {
    focusableChildren[lastIndex].focus()
    event.preventDefault()
  } else if (!withShift && focusedItemIndex === lastIndex) {
    focusableChildren[0].focus()
    event.preventDefault()
  }
}

To find the focusable set, we rely on a list of selectors covering everything that might be tabbable—buttons, links, inputs, and others—then filter for what's actually visible in the dialog.

module.exports = [
  'a[href]:not([tabindex^="-"])',
  'area[href]:not([tabindex^="-"])',
  'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])',
  'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked',
  'select:not([disabled]):not([tabindex^="-"])',
  'textarea:not([disabled]):not([tabindex^="-"])',
  'button:not([disabled]):not([tabindex^="-"])',
  'iframe:not([tabindex^="-"])',
  'audio[controls]:not([tabindex^="-"])',
  'video[controls]:not([tabindex^="-"])',
  '[contenteditable]:not([tabindex^="-"])',
  '[tabindex]:not([tabindex^="-"])',
]
import focusableSelectors from 'focusable-selectors'

function isVisible(element) {
  return element =>
    element.offsetWidth ||
    element.offsetHeight ||
    element.getClientRects().length
}

function getFocusableChildren(root) {
  const elements = [...root.querySelectorAll(focusableSelectors.join(','))]

  return elements.filter(isVisible)
}
handleKeyDown(event) {
  if (event.key === 'Escape') this.hide()
  else if (event.key === 'Tab') trapTabKey(this.element, event)
}

Keeping Focus Honest

Focus trapping has a loophole: if the user tabs away from the dialog container entirely—say, into the browser chrome and back into the page—the trap only works when the focus is already inside. To prevent the dialog from losing focus to outside content, we bind a focus listener on <body> while the dialog is visible, pulling focus back inside whenever the page regains it.

show () {
  // … rest of the code
  // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide`
  document.body.addEventListener('focus', this._maintainFocus, true)
}

hide () {
  // … rest of the code
  // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide`
  document.body.removeEventListener('focus', this._maintainFocus, true)
}

maintainFocus(event) {
  const isInDialog = event.target.closest('[aria-modal="true"]')
  if (!isInDialog) this.moveFocusIn()
}

moveFocusIn () {
  const target =
    this.element.querySelector('[autofocus]') ||
    getFocusableChildren(this.element)[0]

  if (target) target.focus()
}

What to focus on open is a design decision, but the options break down as:

  • The first element, using our existing focusable query.
  • The close button, especially when it's positioned near the top of the dialog.
  • The dialog container itself, which requires a tabindex="-1" fallback.

Note that if an element with autofocus exists, it wins over the first-item default.

Focus Restoration And Naming

While the trap keeps focus inside, we also need to send it in on open. When showing, store the current document.activeElement—usually the trigger button—so hide can restore it.

show() {
  this.previouslyFocused = document.activeElement
  // … rest of the code
  this.moveFocusIn()
}

Restoration on hide guards against edge cases like the element being removed from the DOM or being an SVG.

hide() {
  // … rest of the code
  if (this.previouslyFocused && this.previouslyFocused.focus) {
    this.previouslyFocused.focus()
  }
}

An accessible name is equally essential, governing how the dialog appears in the accessibility tree. Avoid aria-label due to known issues, and instead bind a heading—visible or hidden—via aria-labelledby.

<div id="my-dialog" hidden aria-labelledby="my-dialog-title">
  <div data-dialog-hide></div>
  <div>
    <h1 id="my-dialog-title">My dialog title</h1>
    This will be a dialog.
    <button type="button" data-dialog-hide>Close</button>
  </div>
</div>

Eventing And Teardown

For reacting to state changes, the library needs a simple event emitter supporting .on() and .off(), with show/hide triggering their respective handlers.

class Dialog {
  constructor(element) {
    this.events = { show: [], hide: [] }
  }
  on(type, fn) {
    this.events[type].push(fn)
  }
  off(type, fn) {
    const index = this.events[type].indexOf(fn)
    if (index > -1) this.events[type].splice(index, 1)
  }
}
class Dialog {
  show() {
    // … rest of the code
    this.events.show.forEach(event => event())
  }

  hide() {
    // … rest of the code
    this.events.hide.forEach(event => event())
  }
}

A .destroy() method unregisters all bound listeners, particularly Esc and body focus handlers, so the dialog leaves no trace.

class Dialog {
  destroy() {
    const closers = [...this.element.querySelectorAll('[data-dialog-hide]')]
    closers.forEach(closer => closer.removeEventListener('click', this._hide))

    this.events.show.forEach(event => this.off('show', event))
    this.events.hide.forEach(event => this.off('hide', event))
  }
}

The Complete Picture

Assembled, the library demonstrates just how much thinking—state tracking, focus management, event wiring—goes into a component many take for granted.

import focusableSelectors from 'focusable-selectors'

class Dialog {
  constructor(element) {
    this.element = element
    this.events = { show: [], hide: [] }

    this._show = this.show.bind(this)
    this._hide = this.hide.bind(this)
    this._maintainFocus = this.maintainFocus.bind(this)
    this._handleKeyDown = this.handleKeyDown.bind(this)

    element.setAttribute('hidden', true)
    element.setAttribute('role', 'dialog')
    element.setAttribute('aria-modal', true)

    const closers = [...element.querySelectorAll('[data-dialog-hide]')]
    closers.forEach(closer => closer.addEventListener('click', this._hide))
  }

  show() {
    this.isShown = true
    this.previouslyFocused = document.activeElement
    this.element.removeAttribute('hidden')

    this.moveFocusIn()

    document.addEventListener('keydown', this._handleKeyDown)
    document.body.addEventListener('focus', this._maintainFocus, true)

    this.events.show.forEach(event => event())
  }

  hide() {
    if (this.previouslyFocused && this.previouslyFocused.focus) {
      this.previouslyFocused.focus()
    }

    this.isShown = false
    this.element.setAttribute('hidden', true)

    document.removeEventListener('keydown', this._handleKeyDown)
    document.body.removeEventListener('focus', this._maintainFocus, true)

    this.events.hide.forEach(event => event())
  }

  destroy() {
    const closers = [...this.element.querySelectorAll('[data-dialog-hide]')]
    closers.forEach(closer => closer.removeEventListener('click', this._hide))

    this.events.show.forEach(event => this.off('show', event))
    this.events.hide.forEach(event => this.off('hide', event))
  }

  on(type, fn) {
    this.events[type].push(fn)
  }

  off(type, fn) {
    const index = this.events[type].indexOf(fn)
    if (index > -1) this.events[type].splice(index, 1)
  }

  handleKeyDown(event) {
    if (event.key === 'Escape') this.hide()
    else if (event.key === 'Tab') trapTabKey(this.element, event)
  }

  moveFocusIn() {
    const target =
      this.element.querySelector('[autofocus]') ||
      getFocusableChildren(this.element)[0]

    if (target) target.focus()
  }

  maintainFocus(event) {
    const isInDialog = event.target.closest('[aria-modal="true"]')
    if (!isInDialog) this.moveFocusIn()
  }
}

function trapTabKey(node, event) {
  const focusableChildren = getFocusableChildren(node)
  const focusedItemIndex = focusableChildren.indexOf(document.activeElement)
  const lastIndex = focusableChildren.length - 1
  const withShift = event.shiftKey

  if (withShift && focusedItemIndex === 0) {
    focusableChildren[lastIndex].focus()
    event.preventDefault()
  } else if (!withShift && focusedItemIndex === lastIndex) {
    focusableChildren[0].focus()
    event.preventDefault()
  }
}

function isVisible(element) {
  return element =>
    element.offsetWidth ||
    element.offsetHeight ||
    element.getClientRects().length
}

function getFocusableChildren(root) {
  const elements = [...root.querySelectorAll(focusableSelectors.join(','))]

  return elements.filter(isVisible)
}

Considerations For Production Use

Building a custom dialog is educational but rarely the right call for a real project. The complexity is high, and mistakes can seriously harm assistive technology users. Fortunately, solid alternatives exist if you do need dialogs in your work:

The implementation covered here intentionally skips several features that matter in production. You should investigate these before shipping:

For a broader perspective on accessible component patterns, you can also consult Tech Report’s related coverage on UI behavior and form design.