Skip to content

Managing dialog focus in React

Imagine a React component that renders a dialog.

import {createPortal} from 'react-dom';
function Dialog({isOpen, handleClose, ...props}) {
  if (!isOpen) return null;

  return createPortal(<div role="dialog" {...props} />, document.body);
}

An important part of any dialog component is focus management. When the dialog opens, we want to either focus the role="dialog" element itself, or the first focusable element inside of the dialog. This example will cover focusing the dialog element.

import {useRef} from 'react';
import {createPortal} from 'react-dom';

function Dialog({isOpen, handleClose, ...props}) {
  const dialogRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    dialogRef.current.focus();
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(<div role="dialog" ref={dialogRef} {...props} />, document.body);
}

We can manage focus using a ref -- when the isOpen prop is set to true, we immediately focus the dialog element.

This is only part of the focus management that we need to worry about for dialogs. We also want to ensure that users cannot focus elements outside of the dialog. We can reach for HTML's inert attribute.

inert has pretty solid browser support, but as of this writing, it's still not supported in Firefox. The behavior is complicated, and proper implementation is key for an accessible dialog; for this reason, I recommend polyfilling it.

Let's update our code to inert every non-dialog element.

let inactiveElements = document.querySelectorAll('body > *');
inactiveElements.setAttribute('inert', 'inert');

We'd want to remove the inert attribute as soon as the dialog is unmounted.

inactiveElements.removeAttribute('inert');

One last thing that we'd want to do is keep track of the element that triggered the dialog. When the dialog is closed, we'd want to return focus to this element.

We can do this with a ref.

const triggerElement = useRef(null);

// before the dialog opens
triggerElement.current = document.activeElement;

// after the dialog closes
triggerElement.current.focus();
triggerElement.current = null;

Putting all of this together, we'd have something like the following. I found that I needed to keep track of another state to handle all of the pre-opening and post-closing work.

import {useRef, useState} from 'react';
import {createPortal} from 'react-dom';

function Dialog({isOpen, handleClose, ...props}) {
  const dialogRef = useRef(null);
  const triggerElement = useRef(null);
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    if (isOpen === isMounted) return;

    let inactiveElements = document.querySelectorAll('body > *');

    if (isOpen) {
      triggerElement.current = document.activeElement;
      inactiveElements.setAttribute('inert', 'inert');
    } else {
      inactiveElements.removeAttribute('inert');
    }

    setIsOpen(isMounted);
  }, [isOpen]);

  useEffect(() => {
    if (isMounted) {
      dialogRef.current.focus();
    } else {
      triggerElement.current.focus();
      triggerElement.current = null;
    }
  }, [isMounted]);

  if (!isMounted) return null;

  return createPortal(<div role="dialog" ref={dialogRef} {...props} />, document.body);
}