Introducing v-dt-focustrap: Declarative Focus Trapping

Francis Rupert avatar
Francis Rupert

New Vue directive for focus trapping. Trap Tab/Shift+Tab within dialogs, popovers, and overlays with a single attribute — configurable initial focus, boundary wrapping, and focus restoration built in.

TLDR

  • New directive v-dt-focustrap traps 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.escape handler.
  • Click outside. Not handled. Use a click-outside directive or manual listener.
  • aria-modal or role. Not set. You must provide the appropriate ARIA semantics yourself.
  • Scroll lock. Not managed. Use CSS overflow: hidden on <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.

documentation last updated Wednesday, September 16, 2026