TLDR
- New directive
v-dt-focustraptraps Tab/Shift+Tab within a container element. - Configurable initial focus: auto (first focusable), CSS selector, element ref, or disabled.
- Focus restoration on deactivation — returns focus to the element that triggered the overlay.
- Reactive — bind to your open/close state and the directive handles the full lifecycle.
- Companion to
v-dt-focusgroup— they solve different problems and compose together. - Storybook docs
The Motivation
When a dialog, popover, or drawer opens, Tab must not escape the container. Users who rely on keyboard navigation will Tab past the overlay into page content behind it — confusing, disorienting, and a WCAG 2.4.3 failure.
Focus trapping in Dialtone has been handled by the Modal mixin, which 5+ components wire up differently. Some call focusFirstElement() on mount, some on transition-end, some conditionally. Some restore focus, some don't. There's no shared primitive for new components — every team re-implements the same logic with subtle differences.
The Solution
One directive. One attribute. Zero event handlers.
Tab cycles through the inputs and buttons. Shift+Tab wraps backward. Focus never leaves the container. That's it.
Who Benefits
- Keyboard users stay inside the overlay without escaping into page content behind it
- Screen reader users get the expected dialog focus behavior — no silent focus loss
- Product teams ship accessible overlays without writing focus management logic
- Dialtone has one implementation to test, maintain, and improve
Configuration
Boolean binding — activate when truthy
<div v-dt-focustrap="isOpen" role="dialog" aria-label="Settings">
Object binding — full configuration
<div v-dt-focustrap="{ active: isOpen, initialFocus: '#name-input', restoreFocus: true }" role="dialog">
Always active
<div v-dt-focustrap role="alertdialog" aria-label="Confirm">
Options
| Option | Type | Default | Description |
|---|---|---|---|
active | boolean | true | Whether the trap is active. Reactive. |
initialFocus | 'auto' | string | HTMLElement | false | 'auto' | Where to place focus on activation. 'auto' = first focusable. CSS selector = el.querySelector(). false = don't move focus. |
restoreFocus | boolean | true | Restore focus to the previously-focused element on deactivation. |
Focustrap vs. Focusgroup
These directives solve different problems. Use both when a widget needs both behaviors.
v-dt-focustrap | v-dt-focusgroup | |
|---|---|---|
| Purpose | Prevent focus from leaving a container | Move focus within a container via arrow keys |
| Key handled | Tab / Shift+Tab | Arrow keys, Home, End |
| Typical use | Dialogs, modals, popovers, drawers | Toolbars, tab lists, menus, listboxes |
| Tab behavior | Cycles Tab at container boundaries | Single Tab stop — Tab exits the group |
| Composable? | Yes — use on the dialog container | Yes — use on a toolbar or menu inside the dialog |
What It Does NOT Do
- Escape key. Not handled. Add your own
@keydown.escapehandler. - Click outside. Not handled. Use a click-outside directive or manual listener.
aria-modalorrole. Not set. You must provide the appropriate ARIA semantics yourself.- Scroll lock. Not managed. Use CSS
overflow: hiddenon<body>if needed.
These boundaries are intentional. Each of those concerns has its own edge cases, and bundling them would turn a focus primitive into a full overlay framework.
Replacing a Custom Focus Trap
If your product feature has its own focus trap logic, you can replace it:
<!-- Before: manual focus trap -->
<div ref="dialog" @keydown.tab="trapFocus" role="dialog">
<!-- After: directive handles everything -->
<div v-dt-focustrap="isOpen" role="dialog">
Remove your @keydown.tab handler, your querySelectorAll calls for Tab trapping, and your previousActiveElement save/restore logic. Keep your Escape and click-outside handlers — those are yours to own.
