Skip to main content
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
Vuetify One
Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

useFocusTrap

A composable that confines keyboard focus to a root element for as long as the trap is active.

Edit this page
Report a Bug
Open issues
View on GitHub
Copy Markdown

PreviewIntermediateAug 31, 2026

Usage

A native <dialog> opened with showModal() is trapped by the browser, so it needs nothing here. Modal overlays that are not a native dialog do: a Dialog.Content rendered as="div", a drawer, a command palette. Tooltips and menus typically do not. useFocusTrap wraps Tab and Shift+Tab at the first and last tabbable descendant, focuses into the root on activate, and returns focus to the previously focused element on deactivate, unless focus already left the root.

vue
<script setup lang="ts">
  import { useFocusTrap } from '@vuetify/v0'
  import { shallowRef, useTemplateRef } from 'vue'

  const isOpen = shallowRef(false)
  const panel = useTemplateRef<HTMLElement>('panel')

  useFocusTrap(panel, { active: isOpen })
</script>

<template>
  <button @click="isOpen = true">Open</button>

  <div v-if="isOpen" ref="panel" tabindex="-1" aria-label="Trapped panel">
    <input>
    <button @click="isOpen = false">Close</button>
  </div>
</template>

Architecture

useFocusTrap composes useDocumentEventListener and useToggleScope. Each engaged listen: true trap binds its own document-level listener; a module-level stack chooses which of those listeners owns Tab and Escape:

Focus Trap Composition

Use controls to zoom and pan. Click outside or press Escape to close.

Focus Trap Composition

The keydown listener is bound to document in the capture phase, not to the root. A root-bound listener stops firing the moment focus leaves the subtree — a backdrop click that blurs to <body>, or a stray programmatic focus() — and the trap is then dead with no way back. Binding at the document means an escaped focus is recovered on the next Tab, and containment survives an inner stopPropagation().

Only the boundaries are intercepted. A Tab press in the middle of the list passes through untouched, so a nested widget keeps full control of its own Tab handling everywhere except the first and last stop.

Options

OptionTypeDefaultDescription
activeMaybeRefOrGetter<boolean>undefinedReactive activation source. Omit it to drive the trap imperatively
initialfalse | MaybeElementRefundefinedWhere focus lands on activate. false skips autofocus; an element focuses that node instead of the first tabbable one. If the node cannot take focus, the first tabbable descendant (or the root) is used instead
restorebooleantrueReturn focus to the previously focused element on deactivate. Skipped when focus already sits on an outside control; a blur to <body> still restores
listenbooleantrueBind the capture-phase document listener. Set false to drive onKeydown yourself — a listen: false trap is not pushed onto the nest stack
onEscape(event: KeyboardEvent) => voidundefinedCalled on Escape while this trap is the last connected trap that has onEscape and owns focus. Opt-in — the trap never closes anything itself
ts
import { useFocusTrap } from '@vuetify/v0'
import { shallowRef, useTemplateRef } from 'vue'

const isOpen = shallowRef(false)
const panel = useTemplateRef<HTMLElement>('panel')
const cancel = useTemplateRef<HTMLElement>('cancel')

// Destructive dialogs should land on the safe action
useFocusTrap(panel, { active: isOpen, initial: cancel })

Reactivity

Property/MethodReactiveNotes
isActiveShallowRef, readonly. The trap’s state — active is only its source
activate()-Captures the return-focus target and engages containment
deactivate()-Releases containment and returns focus
onKeydown()-The handler. Bound on document unless listen is false

Examples

Panel with Trapped Focus

A panel holding two text inputs and two buttons. Opening it moves focus to the first input; Tab from the Confirm button wraps back to that input rather than continuing into the page, and Shift+Tab from the first input wraps to Confirm. Escape and Cancel both close the panel, and focus returns to the trigger that opened it.

Reach for this whenever a modal overlay is not a native <dialog>. Pair it with useStack for z-index and dismissal ordering, and with useClickOutside for pointer dismissal — the trap deliberately owns focus order and nothing else.

Open the panel, then Tab — focus stays inside until you close it.

Theme
Mode
Palettes
Accessibility

Recipes

Escape Only for the Top Overlay

Nested traps already resolve to the last connected trap that has onEscape. Nested overlays still need a stacking guard when dismissal policy is overlay-stack, not trap-stack — useStack tracks which overlay is topmost:

ts
import { useFocusTrap, useStack } from '@vuetify/v0'
import { shallowRef, useTemplateRef } from 'vue'

const isOpen = shallowRef(false)
const panel = useTemplateRef<HTMLElement>('panel')
const stack = useStack()
const ticket = stack.register({ onDismiss: () => (isOpen.value = false) })

useFocusTrap(panel, {
  active: isOpen,
  onEscape: event => {
    if (!ticket.globalTop.value) return
    event.preventDefault()
    isOpen.value = false
  },
})

Releasing Tab to an Inner Widget

A code editor or grid inside the trap needs Tab for itself. Because only the boundaries are intercepted, this works without configuration — Tab presses between the first and last stop are never touched.

A widget that sits at a boundary cannot opt out, though. The trap listens on document in the capture phase, so it has already wrapped focus by the time a handler inside the root runs; that handler’s preventDefault() comes too late. Two ways out:

  • Keep the widget off the boundary. A tabbable element after it — a footer button, or a tabindex="0" sentinel — makes the widget interior, and its Tab handling is then untouched.

  • Own the listener. Pass listen: false and call onKeydown yourself — otherwise the document capture listener has already wrapped.

ts
import { useFocusTrap } from '@vuetify/v0'
import { shallowRef, useTemplateRef } from 'vue'

const isOpen = shallowRef(false)
const panel = useTemplateRef<HTMLElement>('panel')
const editorHasFocus = shallowRef(false)
const trap = useFocusTrap(panel, { active: isOpen, listen: false })

function onPanelKeydown (event: KeyboardEvent) {
  if (editorHasFocus.value && event.key === 'Tab') return

  trap.onKeydown(event)
}

Accessibility

A focus trap is one half of a modal contract. The trap manages focus order; you still supply the rest:

ResponsibilityWhere it lives
role="dialog" and aria-modal="true"Your markup, or Dialog.Content
An accessible namearia-label, or Dialog.Title with aria-labelledby
Escape to dismissonEscape, or the stack ticket’s onDismiss
Pointer dismissaluseClickOutside
Inerting the rest of the pageNative <dialog>, or the inert attribute
tabindex="-1" on the rootYour markup

aria-disabled="true" controls are treated as tabbable, per APG↗︎: an aria-disabled control stays in the tab order, and skipping it would let the browser walk past the computed boundary and out of the trap. This is a deliberate divergence from roving-focus composables like useRovingFocus, which must skip disabled items.

The boundary is computed the way the browser computes tab order, so a radio group counts as one stop — the checked radio if it is otherwise tabbable, otherwise the first otherwise-tabbable member. Disabled, hidden, or inert members are not stops and do not hide the rest of the group. Grouping follows the HTML rule of same name plus same form owner, so two forms on a page keep independent groups.

Positive tabindex values are the one case the trap declines to model: candidates are collected in document order and never re-sorted into their real tab position, so a root containing them can leak at the boundary. Use 0 and -1 only.

FAQ

Discord
Need help? Join our community for support and discussions ↗

API Reference

The following API details are for the useFocusTrap composable.
Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/