TrapFocus

Use keyboard to navigate
import { TrapFocus } from "reshaped";

When TrapFocus is activated, it traps keyboard and screen reader navigation within the given element until focus is released. Since TrapFocus is a class, you can create a new instance and pass the target element to initialize it. This is useful when an element (like a modal or popup) appears on screen. When the element is dismissed, call the release method to restore normal focus behavior.

You can manage this behavior inside a useEffect hook:

useEffect(() => {
  const trapFocus = new TrapFocus();

  trapFocus.trap(targetRef.current);
  return () => trapFocus.release();
}, [active]);

There are four focus trap modes available. The default mode is dialog, which fully locks keyboard navigation inside the trapped area. It handles Tab and Shift + Tab, looping focus within the element. Use this mode for components like Modal.

const rootRef = useRef<HTMLDivElement>(null);
const trapToggle = useToggle();

useEffect(() => {
  if (!trapToggle.active) return;
  if (!rootRef.current) return;

  const trapFocus = new TrapFocus();

  trapFocus.trap(rootRef.current, { mode: "dialog" });
  return () => trapFocus.release();
}, [trapToggle.active]);

return (
  <View gap={4} align="center">
    <View.Item>
      <Button onClick={trapToggle.activate}>Trap focus</Button>
    </View.Item>

    {trapToggle.active && (
      <View
        gap={4}
        direction="row"
        padding={4}
        backgroundColor="neutral-faded"
        borderRadius="medium"
        attributes={{ ref: rootRef }}
      >
        <Button onClick={() => {}}>Action 1</Button>
        <Button onClick={() => {}}>Action 2</Button>
        <Button onClick={() => {}}>Action 3</Button>
        <Button onClick={trapToggle.deactivate}>Release</Button>
      </View>
    )}
  </View>
)

The action-menu mode is designed for components like action menus and is used internally by DropdownMenu. It allows focus navigation with the ArrowUp and ArrowDown keys. Pressing Tab moves focus to the next element after the original trigger and automatically releases the focus trap. You can use the onRelease option to dismiss the content when that happens.

const rootRef = useRef<HTMLDivElement>(null);
const trapToggle = useToggle();

useEffect(() => {
  if (!trapToggle.active) return;
  if (!rootRef.current) return;

  const trapFocus = new TrapFocus();

  trapFocus.trap(rootRef.current, {
    mode: "action-menu",
    onRelease: trapToggle.deactivate,
  });
  return () => trapFocus.release();
}, [trapToggle]);

return (
  <View gap={4} align="center">
    <View direction="row" gap={4}>
      <Button onClick={trapToggle.activate}>Trap focus</Button>
      <Button onClick={() => {}}>Next trigger</Button>
    </View>

    {trapToggle.active && (
      <View
        gap={4}
        direction="row"
        padding={4}
        backgroundColor="neutral-faded"
        borderRadius="medium"
        attributes={{ ref: rootRef }}
      >
        <Button onClick={() => {}}>Action 1</Button>
        <Button onClick={() => {}}>Action 2</Button>
        <Button onClick={() => {}}>Action 3</Button>
        <Button onClick={trapToggle.deactivate}>Release</Button>
      </View>
    )}
  </View>
)

The action-bar mode works like action-menu, but uses the ArrowLeft and ArrowRight keys to move focus instead.

The content-menu mode is useful for components like navigation menus that contain regular content, such as a list of links. It keeps the navigation flow natural by including all focusable elements in the trapped area after the trigger element. Focus navigation uses the Tab key, but pressing Tab on the last element moves focus to the next element after the original trigger. This releases the focus trap, and you can respond to it using the onRelease handler.

Using includeTrigger adds the original trigger element to the focus sequence.
It also keeps focus on the trigger when the focus trap is initialized.

trapFocus.trap(ref.current, { includeTrigger: true });

Use initialFocusEl to set which element should receive focus when the trap is initialized.

trapFocus.trap(ref.current, {
  initialFocusEl: internalElementRef.current,
});

By default, releasing the focus trap returns focus to the original trigger element.
Use the withoutFocusReturn option to disable this behavior.
This is useful when closing the element with an outside click and you want to avoid the page scrolling back to the trigger.

trapFocus.release({ withoutFocusReturn: true });
{
  trap: (
    el: HTMLElement,
    {
      // Keep the focus on the trigger and include it in the focus sequence
      includeTrigger: boolean,

      // Element to move to the focus to after the initialization
      initialFocusEl: HTMLElement
    }
  ) => void,

  release: (
    options: {
      // Prevent from returning the focus back to the original trigger
      withoutFocusReturn: boolean
    }
  ) => void,

  // State of the focus trap
  active: boolean
}
Previous