Skip to main content
The focus trap utility confines keyboard focus to a container element. It is commonly used in modals, dialogs, drawers, and other overlay patterns where focus must not escape to the rest of the page while the overlay is open. Features
  • Handles edge cases such as hidden or dynamically added elements
  • Works with shadow DOM via the getShadowRoot option
  • Supports nested focus traps — pauses the outer trap while an inner one is active
  • Follows ARIA-controlled elements (e.g., portalled menus opened from within the trap)
  • Automatically restores focus to the previously focused element on deactivation

Installation

npm install @zag-js/focus-trap

Usage

trapFocus

The trapFocus function is the primary entry point. Pass an element (or a getter function that returns one), and it returns a destroy function to release the trap.
import { trapFocus } from "@zag-js/focus-trap"

const destroy = trapFocus(document.getElementById("modal"))

// Later, release the trap and restore focus
destroy()
You can also pass a getter function, which is useful when the element may not yet exist in the DOM at call time.
const destroy = trapFocus(() => document.getElementById("modal"))

Default behavior

By default, trapFocus is configured with sensible defaults:
OptionDefault value
escapeDeactivatesfalse
allowOutsideClicktrue
preventScrolltrue
returnFocusOnDeactivatetrue
delayInitialFocusfalse
Override any of these by passing an options object as the second argument.
const destroy = trapFocus(element, {
  initialFocus: "#first-input",
  returnFocusOnDeactivate: true,
  escapeDeactivates: true,
})

Multiple containers

Pass an array of elements to trap focus across multiple container nodes simultaneously.
const destroy = trapFocus([containerA, containerB])

Using the FocusTrap class directly

For advanced use cases — such as pausing and resuming a trap independently — use the FocusTrap class.
import { FocusTrap } from "@zag-js/focus-trap"

const trap = new FocusTrap(element, {
  returnFocusOnDeactivate: true,
  escapeDeactivates: false,
})

trap.activate()

// Temporarily suspend trapping (e.g., while a nested trap is open)
trap.pause()
trap.unpause()

// Fully deactivate and return focus
trap.deactivate()

Shadow DOM support

Enable shadow DOM traversal with the getShadowRoot option.
// Enable for all open shadow roots
const destroy = trapFocus(element, {
  getShadowRoot: true,
})

// Custom function for closed shadow roots
const destroy = trapFocus(element, {
  getShadowRoot: (node) => {
    if (node === myCustomElement) return myCustomElement.shadowRoot
    return node.shadowRoot
  },
})

Animated containers

When trapping focus in an element that fades in, use checkCanFocusTrap to delay activation until the element is ready to receive focus.
const destroy = trapFocus(element, {
  checkCanFocusTrap: (containers) =>
    new Promise((resolve) => {
      // Resolve once the container animation ends
      containers[0].addEventListener("animationend", resolve, { once: true })
    }),
})
Similarly, use checkCanReturnFocus to delay returning focus to the trigger element when it fades back in.

API reference

trapFocus

function trapFocus(
  el: HTMLElement | null | (() => HTMLElement | null) | Array<HTMLElement | null | (() => HTMLElement | null)>,
  options?: TrapFocusOptions,
): () => void
Creates and immediately activates a focus trap on the given element(s). Returns a destroy function that deactivates the trap and restores focus.

FocusTrap class

class FocusTrap {
  constructor(elements: HTMLElement | HTMLElement[], options: FocusTrapOptions)

  get active(): boolean
  get paused(): boolean

  activate(options?: ActivateOptions): this
  deactivate(options?: DeactivateOptions): this
  pause(options?: PauseOptions): this
  unpause(options?: UnpauseOptions): this
  updateContainerElements(elements: HTMLElement | HTMLElement[]): this
}

FocusTrapOptions

initialFocus
FocusTarget | false | undefined
The element to focus when the trap activates. Accepts an HTMLElement, a CSS selector string, a getter function, or false to skip initial focus entirely.
fallbackFocus
FocusTarget
An element to focus programmatically if no tabbable element is found in the trap. Ensure the element has a negative tabindex so it can receive programmatic focus.
returnFocusOnDeactivate
boolean
default:"true"
When true, focus returns to the element that was focused before the trap was activated.
setReturnFocus
HTMLElement | string | ((nodeFocusedBefore: HTMLElement | SVGElement) => HTMLElement | string | false)
Override which element receives focus on deactivation instead of the default previously focused element.
escapeDeactivates
boolean | (event: KeyboardEvent) => boolean
default:"true"
When true, pressing Escape deactivates the trap. Pass a function for conditional logic.
clickOutsideDeactivates
boolean | (event: MouseEvent | TouchEvent) => boolean
default:"false"
When true, clicking outside the trap deactivates it and allows the click to pass through.
allowOutsideClick
boolean | (event: MouseEvent | TouchEvent) => boolean
default:"false"
When true, clicks outside the trap are not prevented, even when clickOutsideDeactivates is false.
preventScroll
boolean
default:"false"
When true, the page will not scroll when focusing the initial element.
delayInitialFocus
boolean
default:"true"
When true, the initial focus is applied in a setTimeout to avoid capturing the event that triggered trap activation.
document
Document
The document to use. Useful in <iframe> contexts. Defaults to window.document.
isKeyForward
(event: KeyboardEvent) => boolean
Determines if the given keyboard event moves focus forward. Defaults to Tab.
isKeyBackward
(event: KeyboardEvent) => boolean
Determines if the given keyboard event moves focus backward. Defaults to Shift+Tab.
trapStack
FocusTrap[]
An external array for managing multiple nested traps. Defaults to a shared internal stack.
followControlledElements
boolean
default:"true"
When true, elements controlled via aria-controls with aria-expanded="true" (such as portalled menus) are included in the trap’s tabbable scope.
getShadowRoot
boolean | (node: HTMLElement | SVGElement) => ShadowRoot | boolean | undefined
Enables shadow DOM traversal. Pass true for open shadow roots or a function for closed shadow roots.
checkCanFocusTrap
(containers: Array<HTMLElement | SVGElement>) => Promise<void>
Called during activation. Return a promise that resolves when it is safe to focus the trap container. Useful for animated elements.
checkCanReturnFocus
(trigger: HTMLElement | SVGElement) => Promise<void>
Called during deactivation when returning focus. Return a promise that resolves when the trigger element is ready to receive focus.
onActivate
() => void
Called before focus is sent to the initial element on activation.
onPostActivate
() => void
Called after focus has been sent to the initial element on activation.
onDeactivate
() => void
Called before the trap is deactivated.
onPostDeactivate
() => void
Called after the trap is fully deactivated and focus has been restored.
onPause
() => void
Called immediately after the trap’s state is set to paused.
onPostPause
() => void
Called after the trap has finished pausing and is no longer managing focus.
onUnpause
() => void
Called immediately after the trap’s state is set back to active.
onPostUnpause
() => void
Called after the trap has fully resumed managing focus.

Build docs developers (and LLMs) love