Contents
Introduction
Accessible modals must manage keyboard focus so that users who navigate with the keyboard or assistive technologies remain inside the modal while it is open. This article explains in exhaustive detail how to implement a focus trap in modals using plain JavaScript, how to integrate it into a WordPress theme or plugin, and how to handle real-world edge cases (animations, nested modals, dynamic content, and compatibility).
Why focus trapping matters
- Keyboard navigation: Tab and Shift Tab should cycle only through interactive elements inside the modal while it is open.
- Screen reader users: Ensuring focus is inside the modal helps users understand context use role=dialog and aria-modal=true.
- Focus return: After the modal closes, focus should be restored to the element that opened the modal (commonly a button or link).
- Background inertness: Interacting with content outside the modal should be prevented while modal is open.
Concepts and terms
- Initial focus: When opening a modal, focus should move to a meaningful element inside (first focusable, an element with autofocus, or the modal container via tabindex=-1).
- Tabbable elements: Elements that can receive focus via keyboard (links, form elements, buttons, elements with tabindex >= 0).
- Focus trap: Code that intercepts Tab/Shift Tab and keeps focus inside the modal.
- Inert: A semantic state meaning content outside the modal cannot be interacted with you can use the inert attribute (with polyfill) or set aria-hidden on background elements.
- Focus return: Save the previously focused element and restore focus when closing.
Focusable selectors
To implement a focus trap you need a reliable way to find focusable elements inside a container. The following selector set is commonly used and reasonably comprehensive.
| Selector | Description |
a[href] |
Links with an href attribute |
button:not([disabled]) |
Buttons that are not disabled |
input:not([type=hidden]):not([disabled]) |
Visible input fields |
select:not([disabled]) |
Select elements |
textarea:not([disabled]) |
Textareas |
[tabindex]:not([tabindex=-1]) |
Elements with explicit tabindex (not -1) |
[contenteditable=true] |
Contenteditable elements |
Simple focus-trap implementation (vanilla JS)
This small script demonstrates the core concept: collect tabbable elements, intercept Tab and Shift Tab, keep focus cycling inside the modal, and restore focus on close.
(function () {
var openBtn = document.getElementById(open-modal)
var modal = document.getElementById(modal)
var closeBtn = document.getElementById(modal-close)
var previouslyFocused = null
function getFocusableElements(container) {
var selectors = [
a[href],
area[href],
input:not([disabled]):not([type=hidden]),
select:not([disabled]),
textarea:not([disabled]),
button:not([disabled]),
iframe,
object,
embed,
[contenteditable],
[tabindex]:not([tabindex=-1])
]
return Array.prototype.slice.call(container.querySelectorAll(selectors.join(,)))
.filter(function (el) {
return el.offsetWidth > 0 el.offsetHeight > 0 el.getClientRects().length
})
}
function trapFocus(e) {
if (e.key !== Tab) return
var focusable = getFocusableElements(modal)
if (!focusable.length) {
e.preventDefault()
return
}
var first = focusable[0]
var last = focusable[focusable.length - 1]
var isShift = e.shiftKey
if (!isShift document.activeElement === last) {
e.preventDefault()
first.focus()
} else if (isShift document.activeElement === first) {
e.preventDefault()
last.focus()
}
}
function openModal() {
previouslyFocused = document.activeElement
modal.removeAttribute(hidden)
modal.setAttribute(aria-hidden, false)
// Ensure modal can be focused programmatically
modal.focus()
document.addEventListener(keydown, trapFocus)
}
function closeModal() {
document.removeEventListener(keydown, trapFocus)
modal.setAttribute(hidden, )
modal.setAttribute(aria-hidden, true)
if (previouslyFocused previouslyFocused.focus) {
previouslyFocused.focus()
}
}
openBtn.addEventListener(click, openModal)
closeBtn.addEventListener(click, closeModal)
})()
Explanation of the simple approach
- The script enumerates focusable elements when the Tab key is pressed instead of caching them to handle dynamic changes.
- It uses tabindex=-1 on the modal container so you can call modal.focus() and land the focus somewhere inside if needed.
- On open it saves the previously focused element and restores it when closing.
- This minimal approach works for many cases but lacks features like inerting background content, handling ESC, nested modals, or animation timing.
Robust production-ready focus trap
A production-grade trap includes: inerting the background, Escape to close, click-outside handling, transition-aware focus assignment, nested modal stacking, and support for dynamically added focusable controls. Below is a more comprehensive plain-JS implementation that you can adapt to WordPress.
/ Robust FocusTrap class
- usage: const trap = new FocusTrap(modalElement, { onClose: fn })
- call trap.activate() to open, trap.deactivate() to close.
/
(function (window, document) {
function FocusTrap(element, options) {
this.container = element
this.options = options {}
this.boundKeyHandler = this.handleKeyDown.bind(this)
this.boundFocusIn = this.handleFocusIn.bind(this)
this.boundClickOutside = this.handleClickOutside.bind(this)
this.previousActive = null
}
FocusTrap.prototype.getFocusable = function () {
var selectors = [
a[href],
area[href],
input:not([disabled]):not([type=hidden]),
select:not([disabled]),
textarea:not([disabled]),
button:not([disabled]),
iframe,
object,
embed,
[contenteditable],
[tabindex]:not([tabindex=-1])
]
var nodes = Array.prototype.slice.call(this.container.querySelectorAll(selectors.join(,)))
return nodes.filter(function (el) {
return el.offsetWidth > 0 el.offsetHeight > 0 el.getClientRects().length
})
}
FocusTrap.prototype.handleKeyDown = function (e) {
if (e.key === Escape) {
e.preventDefault()
this.deactivate()
return
}
if (e.key !== Tab) return
var focusable = this.getFocusable()
if (!focusable.length) {
e.preventDefault()
return
}
var first = focusable[0]
var last = focusable[focusable.length - 1]
if (!e.shiftKey document.activeElement === last) {
e.preventDefault()
first.focus()
} else if (e.shiftKey document.activeElement === first) {
e.preventDefault()
last.focus()
}
}
FocusTrap.prototype.handleFocusIn = function (e) {
if (this.container.contains(e.target)) return
// If focus moves outside, bring it to the first focusable
var focusable = this.getFocusable()
if (focusable.length) {
focusable[0].focus()
} else {
this.container.focus()
}
}
FocusTrap.prototype.handleClickOutside = function (e) {
if (!this.container.contains(e.target)) {
if (typeof this.options.closeOnOutsideClick === undefined ? true : !!this.options.closeOnOutsideClick) {
this.deactivate()
}
}
}
FocusTrap.prototype.activate = function () {
this.previousActive = document.activeElement
// Optionally inert the rest of the page. If you use the inert attribute in your project,
// apply it to siblings of the modal container. Alternative: set aria-hidden=true on them.
this.container.setAttribute(aria-hidden, false)
// Ensure modal can receive focus
if (!this.container.hasAttribute(tabindex)) {
this.container.setAttribute(tabindex, -1)
this._tabindexAdded = true
}
// Wait for any entrance animation to finish before focusing contents
var afterOpen = function () {
var focusable = this.getFocusable()
// If there is an element explicitly marked with data-autofocus or autofocus attribute use it.
var auto = this.container.querySelector([data-autofocus], [autofocus])
if (auto auto.focus) {
auto.focus()
} else if (focusable.length) {
focusable[0].focus()
} else {
this.container.focus()
}
document.addEventListener(keydown, this.boundKeyHandler, true)
document.addEventListener(focusin, this.boundFocusIn, true)
document.addEventListener(mousedown, this.boundClickOutside, true)
}.bind(this)
// If CSS animation is used, developer can add a class and trigger transitionend.
// Here we use a microtask to allow CSS classes to apply, then call afterOpen.
Promise.resolve().then(function () {
afterOpen()
})
}
FocusTrap.prototype.deactivate = function () {
document.removeEventListener(keydown, this.boundKeyHandler, true)
document.removeEventListener(focusin, this.boundFocusIn, true)
document.removeEventListener(mousedown, this.boundClickOutside, true)
if (this._tabindexAdded) {
this.container.removeAttribute(tabindex)
}
// Optionally remove aria-hidden on container or siblings
this.container.setAttribute(aria-hidden, true)
if (this.previousActive this.previousActive.focus) {
this.previousActive.focus()
}
if (typeof this.options.onClose === function) {
this.options.onClose()
}
}
// Expose
window.FocusTrap = FocusTrap
})(window, document)
How to use the FocusTrap class
- Create a focus trap instance for each modal container: const trap = new FocusTrap(modalEl, { onClose: afterCloseFn })
- Call trap.activate() when opening the modal and trap.deactivate() when closing.
- Use the onClose callback to perform cleanup or to hide the modal markup.
Handling animations and timing
If your modal opens and closes with CSS transitions or animations, avoid moving focus until the opening transition completes (so that screen readers and keyboard users don’t get confused by off-screen content that is not visually visible). Two approaches:
- Listen for transitionend or animationend on the modal and call focus trap activation afterwards.
- If transitions are very short, use a small setTimeout (e.g., 10–50ms) to yield to the browser and let the CSS apply before focusing.
Inerting background content (why and how)
While a modal is open, users should not be able to interact with the page content behind it. You can achieve this in two ways:
- Use the inert attribute: Set the inert attribute on all siblings of the modal or on a wrapper. Browser support is limited, but a polyfill exists. When inert is applied, those elements are removed from the sequential focus order.
- Use aria-hidden: Set aria-hidden=true on background elements (careful when the background already uses aria-hidden). This is a safe fallback if inert is unavailable, though it does not stop pointer events by itself.
Example of applying inert
// Simple function to set inert on all siblings (requires inert polyfill for cross-browser)
function setInertForSiblings(element, inert) {
var parent = element.parentElement
if (!parent) return
Array.prototype.forEach.call(parent.children, function (child) {
if (child === element) return
if (inert) {
child.setAttribute(inert, )
child.setAttribute(aria-hidden, true)
} else {
child.removeAttribute(inert)
child.removeAttribute(aria-hidden)
}
})
}
WordPress integration (enqueueing and markup)
To add the focus trap functionality to a WordPress theme or plugin, enqueue the script and ensure the modal markup includes the proper accessibility attributes. Below are example snippets for enqueueing and markup.
// In your plugin or theme functions.php
function mytheme_enqueue_modal_scripts() {
wp_enqueue_script(
my-modal-focus-trap,
get_stylesheet_directory_uri() . /js/modal-focus-trap.js,
array(),
1.0.0,
true
)
// Optionally pass selectors or options from PHP to JS
wp_localize_script(my-modal-focus-trap, MyModalSettings, array(
closeOnOutsideClick => true,
selector => #modal
))
}
add_action(wp_enqueue_scripts, mytheme_enqueue_modal_scripts)
Recommended modal HTML structure
Put this markup into a template part or output dynamically be sure the open control (button or link) and modal container are present.
Modal Title
More description for context.
Handling nested modals and stacking
- When opening a second (nested) modal, keep the previously opened modal in an inactive state but not removed from DOM. You can maintain a stack of FocusTrap instances and only activate the topmost trap.
- Do not restore focus to the opener of a nested modal until that nested modal is closed and the parent modal trap is reactivated.
Testing and debugging checklist
- Open modal with keyboard (Enter/Space) and ensure focus moves to an expected element in the modal.
- Use Tab and Shift Tab to cycle focus and confirm focus never escapes the modal.
- Press Escape to close the modal and confirm focus is restored to the opener.
- Try clicking outside to close (if supported) and confirm correct behavior.
- Test with a screen reader (NVDA, VoiceOver) to check spoken announcements and that background content is not read while modal open.
- Check focus after CSS transition animations complete.
- Test with dynamic content inserted into the modal after it is open and confirm focusable elements are tracted correctly.
Common edge cases and solutions
- No focusable elements inside modal: Give the modal container tabindex=-1 and focus it so keyboard users are inside the dialog.
- Inputs hidden by conditional rendering: Query focusable elements on each Tab or on focusin so newly inserted elements are considered.
- Buttons disabled on open: If the first element is disabled, find the next focusable or focus the container.
- Third-party UI interfering: Some libraries alter tabindex ensure your focus selector explicitly includes [tabindex] nodes and account for visibility checks.
- iframes: Iframes can contain focusable content treat them as focusable nodes and avoid unintentionally trapping into an iframes own document.
Alternative: use a tested library
If you prefer not to implement your own trap, consider using vetted libraries such as focus-trap or ally.js which provide battle-tested, accessible implementations. However, for WordPress developers who prefer small, dependency-free code, the examples above show how to achieve the same results using plain JavaScript.
Complete example: HTML CSS JS summary
Below is a compact example you can drop into a theme file and a script file. The CSS only shows basic layout and an overlay.
/ Basic modal CSS example /
.modal-backdrop {
position: fixed
inset: 0
background: rgba(0,0,0,0.5)
display: flex
align-items: center
justify-content: center
}
.modal-inner {
background: white
padding: 1rem
max-width: 600px
width: 100%
border-radius: 6px
}
[hidden] { display: none !important }
Modal Title
Description
// Simplified integration using the earlier FocusTrap class
document.addEventListener(DOMContentLoaded, function () {
var modal = document.getElementById(modal)
var open = document.getElementById(open-modal)
var close = document.getElementById(modal-close)
var trap = new FocusTrap(modal, {
closeOnOutsideClick: true,
onClose: function () {
modal.setAttribute(hidden, )
}
})
open.addEventListener(click, function () {
modal.removeAttribute(hidden)
// inert siblings if desired
trap.activate()
})
close.addEventListener(click, function () {
trap.deactivate()
})
})
Final notes and best practices
- Always provide accessible attributes: role=dialog, aria-modal=true, and refer to title/description with aria-labelledby and aria-describedby.
- Prefer moving focus to a meaningful element inside the modal (a close button, the first form control, or an element marked as autofocus).
- Restore focus to the opener after closing this maintains user orientation.
- Test with keyboard-only and screen reader users.
- Consider using the inert attribute (with polyfill) to disable background interaction.
- For WordPress, enqueue your scripts properly and keep the accessible markup in templates or rendered by PHP so search engines and assistive technologies receive it correctly.
|
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |
