Modals are very useful for displaying one view on top of another.
However, they are more than an absolutely positioned <div>
element wrapping everything when it comes to implementation. Especially if you need dynamic URLs, page refreshes, or a simple scrolling interaction on a mobile device.
In this article, we’ll discuss the various aspects of modals and identify solutions to satisfy the requirements that come with creating dynamic URLs, page refreshes, and other features.
Before starting to shape the modal component, let’s start with some basics of the react-router package.
We’ll use four components from this package: BrowserRouter, Route, Link, and Switch.
Since this is not a react-router tutorial, I won’t be explaining what each of these components do.
However, if you’d like some info about react-router, you can check out this page.
First, go ahead and install react-router-dom
through npm.
npm install react-router-dom --save
At the very top level of your application, use the <BrowserRouter/>
component to wrap your app.
import { BrowserRouter } from "react-router-dom";
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
);
Inside <App/>
, you’ll need to specify the routes so that you can render a specific view when one of them — or none of them — match.
Let’s assume we have three different components to render: <Home/>
, <About/>
and <Contact/>
. We’ll create a navigation menu, which will always be visible at the very top of the application.
The <Link/>
or <NavLink/>
components from react-router-dom
are used for navigation purposes, while <NavLink/>
has the special feature of being applicable to a specific styling when the current URL matches.
Functionality-wise, you can use either one.
Below is the basic structure of the navigation menu, which changes the URL accordingly:
render() {
return (
<div className="app">
<div className="menu">
<Link className="link" to='/'>Home</Link>
<Link className="link" to='/about'>About</Link>
<Link className="link" to='/contact'>Contact</Link>
</div>
</div>
);
}
The next thing we’ll do is implement the mechanism that matches the URL and renders a specific component.
<Switch/>
renders the first matching location specified by its <Route/>
children. When nothing is matched, the last <Route/>
is returned — usually as a 404 page.
render() {
return (
<div className="app">
<div className="menu">
<Link className="link" to='/'>Home</Link>
<Link className="link" to='/about'>About</Link>
<Link className="link" to='/contact'>Contact</Link>
</div>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/contact/" component={Contact} />
<Route exact path="/about" component={About} />
<Route>{'404'}</Route>
</Switch>
</div>
);
}
So far, we’ve implemented the basic routing structure. Now we can create a modal component and work on displaying it as an overlay.
Although there are a variety of different methods for creating modal components, we’ll only be covering one.
A modal component has a wrapper element which spans the whole screen — width and height.
This area also acts as a clickedOutside
detector. Then the actual modal element is positioned relative to that wrapper element.
Below is an example of a <Modal/>
functional component using withRouter
HOC (Higher order component) to access the router history and call the goBack()
method to change the application URL when the modal is closed on click to .modal-wrapper
.
onClick={e => e.stopPropagation()}
is used to prevent propagation of the click event and trigger the onClick
on .modal-wrapper
, which would close the modal when the actual .modal
element is activated.
import React from 'react';
import { withRouter } from 'react-router-dom';
const Modal = () => (
<div
role="button"
className="modal-wrapper"
onClick={() => this.props.history.goBack()}
>
<div
role="button"
className="modal"
onClick={e => e.stopPropagation()}
>
<p>
CONTENT
</p>
</div>
</div>
);
export default withRouter(Modal);
Styling the .modal-wrapper
is just as important. Below, you can find the basic styling used to make it span the whole screen and appear above the content.
Using -webkit-overflow-scrolling: touch
enables elastic scroll on iOS devices.
.modal-wrapper {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100vh;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
The modal component we created should render on top of the existing view when a specific URL is matched, meaning that somehow we have to change the URL so the routing mechanism can decide what to render.
We know that <Switch/>
renders the first matching location, but a modal overlay needs two <Route/>
components rendering at the same time.
This can be achieved by putting the modal <Route/>
out of <Switch/>
and rendering it conditionally.
In this case, we should be able to detect if a modal is active or not.
The easiest way to do this is by passing a state variable along with a <Link/>
component.
In the same way we used the <Link/>
component to create the navigation menu, we’ll use it to trigger a modal view.
The usage shown below lets us define a state variable, which is then made available in the location
prop, which we can access within any component using withRouter
HOC.
<Link
to={{
pathname: '/modal/1',
state: { modal: true }
}}
>
Open Modal
</Link>
Put this anywhere you want. Clicking the link will change the URL to /modal/1
.
There might be several modals with different names like modal/1
, modal/2
, and so on.
In this case, you’re not expected to define each <Route/>
intended to match the individual modal locations. In order to handle all of them under the /modal
route, use the following syntax:
<Route exact path="/modal/:id">
This gives you the flexibility of getting the value of the hardcoded :id
parameter within the modal component through the match.params
prop.
It also lets you do dynamic content renderings, depending on which modal is open.
This section is particularly important because it identifies the mechanism for displaying a modal on top of an existing view even though the location parameter changes when a modal is opened.
When we click the Open Modal
link defined in the previous section, it will change the location path to /modal/1
, which matches nothing in <Switch/>
.
So we have to define the following <Route/>
somewhere.
<Route exact path="/modal/:id" component={Modal} />
We want to display the <Modal/>
component as an overlay.
However, putting it inside <Switch/>
would match it and only render the <Modal/>
component. As a result, there would be no overlay.
To resolve this problem, we need to define it both inside and outside of <Switch/>
with extra conditions.
Below, you’ll see the modified version of the same snippet. There are several changes. Let’s list them quickly:
There is a previousLocation
variable defined in the constructor.
There is an isModal
variable defined, which depends on some other values.
<Switch/>
is using a _location_
prop.
There are two <Route
exact
_path_="/modal/:id" component={Modal} />
used both inside and outside <Switch/>
, and the one outside is conditionally rendered.
When a modal is opened, we need to store the previous location object and pass this to <Switch/>
instead of letting it use the current location object by default.
This basically tricks <Switch/>
into thinking it’s still on the previous location — for example /
— even though the location changes to /modal/1
after the modal is opened.
This can be achieved by setting the location
prop on <Switch/>
.
The following snippet replaces the previousLocation
with the current location object when there is no open modal.
When you open a modal, it doesn’t modify the previousLocation
.
As a result, we can pass it to <Switch/>
to make it think we’re still on the same location, even though we changed the location by opening a modal.
We know that when a modal is opened, the state variable named modal
in the location
object will be set to true
.
We can check if the state of the location object is defined and has the state variable of modal
set to true
.
However, these two checks alone do not suffice in the case of refreshing the page.
While the modal has to be closed on its own, location.state && location.state.modal
still holds.
Checking whether this.previousLocation !== location
, we can make sure that refreshing the page will not result in setting isModal
to true
.
When the modal route is visited directly, which is modal/1
in our example, then none of the checks are true
.
Now we can use this boolean value to both render the <Route/>
outside of the <Switch/>
, and to decide which location
object to pass to location
prop of <Switch/>
.
Given that <Modal/>
component has the necessary stylings, this results in two different views rendering on top of each other.
constructor(props){
super(props);
this.previousLocation = this.props.location;
}
componentWillUpdate() {
const { location } = this.props;
if (!(location.state && location.state.modal)) {
this.previousLocation = this.props.location;
}
}
render() {
const { location } = this.props;
const isModal = (
location.state &&
location.state.modal &&
this.previousLocation !== location
);
return (
<div className="app">
<div className="menu">
<Link className="link" to='/'>Home</Link>
<Link className="link" to='/about'>About</Link>
<Link className="link" to='/contact'>Contact</Link>
</div>
<Switch location={isModal ? this.previousLocation : location}>
<Route exact path="/" component={Home} />
<Route exact path="/contact/" component={Contact} />
<Route exact path="/about" component={About} />
<Route exact path="/modal/:id" component={Modal} />
<Route>{'no match'}</Route>
</Switch>
{isModal
? <Route exact path="/modal/:id" component={Modal} />
: null
}
</div>
);
}
So far we have implemented our modal in a way that ensures we don’t render an overlay when refreshing a page with an open modal, or when directly visiting a modal route.
Instead, we only render the matching <Route/>
inside <Switch/>
.
In this case, the styling you want to apply is likely to be different, or you might want to show a different content.
This is pretty easy to achieve by passing the isModal
variable as a prop on the <Modal/>
component, as shown below.
Then, depending on the value of the prop, you can apply different stylings or return a completely different markup.
return (
<div className="app">
<div className="menu">
<Link className="link" to='/'>Home</Link>
<Link className="link" to='/about'>About</Link>
<Link className="link" to='/contact'>Contact</Link>
</div>
<Switch location={isModal ? this.previousLocation : location}>
<Route exact path="/" component={Home} />
<Route exact path="/contact/" component={Contact} />
<Route exact path="/about" component={About} />
<Route exact path="/modal/:id" component={Modal} />
<Route>{'no match'}</Route>
</Switch>
{isModal
? <Route exact path="/modal/:id">
<Modal isModal />
</Route>
: null
}
</div>
);
When you open the modal on some browsers it may have the content below scrolling underneath the modal, which is not a desirable interaction.
Using overflow: hidden
on body
is the first attempt to block scrolling on the entire page.
However, although this method works fine on desktop, it fails on mobile Safari since it basically ignores overflow: hidden
on body
.
There are several different npm packages attempting to remedy this scroll locking issue virtually across all platforms.
I found the body-scroll-lock package quite useful.
From this package, you can import disableBodyScroll
and enableBodyScroll
functions, which accept a reference to the element for which you want scrolling to persist as an input.
When the modal is open we want to disable scrolling for the entire page, except for the modal itself.
Therefore, we need to call disableBodyScroll
and enableBodyScroll
functions when the modal component is mounted and unmounted, respectively.
To get a reference to the parent <div>
of the modal component, we can use the createRef
API from React and pass it as a ref to the parent <div>
.
The code snippet below disables scrolling when the modal is open and enables it again when the modal component is about to be unmounted.
Using this.modalRef
as the input for these imported functions prevents the content of the modal component from being scroll-locked.
Before using the disableBodyScroll
function, we need a simple check.
This is because a modal component might get mounted if the page is refreshed when a modal is open, or when the modal route is visited directly.
In both cases, scrolling should not be disabled.
We have already passed the isModal
variable as a prop to the <Modal/>
component to render different views, so we can just use this prop to check if there is actually a modal.
Below is the modified version of the modal component:
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
class Modal extends Component {
constructor(props) {
super(props);
this.modalRef = React.createRef();
}
componentDidMount() {
const { isModal } = this.props;
if (isModal) {
disableBodyScroll(this.modalRef.current);
}
}
componentWillUnmount() {
enableBodyScroll(this.modalRef.current);
}
render() {
return (
<div
ref={this.modalRef}
className="modal-wrapper"
onClick={() => this.props.history.goBack()}
>
<div
className="modal"
onClick={e => e.stopPropagation()}
>
</div>
</div>
)
}
}
You now have an understanding of how a modal view works, as well as a sense of some of the problems you may encounter while implementing your own integration.
For the fully functional example, visit this code sandbox project.
#reactjs #react #React Route