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:
- Utilizing the composition pattern, we scaffolded our modal component with the bare minimum HTML and CSS
- We implemented functionality to handle closing the modal when the backdrop is clicked.
- We property injected the necessary WAI-ARIA attributes into our modal as per the WCAG guidelines
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:
- When modal opens, place focus on a focusable child element
- When modal closes, focus returns to the element that invoked the modal
- Trap focus within the modal (focus lock)
- 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
orenter
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 the modal using querySelectorAll web API.
// 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
onhtml
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
- Dialog (Modal) pattern
- Check your WCAG Compliance
- Accessibility Not Checklist
- Codesandbox demo of the complete modal implementation
Written by Amandeep Singh. Developer @ Avarni Sydney. Tech enthusiast and a pragmatic programmer.