Build a full-featured Modal dialog Form with React

How to create a form in a modal that pops on click.

Krissanawat​ Kaewsanmuang
Bits and Pieces

--

In this tutorial, you’ll create a form in a modal with React.

The Modal pops up on a button click. A form is a separate component from the Modal and can be modified without affecting the modal itself. Modal freezes the background and prevents a user from scrolling.

There’s a close button to exit the modal. But it can also be closed by clicking outside the Form area or pressing the Escape key. The end result of the tutorial will meet these criteria.

The form is probably the most important part of any site. You can use it to get leads, login or have people contact you. Popup forms are where you get the most number of leads, so they’re very useful.

Let's create a classic pop up form, one that appears on click of a button. Yes, everything will be done using React. You’ll discover the important factors to consider in a popup as you follow.

Here’s what it looks like when done.

Tip: Use tools like Bit to make components reusable. Your team can share your components, install them in their projects, suggest updates and build faster.

Refer to the source code as you follow along:

Project structure

>App
>Filler
>Container
>Trigger
>Modal
>Form
>Filler
>Filler

The Parent App component

An app is the default container for everything.

  • It has Filler text, just to surround button by something.
  • The Container component, it contains a trigger button and Modal with form.

I also passed onSubmit function for the form submit action from App component.

React components are all about re-usability. I passed the trigger text from App component. What would you pass in the triggerText?

Since it doesn’t have states to keep track of, I’ll make it a stateless component. Stateless component is a JavaScript-like function. It returns JSX instead of a JavaScript data type.

App.js

import React from 'react';import './App.css';import { Container } from './Container';import { Filler } from './Filler';const App = () => {const triggerText = 'Open Form';
const onSubmit = (event) => {
event.preventDefault(event);
console.log(event.target.name.value);
console.log(event.target.email.value);
};
return (
<div className="App">
<Filler />
<Container triggerText={triggerText} onSubmit={onSubmit} />
<Filler />
<Filler />
<Filler />
<Filler />
</div>
);
};
export default App;

The submit method just logs the name and email from the form in the console. You can send them to your autoresponder, store in a database or whatever you wish.

Filler Text

Filler component returns a generic lorem ipsum text. Just for the sake on content we need on the page. It is also a stateless component.

import React from 'react';export const Filler = () => {return (<p className="lorem-text">Lorem ipsum dolor sit, amet consectetur adipisicing elit. Nulla iurevoluptatibus, eligendi labore eveniet repudiandae, dolore, dolor modiipsam eum accusantium fuga atque placeat iste molestiae quisquam quidemsuscipit omnis. Lorem ipsum dolor sit amet consectetur adipisicing elit.Fugit sit et dolore officiis cum voluptatem reprehenderit aliquamaspernatur iure similique quibusdam autem voluptatibus natus molestiaedicta maxime sint, iste temporibus.</p>);};export default Filler;

I’ve used className of lorem-text to justify it. All the CSS, most of which I won’t talk about, is in index.css. Check the code on Codesandbox here.

Filler Text Component

The Container. But why?

What’s the need for the container? We shall just have the Modal, right? Wrong!

The popup in the center is the actual Modal. The background darkens when the popup is visible. That is whole screen is this Container.

Also, the modal should close on clicking outside the Modal area. How do we detect that? Using container and the Modal.

You can’t escape the Container.

And Container holds two components:

  1. TriggerButton
  2. Modal

TriggerButton is the button surrounded by Filler text that launches the Modal, and Modal is, well, the Modal.

The Container needs a state to change the visibility of the Modal. It’ll be a stateful component.

Delcare the isShown state.

import React, { Component } from 'react';
import { Modal } from '../Modal';
import TriggerButton from '../TriggerButton';
export class Container extends Component {
state = { isShown: false };
render() {
return (
<TriggerButton
triggerText={this.props.triggerText}
/>
{this.state.isShown ? (<Modal/>) : null});
}
}
export default Container;

Initially, it’s not shown and isShown is false. It renders the TriggerButton while passing the triggerText to it. Also, conditionally renders Modal .

Conditional Render

We need the Modal when isShown is true, otherwise we only need the trigger button from the Container. In the above code, I used a ternary operator :? to check if isShown state is true. We return null if is shown is false.

condition ? ifTrue : else

Since two components can’t be returned from JSX, wrap it in a React.Fragment. It’s just like div, except it doesn’t render itself on the DOM, it only renders the children.

Time for Modal

Here are the features of a generic modal:

  • Clicking outside Modal area exits the Modal: needs a onClickOutside method.
  • Pressing escape key closes it: needs onKeyDown method
  • Needs close function for the above two cases: a closeModal method
  • Needs focus locked. If you switch the form fields using TAB key, it should repeat within the Modal. Also, if the user presses TAB to navigate within the page, the Modal should not get focused.
  • It’s not on the DOM until it’s rendered: we’ve achieved this using conditional rendering.
  • Don’t let the background scroll when the Modal is active.
  • Render the DOM at the end of the DOM (within the body)

Focus: We need to get focus to close button upon opening the Modal and to the trigger button on closing it. We need to reference Modal and Button. Use refs to refer anything in react. They can also be passed to children and components in children can be referred.

Modal is not rendered in DOM if it’s not in view.

Here’s the Container with all methods.

import React, { Component } from 'react';
import { Modal } from '../Modal';
import TriggerButton from '../TriggerButton';
export class Container extends Component {
state = { isShown: false };
render() {
return (
<React.Fragment>
<TriggerButton
showModal={this.showModal}
buttonRef={(n) => (this.TriggerButton = n)}
triggerText={this.props.triggerText}
/>
{this.state.isShown ? (
<Modal
onSubmit={this.props.onSubmit}
modalRef={(n) => (this.modal = n)}
buttonRef={(n) => (this.closeButton = n)}
closeModal={this.closeModal}
onKeyDown={this.onKeyDown}
onClickOutside={this.onClickOutside}
/>
) : null
}
</React.Fragment>
);
}
}
export default Container;

TriggerButton gets a reference, we’ll use that is a while. It receives triggerText to display and showModal to run on click.

The Modal component receives a lot of props. onSubmit for form submit action, modalRef to refer modal area and buttonRef to refer close button. closeModal, showModal to close and show the modal. It also gets onClickOutside so that we can detect the user click outside the Modal and close modal.

Since we need a lot of methods, lets declare them in Container component.

showModal = () => {
this.setState({ isShown: true }, () => {
this.closeButton.focus();
this.toggleScrollLock();
});
};

showModal sets isShown to true. The setState can take a callback function, we can use that to focus on closeButton (in the modal) as model becomes visible and toggle the scroll lock.

closeButton is the buttonRef passed in the Modal component.

toggleScrollLock

toggleScrollLock = () => {document.querySelector('html').classList.toggle('scroll-lock');};

Select html and hide the overflow to block scrolling, that overflow:hidden is in the index.css file.

closeModal

closeModal = () => {this.setState({ isShown: false });this.TriggerButton.focus();this.toggleScrollLock();};

closeModal sets isShown to false and Modal no longer renders in the DOM. It also gives focus to the trigger button and toggles scroll lock. We removed the hidden overflow by toggling scroll lock.

onKeyDown and onClickOutside

onKeyDown = (event) => {
if (event.keyCode === 27) {
this.closeModal();
}
};
onClickOutside = (event) => {
if (this.modal && this.modal.contains(event.target)) return
this.closeModal();
};

onKeyDown closes the Modal of the if the pressed key is Escape.

onClickOutside checks if the modal contains the current click target and returns in that case. This means click is within the modal. Otherwise, closeModal is called.

Container component

import React, { Component } from 'react';
import { Modal } from '../Modal';
import TriggerButton from '../TriggerButton';
export class Container extends Component {
state = { isShown: false };
showModal = () => {
this.setState({ isShown: true }, () => {
this.closeButton.focus();
});
this.toggleScrollLock();
};
closeModal = () => {
this.setState({ isShown: false });
this.TriggerButton.focus();
this.toggleScrollLock();
};
onKeyDown = (event) => {
if (event.keyCode === 27) {
this.closeModal();
}
};
onClickOutside = (event) => {
if (this.modal && this.modal.contains(event.target)) return;
this.closeModal();
};
toggleScrollLock = () => {
document.querySelector('html').classList.toggle('scroll-lock');
};
render() {
return (
<React.Fragment>
<TriggerButton
showModal={this.showModal}
buttonRef={(n) => (this.TriggerButton = n)}
triggerText={this.props.triggerText}
/>
{this.state.isShown ? (
<Modal
onSubmit={this.props.onSubmit}
modalRef={(n) => (this.modal = n)}
buttonRef={(n) => (this.closeButton = n)}
closeModal={this.closeModal}
onKeyDown={this.onKeyDown}
onClickOutside={this.onClickOutside}
/>
) : null}
</React.Fragment>
);
}
}
export default Container;

Trigger Button

It’s a button that calls showModal on click. The text in button is triggerText. Both are passed as props. Instead of using props.triggerText, I destructured the parameters.

import React from 'react';const Trigger = ({ triggerText, buttonRef, showModal }) => {
return (
<button
className="btn btn-lg btn-danger center modal-button"
ref={buttonRef}
onClick={showModal}
>
{triggerText}
</button>
);
};
export default Trigger;

FocusTrap

We need the focus trapped within the fields in the Modal, for this we can wrap the JSX within FocusTrap and focus will remain locked within.

In component Modal, we need to use FocusTrap.

npm install focus-trap-react

import {FocusTrap } from focus-trap-react

Wrap the Modal in <FocusTrap>. You have successfully trapped the focus, what’s next? Creating modal at the end of the body.

React Portal to inject any component anywhere in DOM

React portals is latest API in to the ReactDOM. It allows to go outside the application into the DOM and place anything.

ReactDOM.createPortal(</Component>, document.body) will append Component to the end of the body.

With FocusTrap and Portal, here’s the Modal code.

import React from 'react';
import ReactDOM from 'react-dom';
import { Form } from '../Form';
import FocusTrap from 'focus-trap-react';
export const Modal = ({
onClickOutside,
onKeyDown,
modalRef,
buttonRef,
closeModal,
onSubmit
}) => {
return ReactDOM.createPortal(
<FocusTrap>
<aside
tag="aside"
role="dialog"
tabIndex="-1"
aria-modal="true"
className="modal-cover"
onClick={onClickOutside}
onKeyDown={onKeyDown}
>
<div className="modal-area" ref={modalRef}>
<button
ref={buttonRef}
aria-label="Close Modal"
aria-labelledby="close-modal"
className="_modal-close"
onClick={closeModal}
>
<span id="close-modal" className="_hide-visual">
Close
</span>
<svg className="_modal-close-icon" viewBox="0 0 40 40">
<path d="M 10,10 L 30,30 M 30,10 L 10,30" />
</svg>
</button>
<div className="modal-body">
<Form onSubmit={onSubmit} />
</div>
</div>
</aside>
</FocusTrap>,
document.body
);
};
export default Modal;

tabIndex of -1 makes it non-focusable. You can give tabIndex of 1 or above to any element and they can be focused with TAB key. Since, we do not not want focus on Modal if its not visible, we gave it -1.

Focus Locked to the Modal (when visible): switching through TAB key.

Modal has a close button and close text. Close text is for screen readers and visually impaired people. It’s hidden through CSS. Close button is created using SVG and a click calls closeModal.

The aside tag (Modal) takes 100% height and width, has a dark overlay background. This is what gives modal a overlay color. It has css transform properties to keep it in the center of the window, check index.css.

Modal (in <aside>) rendered at the end of the DOM.

It has Form component. The onSubmit prop is passed to Form.

The form is nothing special, I grabbed one from bootstrap. Bootstrap isn’t necessary part, but I didn’t want to focus on CSS neither wanted it to look ugly.

import React from 'react';export const Form = ({ onSubmit }) => {
return (
<form onSubmit={onSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input className="form-control" id="name" />
</div>

<div className="form-group">
<label htmlFor="email">Email address</label>
<input type="email" className="form-control" id="email"
placeholder="name@example.com"
/>
</div>
<div className="form-group">
<button className="form-control btn btn-primary" type="submit">
Submit
</button>
</div>
</form>
);
};
export default Form;

That’s a form with onSubmit action defined on App.js.

Conclusion

You can fork the code, download, make changes, practice, or whatever. The full code is available here. Please feel free to comment and ask anything or share the components you made (with Bit or GitHub). Thanks for reading!

--

--

React Ninja l | Hire Me! | Contact: krissanawat101[at:()]gmail | Follow me on Twitter: @reactninja