What is Focus trap?

Focus trap: A focus trap keeps keyboard focus inside an open modal, so Tab and Shift+Tab cycle through its controls instead of escaping to the page behind it.

A focus trap is the keyboard half of a modal. When a dialog is open, the page behind it is dimmed and not clickable, but keyboard focus has no idea; Tab would happily walk out of the dialog into the header, the sidebar and the footer, and a screen reader would follow. A trap intercepts that: from the last focusable element, Tab wraps to the first inside the dialog, and from the first, Shift+Tab wraps to the last. Together with moving focus in on open and back on close, this is what the WAI-ARIA dialog pattern requires.

When to trap, and when not

  • Trap in modal dialogs, modal drawers and side sheets, full-screen menus on mobile, and any overlay with a scrim that blocks the page.
  • Do not trap in popovers, tooltips, non-modal panels or inline disclosures. Users must be able to Tab past them.
  • Always provide an exit. Escape closes the dialog, and there is a visible close button, so a trap never becomes a cage. Touch screen reader users in particular need the button.

Ways to implement it

  • The `inert` attribute on everything except the dialog. Inert content cannot be focused, clicked or read by assistive technology, so there is nothing to trap against. It is supported in most browsers and is the cleanest approach. Apply it to the siblings of the dialog's container, never to an ancestor of the dialog itself.
  • The native `dialog` element with showModal() makes the rest of the document inert for you and handles Escape.
  • A manual trap: listen for Tab on the dialog, collect its focusable elements (a[href], button, input, [tabindex] and so on, minus disabled and hidden ones) and wrap focus at the ends. Needed when the dialog contains a portaled popover that lives outside its DOM subtree, or on older browsers.
  • Sentinels: an invisible focusable element before and after the dialog that pushes focus back inside when reached. Simple, but it breaks for content that is portaled out.

Details that matter

  • On open, focus the first field, or the least destructive button in a confirmation, or the dialog container with tabIndex={-1} when nothing should be preselected.
  • On close, return focus to the element that opened the dialog, even if it has re-rendered. Store a ref to it.
  • Lock page scroll while the dialog is open, and reserve the scrollbar gutter so the page does not shift.
  • Nested overlays, such as a select inside a dialog, must count as part of the dialog, or focus bounces between two traps.

How it works in MiniDev

Dialog, AlertDialog and CommandDialog are built on the Base UI dialog, whose modal mode traps focus, locks scroll and disables pointer interaction outside, with initialFocus and finalFocus props to choose where focus goes on open and close:

tsx
"use client"
import * as React from "react"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"

export function Rename() {
  const first = React.useRef<HTMLInputElement>(null)
  return (
    <Dialog>
      <DialogTrigger>Rename</DialogTrigger>
      <DialogContent initialFocus={first}>
        <DialogTitle>Rename project</DialogTitle>
        <input ref={first} aria-label="Project name" className="h-9 rounded-lg border border-border px-3" />
      </DialogContent>
    </Dialog>
  )
}

Sheet and Drawer move focus into the panel on open, close on Escape and hand focus back; when you use them modally, add inert to the page's header and main regions while they are open. Keep the focus-visible ring strong inside overlays, where keyboard users rely on it most.

DialoguiShadcn compatible modal dialog on Base UI with a blurred backdrop, enter and exit animation, close button, header, title, description and a footer bar.npx shadcn@latest add @minidev/dialogAlert DialoguiShadcn style alert dialog on Base UI for confirmations that need an explicit answer. Modal backdrop, focus trap, title, description and an actions row.npx shadcn@latest add @minidev/alert-dialog