Aman Explains

Settings

How to create an accessible Modal component in React—Part 2

July 02, 2023• ☕️☕️ 10 min read

In part 1 of how to create an accessible modal component in react, we established a solid foundation for our modal component. It included the following:

While this was a good start, there are still a  few important aspects of an accessible modal component that we need to address: keyboard interaction, focus trap, and scroll lock.

Table of Contents


Keyboard interaction

To make modal accessible and keyboard friendly, we need to manage focus placement. This will result in a better UX. Things we need to address are:

  1. When modal opens, place focus on a focusable child element
  2. When modal closes, focus returns to the element that invoked the modal
  3. Trap focus within the modal (focus lock)
  4. Close modal when Escape key is pressed

When modal opens, place focus on a focusable child element

Typically, when the modal is open, the focus is initially set on first focusable element. However, most appropriate focus placement depends on your use case.
For e.g., in our case, the close button is the first focusable child element within the modal. So, when modal is open, we are placing focus on it by default. This will allow users to quickly hit space or enter key to close it, and thus enhance its keyboard accessibility—no need to reach out for mouse anymore.

Moreover, You can enhance this idea by accepting a prop from the consumer of this modal, indicating a focusable element onto which they want to set an initial focus.

We will implement this flexible solution that will allow consumer of the modal to pass in a reference to an initial focusable element of their choice. If this is not provided, we will pick up the first focusable child element by default. The implementation approach will be like this:

  • If user has passed in a initialFocusRef prop, we will simply call focus method on this element programmatically.
  • Otherwise, we will collect all the focusable elements using querySelectorAll API into an array, picks up the first element, and call focus method on it.

Continuing where we left off in the previous part.

// Modal with initial focus placement
import React, { useEffect, useRef } from "react";
import "./modal.css";

const Modal = ({
  isOpen,
  onDismiss,
  children,
  initialFocusRef,
  ...props
}) => {
  // To capture a reference to our modal DOM instance
  const dialogRef = useRef();

  // The following ref is required to extract
  // the focusable child element.
  const firstFocusable = useRef();

  useEffect(() => {
    if (isOpen) {
      // Check if there's any element ref being passed to
      // set initial focus to.
      if (initialFocusRef?.current) {
        initialFocusRef.current.focus();
        return;
      }

      // Otherwise, we will focus the first focusable element.
      firstFocusable.current =
        dialogRef.current?.querySelectorAll(
          `button, [href], input, select, textarea, 
          [tabindex]:not([tabindex="-1"]),video`
        )[0];

      firstFocusable.current?.focus();
    }
  }, [isOpen]);

  // Rest of the code to handle backdrop click --

  return isOpen ? (
    <div className="overlay" onClick={handleBackdropClick}>
      <div
        ref={dialogRef}
        className="content-wrapper"
        role="dialog"
        aria-modal="true"
        {...props}
      >
        {children}
      </div>
    </div>
  ) : null;
};

export default Modal;

Let’s try to use our modal component with this new implementation. First, we won’t pass initialFocusRef prop to see if the initial focus lands on the close button by default. In second use case, we will pass in a value for initialFocusRef to see if focus lands correctly on referenced element.

// Example usage with default initial focus on "close" button
import React, { useState } from "react";
import Modal from "./modal";

const Example4 = (props) => {
  const [isOpen, setIsOpen] = useState(false);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  return (
    <div>
      <button onClick={open}>Open modal</button>
      <Modal isOpen={isOpen} onDismiss={close}>
        <p>
          You can observe how the initial focus is 
          set on the <b>close</b> button by default.
        </p>
        <button onClick={close}>Close</button>
      </Modal>
    </div>
  );
};

This time when modal opens, the close button would receive focus. Try hitting the Open Modal button below:



Let’s look into another example of passing in a reference to a child element where the focus should be set when modal opens.

// Example usage when passing in a ref to a child element  
import React, { useState, useRef } from "react";
import Modal from "./modal";

const Example5 = (props) => {
  const [isOpen, setIsOpen] = useState(false);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  // Required to store reference to button 2 element
  const button2Ref = useRef();

  return (
    <div>
      <button onClick={open}>Open modal</button>
      <Modal isOpen={isOpen} onDismiss={close} initialFocusRef={button2Ref}>
        <p>
          As we have passed a reference to `Button 2` element, 
          you can observe how the initial focus is 
          set on the <b>Button 2</b>. 
        </p>
        <button onClick={close}>Close</button>
        <button>Button 1</button>
        <button ref={button2Ref}>Button 2</button>
        <button>Button 3</button>
      </Modal>
    </div>
  );
};

Now when you open the modal by pressing the Open Modal button below, the focus gets set on the Button 2. Following is a preview of aforementioned implementation:



When modal closes, focus returns to the element that invoked the modal

By bringing focus back to the element that invokes it, helps user to:

  • allow opening the modal easily by pressing space or enter key
  • continue their journey of tabbing through the rest of the elements
  • keep track of the focusable element

There are two ways to implement this. One easy solution is to rely on the consumer of our modal. After calling setIsOpen(false), they need to call focus() method on the element that will receive focus. This approach puts the responsibility on the shoulder of developers/consumers to manage the focus when modal closes.

Another one is encapsulate this functionality in the modal implementation itself, and based on when the modal state switches from open --> close, we invoke the focus() method on the passed in reference if there’s any. This approach has a merit of not relying on consumers for handling the focus management.

We shall take a look at both of these implementations. The following example shows how it’s done from consumer perspective:

// Here the consumer of our modal is handling 
// the focus when modal closes.

import React, { useState, useRef } from "react";
import Modal from "./modal";

const Example6 = (props) => {
  const [isOpen, setIsOpen] = useState(false);
  const open = () => setIsOpen(true);
  const close = () => {
    setIsOpen(false);
    // setting the focus on triggering element
    focusOnCloseRef.current.focus();
  };

  // To store ref to an element that invokes the modal
  const focusOnCloseRef = useRef();

  return (
    <div>
      <button onClick={open} ref={focusOnCloseRef}>
        Open modal
      </button>
      <Modal isOpen={isOpen} onDismiss={close}>
        <p>
          Once modal closes, focus will land on triggering element.
        </p>
        <button onClick={close}>Close</button>
      </Modal>
    </div>
  );
};

Time to see that in action below. Once you close the modal, observe how the focus is landing back on Open modal button. Do you think it’s a better UX? I think, hell it is!



We developers can be lazy, and thus it’s a wise approach to abstract these UX features into our modal component, exposing a simple API to set focus on the triggering element when modal closes. We will expose a prop called focusOnCloseRef that will accept a reference to an element receiving the focus. Once the modal is closed, we shall programmatically call focus() method.

Let’s take a look at how to achieve this by modifying our modal:

// Encapsulating the logic of handling focus when modal closes
import React, { useEffect, useRef } from "react";
import "./modal.css";

const Modal = ({
  isOpen,
  onDismiss,
  children,
  initialFocusRef,
  focusOnCloseRef,
  ...props
}) => {
  // Require to store previous props value for comparison.
  // Can be converted into a hook too
  const prevRef = useRef();

  // NOTE: Order of these hooks is important
  useEffect(() => {
    if (prevRef.current === true && isOpen === false) {
      // invoking the focus method when modal state
      // changes from `open -> close`
      focusOnCloseRef?.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    prevRef.current = isOpen;
  }, [isOpen]);

  // Rest of the code ---
};

Now, the usage is simply passing in a prop value for focusOnCloseRef as shown below:

// Example usage when encapsulating the focus on close logic
import React, { useState, useRef } from "react";
import Modal from "./modal";

const Example7 = (props) => {
  const [isOpen, setIsOpen] = useState(false);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  // To store ref to an element that invokes the modal
  const focusOnCloseRef = useRef();

  return (
    <div>
      <button onClick={open} focusOnCloseRef={focusOnCloseRef}>
        Open modal
      </button>
      <Modal
        isOpen={isOpen}
        onDismiss={close}
        initialFocusRef={button2Ref}
        focusOnCloseRef={focusOnCloseRef}
      >
        <p>
          Once modal closes, focus will land on triggering element.
        </p>
        <button onClick={close}>Close</button>
      </Modal>
    </div>
  );
};

Take a look at the following preview by pressing the Open Modal button below.



Trap focus within the modal

In all of the previous examples, when modal opens, if you continue tabbing through the interactive child elements, the focus will eventually escape. Give it a try!

As per W3C accessibility guidelines for accessible modal, in most conditions when modal is open, the focus shouldn’t leave the modal container while tabbing through all of its interactive child elements.

We need to trap the focus within the modal when it’s open. It means:

  • when focus is at the last interactive element within the modal, hitting tab key should bring the focus back to first interactive element in the modal.
  • and if user is at the first interactive element, pressing shift+tab should focus the last element within the modal.

In other words, focus shouldn’t leave the modal while tabbing through interactive child elements.

Focus should stay within the modal when it’s open

The implementation approach we are going to take is:

// Find all interactive elements within "rootNode"
rootNode.current.querySelectorAll(
  `button, [href], input, select, textarea, 
  [tabindex]:not([tabindex="-1"]), video`
);
  • Register keydown event handlers to handle the logic of keeping the focus trapped within the modal.

We will encapsulate this logic into a reusable component called FocusLock as shown below:

// Component to encapsulate the logic of focus trap
import React, { useEffect, useRef } from 'react';
const TAB_KEY = 9;

const FocusLock = (props) => {
  const rootNode = useRef(null);
  const focusableItems = useRef([]);
  useEffect(() => {
  // Collecting all focusable child items
    focusableItems.current = rootNode.current.querySelectorAll(
      `button, [href], input, select, textarea, 
       [tabindex]:not([tabindex="-1"]), video`,
    );
  }, []);

  useEffect(() => {
    const handleKeydown = (event) => {
      if (!focusableItems.current) {
        return;
      }
      const { keyCode, shiftKey } = event;
      const {
        length,
        0: firstItem,
        [length - 1]: lastItem,
      } = focusableItems.current;

      if (keyCode === TAB_KEY) {
        // If only one item then prevent tabbing
        if (length === 1) {
          event.preventDefault();
          return;
        }

        // If focused on last item
        // then focus on first item when tab is pressed
        if (!shiftKey && document.activeElement === lastItem) {
          event.preventDefault();
          firstItem.focus();
          return;
        }

        // If focused on first item
        // then focus on last item when shift + tab is pressed
        if (shiftKey && document.activeElement === firstItem) {
          event.preventDefault();
          lastItem.focus();
        }
      }
    };
    window.addEventListener('keydown', handleKeydown);
    return () => {
      window.removeEventListener('keydown', handleKeydown);
    };
  }, []);
  return <div ref={rootNode} {...props} />;
};

export default FocusLock;

Open the modal by pressing the button below. Try tabbing (pressing tab or shift + tab) through its children and see how the Focus is trapped 🔒.


The idea is to know the current active element (document.activeElement), and the number of focusable child elements of the modal. We then hijacked the keydown event for Tab key and move the focus accordingly depending upon if it’s the last element or the first.

Note: For the sake of simplicity we have skipped the case when focusable items within the modal can change dynamically based on some user actions. To handle this particular case, you can use mutationObserver API.


Close modal when Escape key is pressed

It’s crucial to close the modal when the Esc key is pressed, unless there are choices within the modal and none of them are for canceling.In those cases we need to alert the user to make a choice instead of closing the modal.

Similar to backdrop click handler, we will attach an onKeyDown event handler to our modal as shown below:

// Pressing `Esc` key should close the modal
import React, { useEffect, useRef } from "react";
import "./modal.css";

const Modal = ({
  isOpen,
  onDismiss,
  children,
  initialFocusRef,
  focusOnCloseRef,
  ...props
}) => {
  // Rest of the code ---

  const handleEscKeyPress = (event) => {
    if (isOpen && event.key === "Escape") {
      event.preventDefault();
      onDismiss();
    }
  };
  return isOpen ? (
    <div
      className="overlay"
      onClick={handleBackdropClick}
      onKeyDown={handleEscKeyPress}
    >
      <div
        ref={dialogRef}
        className="content-wrapper"
        role="dialog"
        aria-modal="true"
        {...props}
      >
        {children}
      </div>
    </div>
  ) : null;
};

export default Modal;

In the preview below, when modal opens, pressing Esc key should close it.



Scroll Lock

If you have read this far, well done!. It was a long journey. We are close to finishing our modal. The last piece we need to look at is to prevent page from scrolling when user is scrolling through the modal’s items. This behaviour is known as scroll lock 🔒. Try playing with previous examples.

Well, the simple and easy approach is to disable body scrolling when modal opens by setting overflow:hidden on body tag, and revert it when modal closes. We can do so via setProperty method.

  // disable body scroll via JavaScript
  document.body.style.setProperty('overflow', 'hidden');

Within the existing useEffect hook, where we handled the auto focus placement when modal opens, we will add the logic of setting and reverting the overflow css property on the body tag. The following examples makes it clear:

// Implementing scroll lock on body tag when modal opens
import React, { useEffect, useRef } from "react";
import "./modal.css";

const Modal = ({
  isOpen,
  onDismiss,
  children,
  initialFocusRef,
  focusOnCloseRef,
  ...props
}) => {
  // --- rest of the code ---
  useEffect(() => {
    if (isOpen) {
      document.body.style.setProperty("overflow", "hidden");

      // rest of the auto focus placement logic ---
    } else {
      document.body.style.setProperty("overflow", "visible");
    }
  }, [isOpen]);

  // --- rest of the code ---
}

In preview below, when modal opens, the content behind it should stop scrolling, and when it closes, the scrolling should be visible again.


If you are not getting scroll lock working, try setting overflow: hidden on html tag too.

Note that tweaking overflow property might not work for mobile iOS. Follow Body scroll lock article to see what other alternatives are available.

Conclusion

The aim of this article was to explain what it takes to build an accessible modal in React. It requires a mindset of being a responsible web developer who takes pride in delivering a UI which is inclusive, screen reader friendly and keyboard accessible. It’s easy to just pick a library and call it a day. Unless you implement something from scratch or delve into the implementation of the library/package you are using, you’ll rarely get to learn much. There’s no excuse for not having enough resources to learn about WCAG requirements before you build a UI widget from scratch. It’s easier than you think and you could sleep well knowing that you have done a great job.

Here’s the codesandbox demo of the complete modal implementation. Enjoy coding.

Resources


Amandeep Singh

Written by Amandeep Singh. Developer @  Avarni  Sydney. Tech enthusiast and a pragmatic programmer.