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
autoFocusinput) takes focus before the trap can see where it came from. falseleaves 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 thantype="hidden", selects and text areas - the areas with an
hrefof an image map whose image is shown, taken where the<map>sits - frames:
<iframe>,<embed>and<object> <audio>and<video>withcontrols- 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
tabIndexof0or 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: noneon 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
inertsubtree - 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
tabIndexchanges 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 positivetabIndexout 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
| Parameter | Type | Default | Description |
|---|---|---|---|
containerRef | RefObject<HTMLElement | null> | — | The element focus stays inside. It has to be in the DOM when the trap turns on. |
options.active | boolean | true | Whether the trap is on. Turning it on moves focus in; turning it off (or unmounting) releases Tab and restores focus. |
options.initialFocusRef | RefObject<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.restoreFocus | boolean | RefObject<HTMLElement | null> | true | Where 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"witharia-labeloraria-labelledby), and give the button that opens itaria-haspopup,aria-expandedandaria-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 setsaria-modal.