How To Build An Accessible Modal
📅May 7th, 2022 - 3 min read
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
shellyarn 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
ts/* FILE: src/components/reach-dialog.ts */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
tsx/* FILE: src/components/reach-dialog-demo.tsx */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.