Pure React Modal

To get a project up and running quickly, it’s not uncommon to utilize a helper library, such as Bootstrap or Material, for the UI. It has its benefits: components and styles work out of the box with little configuration and it can save a lot of time. When our company wanted to introduce Themes, my talented senior developer brought up that it would be a good time to convert our components to use Styled-Components. As part of the conversion, I had the chance to replace Bootstrap’s Modal with a reusable component that had no outside dependencies. Some of the event callbacks I found interesting, to help with the fade in/out animation, which is why I wanted to share this.

TL;DR: You can utilize React’s Transition Events to help with callbacks of CSS transitions.


Let’s think about what “acceptance criteria” would look like for a modal:

  • Covers over other content
  • Fade In on open
  • Fade Out on close
  • Escape key closes and creates Fade Out

We’ll assume you have some solid React experience at this point. If not, there’s tons of readme’s and tutorials out there. And we’ll assume the same with Styled-Components. Styled-Components really extend your React Component by allowing props to be used for conditional styling. It’s an interesting change in thought process, to truly make components reusable and maintainable. On to the code for this super simple component with a modal button:

First we’ll make a basic <Main /> component which renders <Modal/> if the state equates to true:

// Main.js
class Main extends Component {
state = {
isModalOpen: false
};
toggleState = e => {
this.setState({
isModalOpen: !this.state.isModalOpen
});
};
render() {
return (
<div>
<button onClick={this.toggleState}>Open Modal</button>
<div>Modal is: {this.state.isModalOpen ? "Open" : "Closed"}</div>
{this.state.isModalOpen && (
<Modal
id="modal"
isOpen={this.state.isModalOpen}
onClose={this.toggleState}
>
<div className="box-body">I am the content of the modal</div>
</Modal>
)}
</div>
);
}
}

We’ll need to keep track of state outside of the modal to determine if we want to render the Modal itself, and it will also use state as a Prop, for use later on. Then what ever you want in the Modal itself gets passed and rendered as children.

Note: For reusability purposes, there are ways to structure the <Modal /> and the Parent component such that “children” may (or may not) include the Header and Footer, for example: if you do not want all of your modals to include a “close” button.

Now, let’s breakdown the Modal implementation.

To ensure the Modal itself is not affected by other components in your App, we should take advantage of the CreatePortal() API since we want it outside of the main App’s DOM hierarchy. In your index.html file, add a 2nd element after your main app element:

<div id="modal-root"></div>

Now, how should the Modal operate when it renders? 1) It needs to attach to the new DOM node, and 2) it needs to render the children.

// Modal.js
render() {
return ReactDom.createPortal(
<div className="box-dialog">
<div className="box-header">
<h4 className="box-title">Title Of Modal</h4>
<button onClick={this.handleClick} className="close">
×
</button>
</div>
<div className="box-content">{this.props.children}</div>
<div className="box-footer">
<button onClick={this.handleClick} className="close">
Close
</button>
</div>
</div>
<div
className={`background`}
onMouseDown={this.handleClick}
ref={this.background}
/>,
modalRoot
);
}

What we’ve done here is wrap all the Modal nodes inside of CreatePortal() . But if you try to render this, you will get an error modalRoot is not defined because we haven’t declared the modalRoot. Outside of our class, let make a variable:

const modalRoot = document.getElementById("modal-root");

At this point, your modal should “work” in terms of being able to display content, it’s not quite there yet. That’s because we haven’t tapped into the transition event and styling.

Let’s wrap our <Modal /> with a StyledComponent Container:

// Modal.js
<StyledModal
id={this.props.id}
className={`wrapper ${"size-" + this.props.modalSize} fade-${this.state.fadeType} ${this.props.modalClass}`}
role="dialog"
modalSize={this.props.modalSize}
onTransitionEnd={this.transitionEnd}
>
...modal nodes from above
</StyledModal>

(I’ll address some of these component attributes as we go along: modalSize, className, onTransitionEnd.)

Some things to note that I didn’t realize the first time. You can think of this Container as a “div”, “span”, “button” or whatever type of DOM element you want. That means you can assign it with an id and other common attributes. And since it’s a Component for React, you can also pass it props to be used for style logic.

This is what CSS looks like to implement on your Component:

// Modal.css.js
const Modal = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity linear 0.15s;
z-index: 2000;
width: ${props => {
switch (props.modalSize) {
case "lg":
return "800";
default:
return "480";
}
}}px;
margin: 40px auto;
`;
export default Modal;

Let’s consider Acceptance Criteria #1 handled with the z-index.

Notice how I start to style without referencing an ID or Class? Think of this as equal to the element referenced on the first line (in this case div but also as referencing the ID we assigned to it #modal).

As you can see we’re using props.modalSize to change the width of the element, which is passed as prop. You can also set a defaultProp as to not have to pass this everytime.

You’ll also want to style the other modal elements: .box-dialog, .box-header, .x-close, .box-body, .box-footer . You may be thinking: “Why are you prefixing “box-” to everything?” This is because anything after is how Bootstrap handles their class naming and I do not want any conflicts. I’m not going to paste in the style for these since it’s kind of long. I have a link at the end of this with full working code example.

Part of the beauty of Styled Components is that it really encapsulates your styles. You SHOULD name your IDs or ClassNames to be generic about what it is (“wrapper”, “header”, etc.) because 1) it helps with reusability, and 2) it’s encapsulated so you do not need to call it something super specific for styling overrides.

Now for the fun part of React, we can hook into the Components events directly, to generate transitions and call functions as part of the event completion callback. Sometimes, this can be used instead of other libraries such as ReactCSSTransitionGroup .

Notice on the StyledComponent: onTransitionEnd={this.transitionEnd}? We first need to include a transition to keep track of. Back in the styled file, let’s add the fade transitions to the outer div:

&.fade-in {
opacity: 1;
transition: opacity linear 0.5s; // super slow to see fade works
}
&.fade-out {
opacity: 0;
transition: opacity linear 0.5s; // super slow to see fade works
}

But how do we know when to add the fade-in or fade-out class? Let’s start by setting initial state of the component: state = { fadeType: null }; and when the component mounts, we’ll set the state to in so that the className we declared of StyledModal can be used:

componentDidMount() {
setTimeout(() => this.setState({ fadeType: "in" }), 0);
}
Why can’t we just set initial State to true?

Because with the lifecycle of a React Component, the initial render will include the className in and in this case, by the time the component is actually mounted, there is no “transition” change. There is probably a workaround to this, but it seems easier to read and understand the functionality this way, IMO.

Now, when the modal opens, the CSS properties activate for transition and React handles it like any other event, such as onClick. Cool!

We initially declared onTransitionEnd which references the callback function transitionEnd so in our Modal class let’s add this method.

// Modal.js
transitionEnd = e => {
if (e.propertyName !== "opacity" || this.state.fadeType === "in") return;
    if (this.state.fadeType === "out") {
this.props.onClose();
}
};

There’s nothing we need to do on a fade in, but on fade out we do want to trigger the onClose prop method so the Parent state is updated. Let’s consider Acceptance Criteria #2 and #3 handled.

Now to handle an Escape key to close, this should be fairly straight forward. In the ComponentDidMount let’s add an eventListener and corresponding callback function:

// Modal.js
componentDidMount() {
window.addEventListener("keydown", this.onEscKeyDown, false);
setTimeout(() => this.setState({ fadeType: "in" }), 0);
}
onEscKeyDown = e => {
if (e.key !== "Escape") return;
this.setState({ fadeType: "out" });
};

Now our state is updated and the transition event kicks in again. Let’s consider the Acceptance Criteria #4 complete.

The same logic applies to a modal “close” button:

// Modal.js
handleClick = e => {
e.preventDefault();
this.setState({ fadeType: "out" });
};

And now, we have a working modal that covers content, fades in and fades out, and has an escape key close function:

Live example of Pure React modal

Here’s the full Modal component code:

import React, { Component } from "react";
import ReactDom from "react-dom";
import PropTypes from "prop-types";
// styled
import StyledModal from "./Modal.css";
const modalRoot = document.getElementById("modal-root");
class Modal extends Component {
static defaultProps = {
id: "",
modalClass: "",
modalSize: "md"
};
static propTypes = {
id: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
modalClass: PropTypes.string,
modalSize: PropTypes.string
};
state = { fadeType: null };
background = React.createRef();
componentDidMount() {
window.addEventListener("keydown", this.onEscKeyDown, false);
setTimeout(() => this.setState({ fadeType: "in" }), 0);
}
componentDidUpdate(prevProps, prevState) {
if (!this.props.isOpen && prevProps.isOpen) {
this.setState({ fadeType: "out" });
}
}
componentWillUnmount() {
window.removeEventListener("keydown", this.onEscKeyDown, false);
}
transitionEnd = e => {
if (e.propertyName !== "opacity" || this.state.fadeType === "in") return;
if (this.state.fadeType === "out") {
this.props.onClose();
}
};
onEscKeyDown = e => {
if (e.key !== "Escape") return;
this.setState({ fadeType: "out" });
};
handleClick = e => {
e.preventDefault();
this.setState({ fadeType: "out" });
};
render() {
return ReactDom.createPortal(
<StyledModal
id={this.props.id}
className={`wrapper ${"size-" + this.props.modalSize} fade-${
this.state.fadeType
} ${this.props.modalClass}`}
role="dialog"
modalSize={this.props.modalSize}
onTransitionEnd={this.transitionEnd}
>
<div className="box-dialog">
<div className="box-header">
<h4 className="box-title">Title Of Modal</h4>
<button onClick={this.handleClick} className="close">
×
</button>
</div>
<div className="box-content">{this.props.children}</div>
<div className="box-footer">
<button onClick={this.handleClick} className="close">
Close
</button>
</div>
</div>
<div
className={`background`}
onMouseDown={this.handleClick}
ref={this.background}
/>
</StyledModal>,
modalRoot
);
}
}
export default Modal;

And a big kudos to my super talented co-worker, Senior JavaScript Engineer Abhishek Saha, for providing me with so much knowledge over the last year-plus. Your skills and knowledge about JS & React always impress.