As a front-end developer, you have probably had to build a modal window on more than one occasion. This type of element differs from the typical pop-ups because it does not appear automatically, instead, the user has to click somewhere on the website (usually a button) to make it appear.
In this tutorial, you will learn how to develop and implement a modal component in your React project with TypeScript. It will be reusable in any part of your application, and you will be able to customize it and add any type of content.
Modals are undoubtedly one of the most used components on the web because they can be used in different contexts, from messages to user input. They have placed an overlay on the screen. Therefore, they have visual precedence over all other elements.
Like many other components in React, a dependency can be installed to help in this process. However, we always end up limited in several aspects, and one of them is styling.
We can create a modal inside or outside the element we call it from in the DOM hierarchy, but to fulfill the definition of a modal, it should be at the same level as the element used as root in React, and to achieve this, we will use the Portals.
Portals provide a quick and easy way to render children to a DOM node that exists outside the DOM hierarchy of the parent component.
In React, the default behavior is to render the entire application under a single DOM node — the root of the application, but what if we want to render children outside the root DOM node? And you want children to appear visually on top of its container.
A Portal can be created usingReactDOM.createPortal(child, container)
. Here the child is a React element, fragment, or string, and the container is the DOM location (node) to which the portal should be injected.
Below is an example of a modal component created using the above API.
const Modal =({ message, isOpen, onClose, children })=> {
if (!isOpen) return null
return ReactDOM.createPortal(
<div className="modal">
<span className="message">{message}</span>
<button onClick={onClose}>Close</button>
</div>,
domNode)
}
Although a Portal is rendered outside the parent DOM element, it behaves similarly to a normal React component within the application. It can access props and the context API.
This is because the Portals reside within the React Tree hierarchy, and Portals only affect the HTML DOM structure and do not impact the React component tree.
We create our application with
yarn create vite my-modals-app --template react-ts
We install the dependencies that we will need in the project:
yarn add styled-components @types/styled-components
After that, we create the following structure for the project:
src/
├── components/
│ ├── layout/
│ │ ├── Header.tsx
│ │ └── styles.tsx
│ ├── modals/
│ │ ├── Buttons.tsx
│ │ ├── Modal.tsx
│ │ ├── PortalModal.tsx
│ │ ├── index.ts
│ └── └── styles.ts
├── hooks/
│ └── useOnClickOutside.tsx
├── styles/
│ ├── modal.css
│ ├── normalize.css
│ └── theme.ts
├── ts/
│ ├── interfaces/
│ │ └── modal.interface.ts
│ ├── types/
│ └── └── styled.d.ts
├── App.tsx
├── main.tsx
└── config-dummy.ts
As we can see in the folder structure, we have several functional and styling components for this app, but in order not to make this tutorial long, we will focus only on the main components.
App.tsx
: In this component, we have examples of how to use our custom modal. We have buttons that show modals with different configurations to give us an idea of what we can achieve with this modal.
In this component, we also define the theme for our modal, adding a ThemeProvider
and creating a global style with createGlobalStyle
of styled-components
.
import { FC, useState } from "react";
import Header from "./components/layout/Header";
import { Buttons, Modal } from "./components/modals";
import { ThemeProvider } from "styled-components";
import { lightTheme, darkTheme, GlobalStyles } from "./styles/theme";
import * as S from "./components/modals/styles";
import { INITIAL_CONFIG } from "./config-dummy";
import imgModal from "./assets/images/imgModal.jpg";
const App: FC = () => {
const [theme, setTheme] = useState("dark");
const [show1, setShow1] = useState < boolean > false;
const [show2, setShow2] = useState < boolean > false;
const [show3, setShow3] = useState < boolean > false;
const [show4, setShow4] = useState < boolean > false;
const isDarkTheme = theme === "dark";
return (
<ThemeProvider theme={isDarkTheme ? darkTheme : lightTheme}>
<>
<GlobalStyles />
<Header isDarkTheme={isDarkTheme} setTheme={setTheme} />
<main>
<Buttons
show1={show1}
setShow1={setShow1}
show2={show2}
setShow2={setShow2}
show3={show3}
setShow3={setShow3}
show4={show4}
setShow4={setShow4}
/>
<Modal show={show1} setShow={setShow1} config={INITIAL_CONFIG.modal1}>
<h1>My Modal 1</h1>
<p>Reusable Modal with options to customize.</p>
<S.ModalFooter>
<S.ModalButtonSecondary onClick={() => setShow1(!show1)}>
Cancel
</S.ModalButtonSecondary>
<S.ModalButtonPrimary>Acept</S.ModalButtonPrimary>
</S.ModalFooter>
</Modal>
<Modal show={show2} setShow={setShow2} config={INITIAL_CONFIG.modal2}>
<p>Reusable Modal with options to customize.</p>
<input type="email" placeholder="Email" />
<S.ModalFooter>
<S.ModalButtonPrimary>Send</S.ModalButtonPrimary>
</S.ModalFooter>
</Modal>
<Modal show={show3} setShow={setShow3} config={INITIAL_CONFIG.modal3}>
<img src={imgModal} alt="My Modal" />
</Modal>
<Modal show={show4} setShow={setShow4} config={INITIAL_CONFIG.modal4}>
<h1>My Modal 4</h1>
<p>Reusable Modal with options to customize.</p>
</Modal>
</main>
</>
</ThemeProvider>
);
};
export default App;
Modal.tsx
: This component is conditioned to be displayed or not depending on the action performed by the user. It is wrapped in a style component that is superimposed on the screen.
This component receives as property the configuration that is where we will define how our modal will be seen, that is to say, the position where it will be shown, the title of the modal, paddings, etc.
It also receives children, which contains all the content that will be shown inside the modal. It can be any type of tsx
content.
Also, in this component, we have a couple of functionalities, which serve us to close the modal.
useOnClickOutside
: This is a custom hook that will close the modal when it detects that the user clicks outside the modal.
This hook receives as a parameter the reference of the element that we want to detect and a callback that is the action that we want to make when detecting a click.
This hook adds an EventListener
that will respond to the mousedown
and touchstart
event, after this, it will evaluate if the click was inside the element or outside of it.
handleKeyPress
: This is a callback that will be executed when it detects that the user presses the ESC key to close the modal.
It does this by adding an EventListener
to the keydown
event to then evaluate which key was pressed.
import { useCallback, useEffect, useRef } from "react"
import PortalModal from "./PortalModal"
import useOnClickOutside from "../../hooks/useOnClickOutside"
import { ModalConfig } from "../../ts/interfaces/modal.interface"
import * as S from "./styles"
import "../../styles/modal.css"
interface Props {
show: boolean;
config: ModalConfig;
setShow: (value: boolean) => void;
children: JSX.Element | JSX.Element[];
}
const Modal = ({ children, show, setShow, config }: Props) => {
const modalRef = useRef < HTMLDivElement > null
// handle what happens on click outside of modal
const handleClickOutside = () => setShow(false)
// handle what happens on key press
const handleKeyPress = useCallback((event: KeyboardEvent) => {
if (event.key === "Escape") setShow(false)
}, [])
useOnClickOutside(modalRef, handleClickOutside)
useEffect(() => {
if (show) {
// attach the event listener if the modal is shown
document.addEventListener("keydown", handleKeyPress)
// remove the event listener
return () => {
document.removeEventListener("keydown", handleKeyPress)
}
}
}, [handleKeyPress, show])
return (
<>
{show && (
<PortalModal wrapperId="modal-portal">
<S.Overlay
showOverlay={config.showOverlay}
positionX={config.positionX}
positionY={config.positionY}
show={show}
style={{
animationDuration: "400ms",
animationDelay: "0",
}}
>
<S.ModalContainer padding={config.padding} ref={modalRef}>
{config.showHeader && (
<S.ModalHeader>
<h3>{config.title}</h3>
</S.ModalHeader>
)}
<S.Close onClick={() => setShow(!show)}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-x"
viewBox="0 0 16 16"
>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
</svg>
</S.Close>
<S.Content>{children}</S.Content>
</S.ModalContainer>
</S.Overlay>
</PortalModal>
)}
</>
)
}
export default Modal
PortalModal.tsx
: This component uses the React Portals, which we have already mentioned previously.
It receives children that would be our modal and an id that we will use to assign it to an HTML element.
In this component, we use the hook useLayoutEffect
. This hook is a little different from useEffect
since this one is executed when it detects a change in the virtual DOM and not in the state, which is exactly what we are doing when creating a new element in the DOM.
Inside the useLayoutEffect
, we look for and validate if the element has already been created with the id that we have passed, and we set this element. Otherwise, we make a new element in the DOM with the function createWrapperAndAppenToBody
.
With this function, we can create the element where it best suits us. In this case, it is being created at the same level as the root element within the body.
Once we have created the element where we are going to insert our modal, we create the portal with createPortal
.
import { useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
interface Props {
children: JSX.Element;
wrapperId: string;
}
const PortalModal = ({ children, wrapperId }: Props) => {
const [portalElement, setPortalElement] =
(useState < HTMLElement) | (null > null);
useLayoutEffect(() => {
let element = document.getElementById(wrapperId) as HTMLElement
let portalCreated = false;
// if element is not found with wrapperId or wrapperId is not provided,
// create and append to body
if (!element) {
element = createWrapperAndAppendToBody(wrapperId);
portalCreated = true;
}
setPortalElement(element);
// cleaning up the portal element
return () => {
// delete the programatically created element
if (portalCreated && element.parentNode) {
element.parentNode.removeChild(element);
}
};
}, [wrapperId]);
const createWrapperAndAppendToBody = (elementId: string) => {
const element = document.createElement("div");
element.setAttribute("id", elementId);
document.body.appendChild(element);
return element;
};
// portalElement state will be null on the very first render.
if (!portalElement) return null;
return createPortal(children, portalElement);
};
export default PortalModal;
configDummy.ts
: This is the file we will use as a template to generate different modals, in this case, 4.
As you can see, you can make a lot of combinations to generate a modal different from each other, and you could add more configurations if you wish.
import {
ModalConfigDummy,
ModalPositionX,
ModalPositionY,
} from "./ts/interfaces/modal.interface";
export const INITIAL_CONFIG: ModalConfigDummy = {
modal1: {
title: "Modal Header 1",
showHeader: true,
showOverlay: true,
positionX: ModalPositionX.center,
positionY: ModalPositionY.center,
padding: "20px",
},
modal2: {
title: "Modal Header 2",
showHeader: false,
showOverlay: true,
positionX: ModalPositionX.center,
positionY: ModalPositionY.center,
padding: "20px",
},
modal3: {
title: "Modal Header 3",
showHeader: false,
showOverlay: true,
positionX: ModalPositionX.left,
positionY: ModalPositionY.start,
padding: "0",
},
modal4: {
title: "Modal Header 4",
showHeader: false,
showOverlay: true,
positionX: ModalPositionX.right,
positionY: ModalPositionY.end,
padding: "0",
},
};
That’s it! we have our cool Modal.
In this tutorial, we have created a reusable component as we can use anywhere in our application. Using React Portals, we can insert it anywhere in the DOM as it will create a new element with the id
, we assign to it.
We also have different styling options for our modal, and we can add the ones we can think of, besides having implemented a dark mode that I particularly like.
I hope this tutorial has been useful for you and that you have learned new things in developing this application.
Also published here.