When building modals or dropdown menus, you need to know which elements can receive focus. This selector list covers links, inputs, buttons, and anything with a non-negative tabindex.
export const FOCUSABLE_ELEMENTS = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
'select:not([disabled]):not([aria-hidden])',
'textarea:not([disabled]):not([aria-hidden])',
'button:not([disabled]):not([aria-hidden])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex^="-"])',
]
export const getFocusableNodes = (context: HTMLElement) =>
context.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS.join(','))
export const getOffset = (element: HTMLElement) => {
const rect = element.getBoundingClientRect()
return {
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
}
}Focus trap
The most common reason to query focusable elements is building a focus trap inside a modal. When the user presses Tab on the last element, focus wraps back to the first. Shift+Tab on the first wraps to the last.
function trapFocus(modal: HTMLElement) {
const focusable = Array.from(getFocusableNodes(modal))
const first = focusable[0]
const last = focusable[focusable.length - 1]
modal.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return
if (event.shiftKey) {
if (document.activeElement === first) {
event.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
})
}Call trapFocus after the modal opens and has focus on its first element. Without this, keyboard users can Tab out of the modal into the page behind it, which breaks both usability and WCAG 2.1 criterion 2.1.2.
A few things to watch
querySelectorAll returns a static NodeList. If the modal’s content changes after you call getFocusableNodes — say, a form step renders new fields — re-query. It won’t update automatically.
Elements with tabindex="-1" are excluded on purpose. They can receive focus programmatically via .focus(), but they don’t belong in the natural tab order. Arrow-key navigation patterns (like listboxes or toolbars) do need them, so for those you’d run a separate query that includes [tabindex="-1"].
The aria-hidden filters also matter. A common pattern for animated modals is rendering the panel off-screen with aria-hidden="true" while it slides in. Without those :not([aria-hidden]) checks, you’d capture elements that are invisible to screen readers. Focus would jump to something the user can’t hear described.
iframe and object are in the list because browsers treat them as focusable. Whether focus can actually move inside them depends on cross-origin rules and the embedded content. For most apps you can leave them in; just know they’ll show up.