20
Build an Easy Popup System With React
There are plenty of popup systems out there, but they usually don’t meet the high-quality requirement I have on user interfaces and development simplicity.
When I’m adding a popup into a website, it is important to me that the system is:
With these requirements, I always find it difficult to find a library with what I need and the blocking points are often too painful to be worked around.
Even if it might not be intuitive, the last standing option is to create our own system so that will ensure a perfect match with your needs
Enough speaking, let’s dive into a popup component system creation.
There are a few things we want in this popup system:
That’s a lot to do so we better get started.
The first thing to have a modal system is to have a modal root, where the system will take place. To do so, we just need to have a new
div#modal-root
element in our root document.This part is important so the modal can be easily styled. With a separate root element, we are sure that the parent elements of the modal does not have styles that will make it harder for us to reach the perfect style.
To be sure that the modal will always be on top of the document, we just need to add the right
z-index
on the application root and the modal-root.Also, since the modal behavior is to be opened and directly occupy the whole browser’s page, we add an ARIA live region to the modal system so it can be announced to the user.
The aria live region is set to assertive because we want the readers to have the same behavior as the browser, which places the popup on top of everything else.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#root { | |
position: relative; | |
z-index: 1; | |
} | |
#modal-root { | |
position: relative; | |
z-index: 2; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- ... --> | |
<body> | |
<noscript>You need to enable JavaScript to run this app.</noscript> | |
<div id="root"></div> | |
<div id="modal-root" aria-live="assertive"></div> | |
</body> | |
<!-- ... --> |
The modal component is split into three different components:
ModalPortal
component that will link our modal to the div#modal-root
elementModalView
component that aims to handle the visible part of the componentModalAnimated
component that will handle the popup domain and the CSS appearance effects of the popup systemThe
ModalPortal
component exists to link our popup to the div#modal-root
element that we have created. Here’s the code:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useRef } from "react"; | |
import { createPortal } from "react-dom"; | |
interface IModalPortalProps { | |
active: boolean; | |
children: React.ReactNode; | |
} | |
export default function ModalPortal({ | |
active, | |
children, | |
}: IModalPortalProps): React.ReactPortal | null { | |
const elRef = useRef<HTMLDivElement>(); | |
useEffect(() => { | |
if (window) { | |
elRef.current = window.document.createElement("div"); | |
} | |
}, []); | |
useEffect(() => { | |
const modalRoot = window.document.getElementById("modal-root"); | |
if (active && elRef.current && modalRoot !== null) { | |
const { current } = elRef; | |
modalRoot.appendChild(current); | |
return () => { | |
modalRoot.removeChild(current); | |
}; | |
} | |
return () => {}; | |
}, [active]); | |
if (elRef.current && active) { | |
return createPortal(children, elRef.current); | |
} | |
return null; | |
} |
It is made of four sections:
ref
corresponding to a simple div
element, with the goal of holding the popup content. We do not use directly the root element so we are able to create two or more different popups if we want to stack them.useEffect
hook to create the div
element. This is a security to make the system work also on SSR systems such as NextJs or Gatsby.useEffect
hook, to add the previously created div
in the portal when active, and remove it when inactive. It will prevent the div#modal-root
element to contain plenty of empty divs.div
element created does not exist or the popup is not currently active.This one is basically a layout component so we can style the popup the way we want.
Even if I’m presenting only one template, you are able to use it for as many needs you may have such as:
alert
and confirm
modal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.Overlay { | |
background-color: rgba(0, 0, 0, 0.3); | |
border: 0; | |
height: 100%; | |
left: 0; | |
padding: 0; | |
position: fixed; | |
top: 0; | |
width: 100%; | |
z-index: 1; | |
} | |
.Content { | |
background: rgba(255, 255, 255, 1); | |
border-radius: 16px; | |
box-shadow: 0 10px 13px -6px rgba(0, 0, 0, 0.2), 0 20px 31px 3px rgba(0, 0, 0, 0.14), | |
0 8px 38px 7px rgba(0, 0, 0, 0.12); | |
color: rgba(0, 0, 0, 0.85); | |
left: 50%; | |
max-height: 80vh; | |
max-width: 90vw; | |
overflow: auto; | |
padding: 32px; | |
position: fixed; | |
top: 50%; | |
transform: translate(-50%); | |
width: 600px; | |
z-index: 2; | |
} | |
.Close { | |
background-color: transparent; | |
border-radius: 50%; | |
border: 0; | |
cursor: pointer; | |
display: block; | |
fill: rgba(0, 0, 0, 1); | |
height: 40px; | |
margin: -24px -24px 0 auto; | |
padding: 8px; | |
transition: all 0.15s cubic-bezier(0.4, 0, 0.6, 1); | |
width: 40px; | |
} | |
.Close:hover { | |
background-color: rgba(0, 0, 0, 0.15); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import classes from "./ModalView.module.css"; | |
interface IModalViewProps { | |
onClose: () => void; | |
children: React.ReactNode; | |
} | |
const ModalView: React.FC<IModalViewProps> = ({ onClose, children }) => ( | |
<> | |
<button | |
className={classes.Overlay} | |
onClick={onClose} | |
aria-label="Close the popin" | |
/> | |
<div className={classes.Content}> | |
<button | |
className={classes.Close} | |
onClick={onClose} | |
aria-label="Close the popin" | |
> | |
<svg viewBox="0 0 24 24"> | |
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" /> | |
</svg> | |
</button> | |
{children} | |
</div> | |
</> | |
); | |
export default ModalView; |
The present component is just a bunch of native elements with some styles separated into two sections:
The two blocks are siblings because we don’t want the click event to propagate from one to the other.
For accessibility reasons, both the overlay and the close buttons are native button elements with an
aria-label
attribute.In the CSS part, I use various positioning techniques that you are free to adapt depending on your needs.
For the last part of the system, we need a component that will control the modal. Here’s the code:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.ModalAnimated { | |
bottom: 0; | |
left: 0; | |
pointer-events: initial; | |
position: absolute; | |
right: 0; | |
top: 0; | |
} | |
.ModalAnimated:global(.modal-enter) { | |
opacity: 0; | |
transform: translateY(100px) scale(0.9); | |
} | |
.ModalAnimated:global(.modal-enter-active) { | |
opacity: 1; | |
transform: translateY(0) scale(1); | |
transition: all cubic-bezier(0, 0, 0.2, 1) 300ms; | |
} | |
.ModalAnimated:global(.modal-exit) { | |
opacity: 1; | |
transform: translateY(0) scale(1); | |
} | |
.ModalAnimated:global(.modal-exit-active) { | |
opacity: 0; | |
transform: translateY(-100px) scale(0.9); | |
transition: all cubic-bezier(0, 0, 0.2, 1) 300ms; | |
} | |
@media screen and (prefers-reduced-motion: reduce) { | |
.ModalAnimated:global(.modal-enter-active) { | |
transition: none; | |
} | |
.ModalAnimated:global(.modal-exit-active) { | |
transition: none; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import { CSSTransition } from "react-transition-group"; | |
import classes from "./ModalAnimated.module.css"; | |
import ModalPortal from "./ModalPortal"; | |
import ModalView from "./ModalView"; | |
import useEchap from "./useEchap"; | |
type ViewComponentType = React.FunctionComponent<{ | |
children: React.ReactNode, | |
onClose: () => void, | |
}>; | |
interface Props { | |
active: boolean; | |
children: React.ReactNode; | |
onClose: () => void; | |
view?: ViewComponentType; | |
} | |
const ModalAnimated: React.FC<Props> = ({ | |
active, | |
children, | |
onClose, | |
view: ViewComponent = ModalView, | |
}) => { | |
useEscape(active, onClose); | |
return ( | |
<CSSTransition in={active} timeout={300} classNames="modal"> | |
<ModalPortal active={active}> | |
<div className={classes.ModalAnimated}> | |
<ViewComponent onClose={onClose}>{children}</ViewComponent> | |
</div> | |
</ModalPortal> | |
</CSSTransition> | |
); | |
}; | |
export default ModalAnimated; |
This component has several tasks to handle:
div#modal-root
DOM elementThe CSS has a weird CSS Modules syntax to handle global classes, but it also uses the
prefers-reduced-motion
media query to shutdown the animation for people asking for it.If the last part could be set globally for all elements, it is better illustrated in the component.
To improve usability, we can add another great feature to our popup system by adding an escape listener that can close the popup.
To do so, there is a
useEscape(active, onClose);
code in the ModalAnimated component, but this is yet to be implemented. Here’s the code:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback, useEffect } from "react"; | |
export default function useEscape(active: boolean, onClose: () => void) { | |
const onEchap = useCallback( | |
(event) => { | |
if (event.keyCode === 27) { | |
onClose(); | |
} | |
}, | |
[onClose] | |
); | |
useEffect(() => { | |
if (active) { | |
window.addEventListener("keydown", onEchap); | |
return () => { | |
window.removeEventListener("keydown", onEchap); | |
}; | |
} | |
}, [onEchap, active]); | |
} |
The hook is quite simple, and it is made of two blocks:
onEscape
callback that memoize the keyboard event by listening to the keyCode for the escape key — 27useEffect
method to bind it to the window document and unbind it as soon as the modal is unmountedThe usage is pretty straightforward: we need the
ModalAnimated
component with two props if we want a custom ModalView component.The content of the popup itself is just the children elements passed to
ModalAnimated
. I usually put the content inside another component to keep the page as light as possible. Here’s the code:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState } from "react"; | |
import ModalAnimated from "../components/modal/ModalAnimated"; | |
interface Props { | |
[key: string]: never; | |
} | |
const PopinUsageSample: React.FC<Props> = () => { | |
const [active, setActive] = useState(false); | |
return ( | |
<> | |
<ModalAnimated active={active} onClose={() => setActive(false)}> | |
Hello, world! | |
</ModalAnimated> | |
<button onClick={() => setActive(true)}>Open a popin</button> | |
</> | |
); | |
}; | |
export default PopinUsageSample; |
By creating three light components and a simple custom hook, we are able to get a very modulable and customizable popup system.
While it can still be improved, we have implemented a system that will make your UI designer happy, and it implements the accessibility basics.
Did we check all the initial requirements?
Mission accomplished! Now it is your turn to use it and improve it in your projects.
Happy coding!
20