A browser native modal with React
topics
- react
- front-end
- dialog
- react hooks
- accessibility
- browser api
Contents
Modals are commonly used overlay elements that alert users to urgent information while temporarily blocking interaction with the underlying page. They often appear as warnings, confirmation prompts, or mandatory form fields. However, building a modal is not a trivial task. Developers must consider several factors, such as stacking order, focus management, keyboard navigation, scrolling behavior, and accessibility.
In React, there are many valid ways to create a reusable modal component, each with varying levels of complexity and trade-offs. Some approaches may involve using dedicated libraries for specific features such as focus management and scrolling behavior, or choosing a robust component library like Material UI or Shadcn as a base. Additionally, modern browser tools enable developers to build more streamlined, customizable solutions that will adapt to any bespoke design system or style configuration.
Modern browsers offer many useful tools to address common usability and accessibility concerns natively. The HTML <dialog> element, in particular, significantly improves the developer experience when building a modal. It can reduce cognitive load while allowing developers to build fully functional modals using just the <dialog> element and built-in React hooks, without reliance on external libraries.
More about the <dialog>
Modal v non-modal
A dialog element can be either modal or non-modal. Modal means an alert demands the user’s attention and disables interaction with any element beneath the dialog's overlay until a specific action is taken. Modals like the one we’re building here fall into this category.
Non-modal alerts are less urgent and do not block users from interacting with the rest of the page. This could be a tooltip or a toast message, for example.
Accessibility
The dialog element is semantically sound. Because we’re building a modal dialog, the dialog element implicitly applies aria-modal=”true”, allowing assistive technology users to recognize its presence rather than manually adding the role=”dialog” attribute.
Stacking Order
By default, the dialog element is displayed with fixed positioning, placing it on top of all elements in the stacking order. Everything other than the dialog element itself is rendered inert.
Focus
Focus will automatically return to the previously focused item when the dialog element is dismissed. When open, focus is set to the first interactive element, such as a button, link, or input. If there’s a need to set the initial focus to a specific element, that can be done manually. The autofocus attribute won’t work here since the modal is rendered dynamically. In React, you'll need to use the focus() method instead.
Keyboard Navigation
By default, keyboard users can also dismiss the dialog element using the Escape key, as expected.
Learn more at MDN.
Getting Started
This exercise will build a scaled-down React app in CodeSandbox using minimal markup, CSS modules, and plain JavaScript. The App component will feature placeholder text and a button to toggle the modal's visibility. The core building blocks of this demo will be in the components and hooks directories, which include the Modal Component itself and a couple of custom hooks to enhance the Modal's behavior. Check here to view the completed code.
Starting in the components directory, we’ll compose two new files, Modal.jsx and Modal.module.css. In Modal.module.css, the base modal styles are defined as follows.
/*
Modal.module.css
*/
.modal-container {
width: 100%;
max-width: 540px;
border: none;
border-radius: 8px;
background: white;
padding: 0;
}
.modal-container::backdrop {
background: hsl(0deg 0% 0% / 0.75);
}
.modal-content {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
". close"
"body body";
row-gap: 0.5rem;
padding-top: 0.5rem;
padding-inline: 1rem;
padding-bottom: 1.5rem;
}
.modal-body {
grid-area: body;
}
.close-btn {
grid-area: close;
border: none;
background: transparent;
}
The ::backdrop pseudo class allows us to control the appearance of the dialog elements' backdrop. Otherwise, the backdrop will appear transparent. Pro tip: Use the [open] attribute to position the dialog element explicitly.
In Modal.jsx, we’ll build the basic structure of the Modal component. The initial set of imports will include a close icon from the react-feather library – based on the open-source feather icons – and styles from Modal.module.css. We’ll also pass in three props: isOpen to keep track of the Modal’s state, handleToggle to handle changes to the Modal’s state, and the children prop for the Modal’s inner content. Finally, we’ll add an onClick handler to the close button and assign the handleToggle prop to it.
// Modal.jsx
import { X as CloseIcon } from "react-feather";
import styles from "./Modal.module.css";
function Modal({isOpen, handleToggle, children}) {
return (
<dialog
className={styles["modal-container"]}
>
<div className={styles["modal-content"]}>
<button
type="button"
className={styles["close-btn"]}
aria-label=”dismiss dialog window”
onClick={handleToggle}
>
<CloseIcon aria-hidden="true" />
</button>
<div className={styles["modal-body"]}>{children}</div>
</div>
</dialog>
);
}
export default Modal;
Making the Modal functional
To make the Modal functional, we need to use the showModal() and close() methods associated with the <dialog> element. This will require manipulating the DOM directly. Since React does not immediately synchronize with the actual DOM, we can use the built-in useRef and useEffect hooks to reference the <dialog> element and keep it in sync with React state.
Inside the Modal component, we’ll create a reference to the <dialog> element in the DOM via the useRef hook. Within useEffect, we’ll pass an anonymous function; first checking whether the <dialog> element exists, then calling showModal() on the <dialog> if the isOpen state is true. Otherwise, we will call the close() method. For useEffect’s dependency array, we’ll pass in isOpen to keep track of state changes.
// Modal.jsx
import { useRef, useEffect } from "react";
...
function Modal({ isOpen, toggleIsOpen, children }) {
const dialogRef = useRef();
const dialogElement = dialogRef.current;
useEffect(() => {
if (!dialogElement) {
return;
}
if (isOpen) {
dialogElement?.showModal();
} else {
dialogElement?.close();
}
}, [isOpen]);
...
return (
<dialog
className={styles["modal-container"]}
ref={dialogRef}
>
...
)
Next, we’ll wire up the toggle button in the App component. Here we’ll utilize the useState hook, setting the state variable to IsModalOpen, the setter function to setIsModalOpen, and the initial value to false. Then we’ll create a function, handleToggle, where we'll call the state setter function. The function will change the state to its opposite value, using the setter’s updater function to always return the most current state value.
Now, we can add an onClick handler to the toggle button and pass it the handleToggle function. We’ll also introduce the Modal component, assigning isModalOpen and handleToggle to the related props. Clicking the toggle button should then open the Modal, while clicking the Modal’s close button should dismiss it.
// Modal.jsx
import { useState } from "react";
import Modal from "./components/Modal/Modal";
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
function handleToggle() {
setIsModalOpen((prevValue) => !prevValue);
}
return (
...
<Modal isOpen={isModalOpen} handleToggle={handleToggle}>
...
<button onClick={handleToggle}>Toggle modal</button>
...
);
}
We’re almost there, except there are a few more things we can do to improve this Modal’s usability. Specifically, we’ll need to address scroll management, the escape key, and dismissing the Modal when clicking outside the modal window.
Scroll Management
Though the elements below the Modal’s overlay are inert, you’ll notice scrolling in the browser window is still possible. For users of the application, this unexpected behavior can be quite disorienting. To address this, we’ll add a custom hook to lock scrolling while the Modal is open.
In styles.css we’ll add a new rule for the document’s body element, which will include margin and overflow properties with custom properties as values. For now, these custom properties will remain undefined and have no immediate effect on the body element.
/*
styles.css
*/
body {
overflow: var(--scroll-lock);
margin-inline-end: var(--scroll-bar-offset);
}
...
In the hooks directory, we’ll create a new file useScrollLock.js. This will return two functions: lockScroll and unlockScroll. When called, lockScroll will set the custom properties – referenced by the body element in styles.css – via the style attribute. It will set overflow to hidden, preventing scrolling. Then, by calculating the difference between the document window and body element, we’ll define an offset margin to prevent layout shifts when the browser’s scrollbar is hidden.
unlockScroll is the cleanup function that will remove the style attribute from the body element, resetting the custom property values. We’ll also take advantage of the useCallback hook to create stable references to these functions between re-renders.
// useScrollLock.js
import { useCallback } from "react";
function useScrollLock() {
const bodyElement = document.body;
const lockScroll = useCallback(() => {
const scrollBarWidth = window.innerWidth - bodyElement.offsetWidth;
bodyElement.setAttribute(
"style",
`--scroll-lock: hidden; --scroll-bar-offset: ${scrollBarWidth}px`
);
}, []);
const unlockScroll = useCallback(() => {
bodyElement.removeAttribute("style");
}, []);
return {
lockScroll,
unlockScroll,
};
}
export default useScrollLock;
Now, within the Modal component, we can import the useScrollLock hook and call the lockScroll and unlockScroll functions within the Modal’s useEffect function. Scrolling should now be locked whenever the Modal is open and should resume whenever the Modal is dismissed.
// Modal.jsx
import useScrollLock from "../../hooks/useScrollLock";
...
function Modal({ isOpen, toggleIsOpen, children }) {
useEffect(() => {
if (!dialogElement) {
return;
}
if (isOpen) {
dialogElement?.showModal();
lockScroll();
} else {
dialogElement?.close();
unlockScroll();
}
}, [isOpen]);
...
Escape key
By default, the dialog element lets us dismiss modals with the Escape key. However, in React, this creates a wrinkle. The actual DOM has no reference to React’s state. Therefore, the Modal will be dismissed visually, but its state is never updated. Attempting to open the Modal again won’t have any effect, as React expects the Modal to still be in an "open" state. Fortunately, we can solve this problem with a simple custom hook.
This time, let's add useEscapeKey.js to the hooks directory. This function will take one parameter, handleDismiss. Utilizing useEffect, it listens for an Escape key event and calls the function passed in via the handleDismiss parameter. Finally, we’ll specify a clean up function to remove the event each time the Effect runs.
// useEscapeKey.js
import { useEffect } from "react";
function useEscapeKey(handleDismiss) {
useEffect(() => {
function handleEscape(event) {
if (event.key === "Escape") {
handleDismiss();
}
}
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("keydown", handleEscape);
};
}, []);
}
export default useEscapeKey;
Back in the Modal component, we’ll import and call the useEscapeKey hook, passing it the handleToggle function. Now, using the Escape key will sync with React’s internal state.
// Modal.jsx
import useEscapeKey from "../../hooks/useEscapeKey";
...
function Modal({ isOpen, toggleIsOpen, children }) {
...
useEscapeKey(handleToggle);
...
Outside clicks
One thing the dialog element does not explicitly handle is dismissing the modal when the backdrop is clicked. We’ll need to add this feature manually.
Inside the Modal component, we’ll add a new function handleOutsideClick. This function takes a single parameter, event. First, we’ll add a safety check to determine if a click event has occurred on the dialog element. If so, it calls the handleToggle function, which dismisses the Modal.
// Modal.jsx
...
function Modal({ isOpen, toggleIsOpen, children }) {
...
function handleOutsideClick(event) {
const isDialog = event.target === dialogElement;
if (!isDialog) {
return;
}
console.log("event:", event);
console.log("event key:", event.target.nodeName);
if (isDialog) {
toggleIsOpen();
}
}
...
return (
<dialog
className={styles["modal-container"]}
ref={dialogRef}
onClick={handleOutsideClick}
>
...
)
We can then add an onClick handler to the <dialog> element and pass in the handleOutside function. Clicking on the backdrop will now dismiss the Modal as expected.
Wrapping up
We now have a functional Modal component that meets the minimum requirements to build upon. This can be extended in various ways, including compound components for more complex configurations, custom hooks for additional features, or the use of Context for deeply nested Modals.
No matter the architectural requirements or use case, this serves as a solid starting point. Using the dialog element allows the browser to handle key usability and accessibility concerns, enhancing the developer experience, reducing reliance on external libraries, and offering the flexibility to incorporate any design system.
Check out the live demo and full code example.