How To Build An Accessible Modal

Modal window (or dialog box) is one of those classic element composing an interface. Every app or website has a modal. But it’s surprisingly hard to get it right.
Accessibility Concerns About Modals
I used to think that building a modal in React is just a matter of showing a box when a variable is set to true (after a click on a button for example). But there are many things, accessibility related, to consider :
- annonce the modal and hide the rest of the page from screen reader
- make the first element tab-able inside the modal to have focus
- close the modal when clicking outside or when pressing esc key
- restore focus after closing the modal
Using Headless Modal Component
You could try to implement a modal from scratch. But I strongly advise you against that (at least for production code).
In React, there are many headless component libraries that help developers build accessible interfaces. Headless components are a type of unstyled components that are fully functional.
You can find below few examples of headless modal libraries
- react modal (most popular)
- reach UI dialog (what I recommand)
- radix UI dialog
- reakit dialog
React modal is clearly the most popular out there. But I think it is not the best option, because you can disable some accessibility features needed for a modal.
For the other libraries, important accessibility features are enabled by default. Each of them is a good solution but I have a preference for Reach UI dialog for its ease of use (simple API and component easily style-able).
Reach UI Dialog Quickstart
Installation
yarn add @reach/dialog# ornpm install @reach/dialog
Implementation
In this example, I use Stitches. But you can use any CSS-in-JS library of your choice. You can also use CSS modules to style DialogOverlay and DialogContent
import { DialogOverlay, DialogContent } from "@reach/dialog";import { styled } from "path/to/stitches.config.ts";
export const Overlay = styled(DialogOverlay, { backgroundColor: "hsl(0 0% 0% / 0.439)", position: "fixed", top: 0, bottom: 0, left: 0, right: 0, display: "grid", placeItems: "center",});
export const Content = styled(DialogContent, { backgroundColor: "white", padding: "1rem", minWidth: 300, borderRadius: 20,});
Usage
import { useState } from "react";import { Content, Overlay } from "./reach-dialog.ts";
const ReachDialogDemo = () => { const [isOpen, setIsOpen] = useState(false); const show = () => setIsOpen(true); const close = () => setIsOpen(false); return ( <main> <h1>Reach Dialog Demo</h1> <button onClick={show}>Open Dialog</button> <Overlay onDismiss={close} isOpen={isOpen}> <Content> <h2>Reach dialog</h2> <p>Hello World</p> <button onClick={close}>Close</button> </Content> </Overlay> </main> );};
export default ReachDialogDemo;
What About The Official dialog Element ?
Although major browsers support it recently, there are still accessibility issues that are being discussed. I think, reaching for a solution like headless components is totally valid for now.