Skip to main content

useFocusTrap

Overview​

useFocusTrap keeps keyboard focus inside a container while it is active: focus moves in when the trap turns on, Tab and Shift+Tab wrap between the first and last tab stops, and focus goes back where it came from when the trap turns off.

Use it for your own floating or modal content, such as a filter panel, a command palette or a custom popover. Modal, Dialog and the date and time pickers already manage focus themselves.


Import​

import { useFocusTrap } from '@allxsmith/bestax-bulma';

Usage​

A panel opened from a button​

Pass a ref to the container and turn the trap on with the panel. Give the container tabIndex={-1} so it can hold focus itself when nothing inside can. Open the panel below, then Tab past Cancel or Shift+Tab past the input: focus wraps, skipping the disabled button.

function example() {
  const [open, setOpen] = useState(false);
  const buttonRef = React.useRef<HTMLButtonElement>(null);
  const panelRef = React.useRef<HTMLDivElement>(null);
  useFocusTrap(panelRef, { active: open, restoreFocus: buttonRef });

  return (
    <>
      <Button
        ref={buttonRef}
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls="filters-panel"
        onClick={() => setOpen(o => !o)}
      >
        Filters
      </Button>
      {open && (
        <div
          ref={panelRef}
          id="filters-panel"
          role="dialog"
          aria-label="Filters"
          tabIndex={-1}
          onKeyDown={e => e.key === 'Escape' && setOpen(false)}
        >
          <Box mt="3">
            <Input label="Name contains" />
            <Buttons>
              <Button color="primary" onClick={() => setOpen(false)}>
                Apply
              </Button>
              <Button disabled>Export</Button>
              <Button onClick={() => setOpen(false)}>Cancel</Button>
            </Buttons>
          </Box>
        </div>
      )}
    </>
  );
}

The trap doesn't close anything. Escape, a click outside and the buttons that dismiss the panel are the component's to handle, as the onKeyDown above does.

Where focus starts​

On its own the trap focuses the first tab stop inside the container, or the container when there is none. initialFocusRef picks a specific element instead, such as the search box of a command palette.

const searchRef = useRef<HTMLInputElement>(null);
useFocusTrap(paletteRef, { active: open, initialFocusRef: searchRef });

Where focus goes back to​

restoreFocus decides where focus lands when the trap turns off, whether because active went false or because the component unmounted.

  • true (the default) returns it to the element that had focus when the trap turned on.
  • A ref sends it to that element instead. Prefer this for a panel opened from a button: some browsers don't focus a button when it is clicked, and content that focuses itself as it mounts (an autoFocus input) takes focus before the trap can see where it came from.
  • false leaves focus alone.

Focus only moves back while the trap still holds it. If a click has already put focus on something else outside the container, it stays there.

With portals​

A trapped panel is often rendered through a portal, at the end of document.body. The trap waits for hydration, so it also attaches to a container that only appears once the page has hydrated, the way portaled content does.

The other way round doesn't work: a portal rendered inside the trapped container puts its content somewhere else in the DOM, outside the container, where Tab from the trap can't reach it and the trap doesn't hold focus. Render a nested overlay inside the container, or give it a trap of its own.

Which elements are tab stops​

The trap wraps at the first and last elements the browser's Tab would visit, taken in document order:

  • links with an href, buttons, inputs other than type="hidden", selects and text areas
  • the areas with an href of an image map whose image is shown, taken where the <map> sits
  • frames: <iframe>, <embed> and <object>
  • <audio> and <video> with controls
  • the summary of a <details>, which stays a stop while the details is closed
  • an editable region (contentEditable), counted once rather than once per editable element inside it
  • anything with a tabIndex of 0 or more
  • the content of an open shadow root inside the container, such as a web component's own controls, with anything slotted into it taken where its slot is

Within those, what the browser's Tab skips is skipped here too, so none of it can become an end of the trap and let Tab walk out of the container:

  • a negative tabIndex, such as the unfocused items of a roving-tabindex grid
  • disabled controls, including those inside a disabled <fieldset>
  • anything not rendered: hidden, display: none on the element or an ancestor (for slotted content, an ancestor of its slot counts), visibility: hidden, the content of a closed <details>, and a child of an open shadow root's host that no slot takes
  • anything inside an inert subtree
  • all but one button of a radio group: the checked one, or the first when none is checked

When Tab leaves anyway​

A browser can leave out a stop the page has, such as links when its settings say to. When a Tab the trap let through still takes focus out of the container, the trap sends it back to the other end. If the container is the last thing on the page, there is nothing outside for focus to land on and the browser can take it to its own toolbar instead, so avoid ending a trap with a link.

Only Tab is watched. A click or a script that moves focus out of the container is left alone, and the next Tab starts from wherever focus went. Pulling focus back from a click is modal behaviour, and a modal also has to block the page behind it; Modal does both. For a non-modal panel, close it when focus or a click goes elsewhere.

What the trap can't see into​

Some elements move focus among parts of their own, and the trap sees them as one stop:

  • A frame's content is its own document. Once focus is inside, Tab moves through the frame without the trap seeing it, and Tab from the frame's last stop goes on to whatever follows the frame. At the end of the trap, that is outside it: follow a frame with another stop.
  • A native date or time input has a stop for each field. At the edge of the trap, Tab wraps instead of visiting the later fields. Keep it away from the edges, or follow it with another stop.
  • A closed shadow root can't be looked into. The trap sees its host and the host's own children, but not the stops inside the root or which of those children a slot takes. A child that no slot takes isn't rendered and can't take focus, so if it ends up as the trap's first or last stop, turning the trap on or wrapping Tab to it leaves focus where it was. Keep a closed-root host away from the trap's edges, or give it no children that a slot doesn't take.
  • A scrollable region with nothing focusable inside is a stop in some browsers and not in others. Give it tabIndex={0} so it is one everywhere, which also lets keyboard users scroll it.

What the trap takes on trust​

  • A browser can visit an image map's areas where the <map> sits or where its image is. The trap takes them where the map is, so keep the map right after its image, where the two agree.
  • A positive tabIndex changes the order the browser's Tab visits, but not the trap's ends, which stay the first and last stops in document order. Where the two disagree, a Tab past the browser's end is let through and then sent to the trap's end instead, which can be the very element it left, so focus seems stuck. Keep positive tabIndex out of a trap.
  • The trap listens for Tab on the container. Content inside that stops a Tab keydown from propagating keeps it from the trap, so that Tab isn't wrapped, and focus it takes out isn't sent back.

Nested traps​

The trap listens for Tab on its container, so a trap inside another one handles Tab first and the outer trap stands aside. A panel that opens a second panel inside it keeps focus in the inner one.


API​

function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
options?: UseFocusTrapOptions
): void;

interface UseFocusTrapOptions {
active?: boolean;
initialFocusRef?: RefObject<HTMLElement | null>;
restoreFocus?: boolean | RefObject<HTMLElement | null>;
}

Parameters​

ParameterTypeDefaultDescription
containerRefRefObject<HTMLElement | null>—The element focus stays inside. It has to be in the DOM when the trap turns on.
options.activebooleantrueWhether the trap is on. Turning it on moves focus in; turning it off (or unmounting) releases Tab and restores focus.
options.initialFocusRefRefObject<HTMLElement | null>—The element to focus when the trap turns on. Without it, or while it points at nothing, the first tab stop, then the container.
options.restoreFocusboolean | RefObject<HTMLElement | null>trueWhere focus goes when the trap turns off: true for the element focused when it turned on, a ref for that element, false to leave focus alone.

It returns nothing. On the server it does nothing, and the container renders as written.


Accessibility​

  • Trap focus in content that asks for the reader's attention until it is dismissed: a dialog, a command palette, a panel with its own controls. Content a reader should be able to Tab past, such as a tooltip or a disclosure, shouldn't be trapped.
  • Pair the trap with a way out. The trap handles Tab only, so wire Escape (and any close button) to turn it off.
  • Give the container a role and an accessible name (role="dialog" with aria-label or aria-labelledby), and give the button that opens it aria-haspopup, aria-expanded and aria-controls.
  • Return focus to the element that opened the content when it closes. The default does, and a ref to the trigger makes it reliable.
  • The trap is for the keyboard. It doesn't hide the rest of the page from screen readers or pointers, and it doesn't pull focus back from a click; for fully modal content, use Modal, which sets aria-modal.

  • Modal: A modal overlay with its own focus management.
  • Dialog: Ready-made confirm and alert dialogs.

Additional Resources​