vue-focus-guard
v1.3.0
Published
Zero-dependency Vue 3 component that traps keyboard focus within a DOM element — written from scratch.
Maintainers
Readme
vue-focus-guard
Vue 3 component to trap keyboard focus within a DOM element.
Written from scratch — no focus-trap peer dependency. Useful for modals, dialogs, drawers, and anything that needs to be keyboard-accessible.
Installation
npm install vue-focus-guardUsage
FocusTrap can be controlled in three ways:
- using the
activeprop directly - using
v-model:active(recommended) - calling
activate()/deactivate()on a template ref
v-model:active
<script setup>
import { ref } from 'vue'
import { FocusTrap } from 'vue-focus-guard'
const isOpen = ref(false)
</script>
<template>
<button @click="isOpen = true">Open dialog</button>
<FocusTrap v-model:active="isOpen">
<dialog :open="isOpen">
<h2>Trapped!</h2>
<p>Tab stays inside while this is open.</p>
<button @click="isOpen = false">Close</button>
</dialog>
</FocusTrap>
</template>When isOpen becomes true, the trap activates and focus moves to the first tabbable element inside the dialog. Pressing Escape (or clicking "Close") sets isOpen back to false.
Imperative via template ref
<script setup>
import { ref } from 'vue'
import { FocusTrap } from 'vue-focus-guard'
import type { FocusTrapExposed } from 'vue-focus-guard'
const trap = ref<FocusTrapExposed>()
</script>
<template>
<FocusTrap ref="trap" :active="false">
<div role="dialog">
<button @click="trap?.deactivate()">Close</button>
</div>
</FocusTrap>
<button @click="trap?.activate()">Open</button>
</template>Global registration
import { createApp } from 'vue'
import { FocusTrap } from 'vue-focus-guard'
import App from './App.vue'
createApp(App)
.component('FocusTrap', FocusTrap)
.mount('#app')Props
FocusTrap requires exactly one child element (or component). It clones the child and attaches listeners to it.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| active | boolean | true | Whether the trap is on. Use v-model:active to sync with parent state. |
| escapeDeactivates | boolean | true | Press Escape to close and restore focus. |
| returnFocusOnDeactivate | boolean | true | When deactivating, return focus to the element that had it before activation. |
| allowOutsideClick | boolean \| (e: MouseEvent\|TouchEvent) => boolean | true | Whether clicks outside the trap are allowed. Pass a function for per-click control. |
| clickOutsideDeactivates | boolean \| (e: MouseEvent\|TouchEvent) => boolean | false | Close the trap when clicking outside. |
| initialFocus | string \| HTMLElement \| () => HTMLElement \| false | first tabbable | What gets focused on activation. A CSS selector, an element, a function that returns one, or false to skip auto-focus. |
| fallbackFocus | string \| HTMLElement \| () => HTMLElement | container | What to focus when no tabbable elements are found. Falls back to focusing the container itself if not set. |
| delayInitialFocus | boolean | true | Wait a microtask before setting initial focus. Helpful when the child element has an enter animation. |
| preventScroll | boolean | false | Passed to .focus({ preventScroll }) — stops the page from jumping when focusing an off-screen element. |
Events
| Event | Payload | When it fires |
|-------|---------|---------------|
| activate | — | The moment the trap turns on |
| postActivate | — | After the initial element has been focused |
| deactivate | — | The moment the trap turns off |
| postDeactivate | — | After focus has been returned to the previous element |
| update:active | boolean | For v-model:active two-way binding |
Methods (via template ref)
Use ref typed as FocusTrapExposed to get access to the imperative API:
import type { FocusTrapExposed } from 'vue-focus-guard'
const trap = ref<FocusTrapExposed>()
trap.value?.activate() // turn the trap on
trap.value?.deactivate() // turn it off and restore focus
trap.value?.pause() // suspend trapping without deactivating
trap.value?.unpause() // resume after a pauseExamples
Custom initial focus
<FocusTrap v-model:active="open" :initial-focus="() => nameInput">
<dialog :open="open">
<label>
Name
<input ref="nameInput" type="text" />
</label>
<button @click="open = false">Submit</button>
</dialog>
</FocusTrap>Skip auto-focus (just trap Tab)
<FocusTrap v-model:active="open" :initial-focus="false">
<div role="dialog">...</div>
</FocusTrap>Click outside closes the trap
<FocusTrap v-model:active="open" :click-outside-deactivates="true">
<div class="dropdown">...</div>
</FocusTrap>Selectively block outside clicks
<FocusTrap
v-model:active="open"
:allow-outside-click="(e) => e.target.closest('.toolbar') !== null"
>
<div role="dialog">...</div>
</FocusTrap>No Escape key
<FocusTrap v-model:active="open" :escape-deactivates="false">
<div role="dialog">
<button @click="open = false">Only way out</button>
</div>
</FocusTrap>Pause and unpause (nested traps)
If a second modal or tooltip opens on top of an existing trap, pause the outer one while the inner is active:
<script setup>
const outer = ref<FocusTrapExposed>()
const innerOpen = ref(false)
function openInner() {
outer.value?.pause()
innerOpen.value = true
}
function closeInner() {
innerOpen.value = false
outer.value?.unpause()
}
</script>
<template>
<FocusTrap ref="outer" v-model:active="outerOpen">
<div role="dialog">
<button @click="openInner">Open inner</button>
</div>
</FocusTrap>
<FocusTrap v-model:active="innerOpen" @deactivate="closeInner">
<div role="dialog">...</div>
</FocusTrap>
</template>Fallback focus (no tabbable children)
<FocusTrap v-model:active="open" fallback-focus="#my-dialog">
<div id="my-dialog" role="dialog">
<!-- no interactive elements here, but focus will land on the div -->
<p>Read-only content</p>
</div>
</FocusTrap>Utilities
The package exports its tabbable-element helpers if you need them directly:
import { getTabbable, getFirstTabbable, getLastTabbable } from 'vue-focus-guard'
const all = getTabbable(containerEl) // all focusable elements in order
const first = getFirstTabbable(containerEl)
const last = getLastTabbable(containerEl)Elements are included only if they pass a visibility check (not display:none, visibility:hidden, hidden, or [inert]).
How it works
- Tabbable detection — own selector-based scan covering
<a>,<button>,<input>,<select>,<textarea>,[contenteditable],[tabindex],<details summary>,<audio controls>,<video controls>. Filtered for visibility and[inert]ancestors. - Tab cycling —
keydownlistener attached in capture phase redirects Tab/Shift+Tab at the boundary, wrapping around. - Focus escape guard —
focusinlistener in capture phase catches focus landing outside the container and pulls it back. - Click outside —
mousedown/touchstartcaptured before the click, checked against the container. RunsallowOutsideClickorclickOutsideDeactivateslogic before deciding what to do. - Pause — sets an
isPausedflag; all handlers skip processing while paused. The listeners stay attached so unpause is instant. - Cleanup — all listeners removed on
deactivate()and also ononBeforeUnmount.
Dist formats
| File | Format | Use case |
|------|--------|---------|
| dist/focus-trap-vue.esm.js | ESM | Bundlers (Vite, webpack) |
| dist/focus-trap-vue.cjs.js | CJS | Node / require() |
| dist/focus-trap-vue.cjs.prod.js | CJS minified | Production Node |
| dist/focus-trap-vue.global.js | IIFE | <script> tag / CDN |
License
MIT © Anmol Agarwal
