How to build an accessible React modal component

How to build an accessible React modal component

A step-by-step guide to creating an accessible and reusable modal component in React.

A step-by-step guide to creating an accessible and reusable modal component in React.

Modal is an overlay on the web-page, but has some standards to follow. WAI-ARIA Authoring Practices are the standards set by W3C. This lets bots and screen-readers know that it is a modal. It is not within the regular flow of the page. We’ll create an awesome react modal using React components.

On top of this, React has its own workflow. Components shouldn’t be on the DOM before rendering. So, you can’t just use display properties to toggle the Modal. Never ever touch the DOM, do everything within the virtual DOM. React is smart to re-render components partially, DOM manipulations are slower. React Modal in the tutorial follows the standards!

If you’d like to see code for any reason, it’s here on codesandbox. Output is also embedded at the end of this tutorial.

Setting Up the Application

Here are the basic requirements of a simple react modal:

  1. Lock the focus within the Modal. And freeze the background.
  2. Must follow WAI-ARIA practice, with roles and semantic HTML tags. More on this later.
  3. Escape key exits an open Modal. So does a click outside of the modal.

Set up the application with create-react-app

npx create-rect-app react-modal

App component

Clean up the *App.js *to return some dummy text. Text is required to show that scrolling is locked whenever the Modal is active. You can add images or other react components. React Modal will overlay on top of these contents. Let’s get into it.

For random text, I’ll create a separate component Text instead of cluttering the *App.js.* Import Text into App and place it in the return method a couple of times. I’ll put the Modal in between these Textcomponents for the most realistic representation of a modal.

Here’s the initial App.js file.

import React, { Component } from 'react';
import './App.css';
import Text from './Text';
class App extends Component {
  render() {
    return (
      <div className="App">
        <Text />
        <Text />
        <Text />
      </div>
    );
  }
}
export default App;

Text Component

Text component returns paragraphs of text. Multiple paragraphs are wrapped by a React Fragment. As multiple containers cannot be returned, they must be wrapped in React.Fragment if such case arises.

Here’s what Text component looks like.

import React, { Component } from "react";

export class Text extends Component {
  render() {
    return (
      <React.Fragment>
        <p className="lorem-text">
          Lorem ipsum dolor sit amet consectetur, adipisicing elit...
        </p>
        <p className="lorem-text">
          Lorem ipsum dolor sit amet consectetur, adipisicing elit...
        </p>
      </React.Fragment>
    );
  }
}
export default Text;

I’ve used lorem ipsum as a filler text. You can use any text or image as placeholder. Or you can generate lorem ipsum within the VS Code. To get a lorem ipsum text in VS Code, type lorem,* suggestion for lorem ipsum will appear. Expand it with *TAB** key. The suggestion is provided by emmetplugin (comes built-in).

The Modal component, for now, is a button to launch the react modal. Import the Modal in App.

export class Modal extends Component {
  render() {
    return <button className="modal-button">Launch the Modal!</button>;
  }
}

Import and insert it somewhere between other contents in App.

class App extends Component {
  render() {
    return (
      <div className="App">
        <Text />
        <Modal />
        <Text />
        <Text />
      </div>
    );
  }
}

The output is shown below. I added some CSS for a better-looking button, all of which is in index.css, I’ll talk about the CSS where it’s used for functionality over aesthetics.

The Modal component will handle the actual content in the modal as well as the ModalTrigger.

Create a ModalTrigger component, it’s same as Modal component for now.

Change the Modal to return ModalTrigger.

export class Modal extends Component {
  render() {
    return <ModalTrigger />; ;
  }
}

Making Trigger Component Dynamic

React Components should be reusable. But our content in the ModalTrigger remains the same. I want to be able to use this component with other buttons as well.

Make it reusable by receiving a text prop from the parent component. For a dynamic react modal trigger, pass modalProps from App to Modal, include a triggerText in modalProps*.* You can pass styles as well and apply them to Modal or ModalTrigger.

class App extends Component {
  modalProps = {
    triggerText: 'Launch the Modal!'
  };
  render() {
    return (
      <div className="App">
        <Text />
        <Modal modalProps={this.modalProps} />
        <Text />
        <Text />
      </div>
    );
  }
}
export default App;

Also, pass the triggerText prop from Modal to ModalTrigger.

export class Modal extends Component {
  render() {
    return <ModalTrigger triggerText={this.props.modalProps.triggerText} /> ;
  }
}

Now, the ModalTrigger component should render triggerText

export class ModalTrigger extends Component {
  render() {
    return <button className="modal-button">{this.props.triggerText}</button>;
  }
}

We have the same output, but changing triggerText in modalProps will change the text in button. Try for yourself.

Actual Modal

This far to the tutorial and I haven’t even talked about the actual modal. I’ll name this component ModalContent.

import React, { Component } from 'react';

export class ModalContent extends Component {
  render() {
    return <div>Hello! This is content!</div>;
  }
}

export default ModalContent;

Well, that is a valid component. But let’s make some changes to make it a modal!

The first thing to do is to change container div to aside. Why? Semantically, this React modal or any modal is more of an extra/aside component from the main content.

Let’s look at the whole code…

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
export class ModalContent extends Component {
  render() {
    return ReactDOM.createPortal(
      <aside
        className="modal-cover"
      >
        <div className="modal-area">
          <button
            className="_modal-close"
          >
            <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">The Actual Content in the Modal</div>
        </div>
      </aside>,
      document.body
    );
  }
}
export default ModalContent;

Modal Cover is a full-page container (100% height and width). The actual Modal content of the react modal is inside this container. Use the cover for closing the modal whenever use clicks outside the Modal content. The actual modal portion is the modal-body. There’s also a close button to exit the modal. The close text will be invisible to the user but can be accessed by the screen-readers to locate the close option.

The react modal cover and modal are both in a fixed layout. This is done to keep the modal in the center of the page. The close button is positioned absolutely at the right-top of the modal.

If you replace ModalTrigger with ModalContent in Modal component, you’ll get the output as below.

Render the Modal at the end of the body

If you look at the modal, it looks fine. But it’s always in the DOM. The Modal is just rendered wherever you call it. A proper Modal is appended to the end of the body.

To take components out of the place it’s rendered, an event can be bubbled up through DOM. DOM should not be accessed directly in React and event bubbling is difficult to debug in a complex application. I’ll take an easier Route of React Portals. They serve this particular case.

React Portal is an API in ReactDOM.

ReactDOM.createPortal() takes two parameters: first JSX and second location on DOM.

ReactDOM.createPortal(

Hello

, document.body) creates a new paragraph at the end of document.body. Now replace this p by the aside in ModalContent.

Here’s the code…

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
export class ModalContent extends Component {
  render() {
    return ReactDOM.createPortal(
      <aside className="modal-cover">
        <div className="modal-area">
          <button className="_modal-close">
            <span 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">The Actual Content in the Modal!</div>
        </div>
      </aside>,
      document.body
    );
  }
}
export default ModalContent;

The output looks the same, but the ModalContent is rendered at the end of the body, outside the App*.*

Modal went outside the App to render the Modal. This would be nearly impossible without React Portal.

Display the Modal with a state

Only render the Modal whenever a user clicks the Launch button. To handle this, use a state.

First, render both ModalTrigger and ModalContent from Modal. But JSX can return only one wrapping tag.

Don’t use div for wrapping. Wrap in React.Fragment, it doesn’t render in the DOM but does wrap the content. After rendering, it looks as if multiple components were returned.

return (
      <React.Fragment>
        <ModalTrigger triggerText={this.props.modalProps.triggerText} />
        <ModalContent />
      </React.Fragment>
    );

Set a state in the Modal component to toggle the ModalContent. I’ll call the state isShown for rendering ModalContent.

I’ll do this through a constructor.

 constructor() {
    super();
    this.state = {
      isShown: false
    };
  }

Modal is hidden initially, so isShown is false.

Set up the conditional display

In Modal, replace ** by the code below

{this.state.isShown?<ModalContent/>:<React.Fragment/>}

This is a ternary operation, it returns ModalContent if this.state.isShown is true*,* else returns a blank Fragment. A blank fragment means a container that doesn’t render itself and contains nothing. So, it’s blank, literally!

Toggling the state and ModalContent

Now there’s a state in consideration, but there isn’t a way to change the state.

In Modal component, pass the showModal prop to ModalTrigger*.*

<ModalTrigger
showModal={this.showModal}
triggerText={this.props.modalProps.triggerText}
>

Add an onClick handler to the button in ModalTrigger*.*

<button onClick={this.props.showModal} className="modal-button">
        {this.props.triggerText}
      </button>

Now, clicking the button should show the ModalContent*.*

Similarly, add closeModal to close the ModalContent*.* Pass the closeModalas a prop to ModalContent.

<ModalContent closeModal={this.closeModal}/>

In the ModalContent, add onClick handler to close button.

 <button className="_modal-close" onClick={this.props.closeModal}>
   ...Contents inside...
</button>

Now, the modal can open and close!

The Modal renders at the end of the body. And it does not render until the trigger button is clicked!

On closing, modal is removed from the DOM.

Perfect!

But, we aren’t done yet. A click outside modal doesn’t close it, neither does the Escape key.

The accessibility of the Modal

Give aside tag an attribute of aria-modal=”true”, this indicates this is a modal.

Also, give it a tabIndex of -1. You can traverse through an element of the page with Tab, tabIndex gives power to custom order the elements to focus. The value -1 takes it out of focus, which means it can’t be focused with Tab. Clicking the trigger button is the only way to open the modal.

Give it a role of dialog as it a dialog and independent on everything else on the page.

For the close button, give it an aria-label of Close Modal and an id of close-modal. This id can be referred by aria-labelledby in the parent close button. aria-label should not be repeated in a page (they’re unique, just like id, in a HTML document.)

<button
            aria-label="Close Modal"
            aria-labelledby="close-modal"
            className="_modal-close"
            onClick={this.props.closeModal}
          >
            <span id="close-modal" className="_hide-visual">
              Close
            </span>

With aria-label, it follows the web accessibility guidelines.

Passing the content to the Modal

Currently, modal has a fixed content. Being able to pass content to the ModalContent makes it reusable.

In App*,* set modalContent as a JSX value, and pass it to Modal*.*

<ModalmodalProps={this.modalProps}modalContent={this.modalContent}/>

Now, Modal receives the content, pass it to ModalContent as content.

{this.state.isShown ? (
          <ModalContent
            closeModal={this.closeModal}
            content={this.props.modalContent}
          />
        ) : (
          <React.Fragment />
        )}

In ModalContent, replace the generic text in a body by this code.

<div className="modal-body">{this.props.content}</div>

It’ll display whatever you pass as modalContent from App.

class App extends Component {
  modalProps = {
    triggerText: 'Launch the Modal!'
  };
  modalContent = (
    <div>
      <p>Hello From passed modalContent from App!</p>
    </div>
  );
...

The above output illustrates this change!

Event Listeners to Close the Modal

Add some event listeners to improve the user experience with the modal. Pressing escape key or clicking outside the modal area should exit the modal.

With Escape Key

Set onKeyDown = {this.props.keyDown} event listener to the aside component in ModalContent. Pass onKeyDown={this.onKeyDown} to ModalContent from Modal.

<ModalContent
            closeModal={this.closeModal}
            content={this.props.modalContent}
            onKeyDown={this.onKeyDown}
          />

Also, define keyDown in Modal, it just closes the Modal. keyCode for Escape key is 27.

onKeyDown = event => {
    if (event.keyCode === 27) {
      this.closeModal();
    }
  };

Activating the Modal doesn’t give focus to the aside component. It gets focus if you click anywhere in the Modal. And Escape key does call closeModal thereafter. We’ll fix this in a while, continue with the mouse event listener for now!

With a click outside the Modal area

Technically, I’ve used 100% of the width and height for the modal. But the aside component is only the container, the actual modal is *

*

Modal closes on clicking outside this modal-area div are the expected result.

Clicking on modal cover outside the modal-area will close the modal. Use ref in React to refer to the children and detect modal-area.

In the Modal component, pass a reference to ModalContent as a ***modelRef*prop. The *ref*** can take a callback function.

Also, create a onClickOutside function and pass to the ModalContent. The reference modalRef is passed as modalRef={n=>(this.modal=n)}.

Add the ref = this.props.modalRef to modal-area, and onClick = {this.props.onClickOutside} to modal-cover.

The onClickOutside method is as follows.

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

onClickOutside checks if the click is within the modal-area. It does nothing in that case. The closeModal method is called otherwise.

Focus Lock on Modal Component

Focus the Modal on its appearance. This is also the reason why escape key works only after clicking in the modal once.

The close button in the Modal requires a reference for this, pass buttonReffrom Modal to ModalContent.

buttonRef={n => this.closeButton = n}

Add this reference to the close button as

<button
            ref={this.props.buttonRef}
            aria-label="Close Modal"
            aria-labelledby="close-modal"
            className="_modal-close"
            onClick={this.props.closeModal}
          >

The setState can optionally take a callback method, use this to focus on the modal close button.

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

It immediately focuses on the close button after opening the modal, Escapes key works as expected. Return key will also do the same until user focuses on other parts within the Modal.

Once closed, the focus in not back to the trigger button.

Getting focus back to trigger button

Pass the buttonRef to the TriggerModal component as well.

<ModalTrigger
          showModal={this.showModal}
           buttonRef={n => this.TriggerButton = n}
          triggerText={this.props.modalProps.triggerText}
        />

In the ModalTrigger, set this ref to the button.

 <button
        ref={this.props.buttonRef}
        onClick={this.props.showModal}
        className="modal-button"
      >

As in the openModal, add the focus to closeModal.

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

Focus the trigger button automatically after closing the modal. This is why it’s not in the callback!

Focus locking

Modal does not have the focus locked in it. This is for people that traverse the modal with a keyboard, TAB.

Get all focus-able elements from modal. And loop through them to trap the focus.

The better way is to use a *npm * library that serves this particular case. It doesn’t have more features to bloatware our lightweight react modal.

npm i focus-trap-react

and import

import FocusTrap from 'focus-trap-react'

Wrap the aside container in ModalContent with FocusTrap.

It’ll lock the focus to the modal and won’t allow to focus elements out of the modal with TAB key while modal is active.

<FocusTrap>
     <aside>
   .........
    </aside>
</FocusTrap>

Fix the Page Scroll

The modal works as expected. But content in the page can scroll even when the modal is shown.

Set a method scrollLock that toggles the overflow. Call this method from closeModal as well as openModal.

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

What does the scroll-lock class do?

It hides the overflowing content and thus able to scroll.

.scroll-lock {
     overflow:hidden;
}

From showModal

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

From closeModal

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

There you have a perfectly valid and accessible react modal. Pass any html-like JSX as *modelContent from App.js, you’ll get the desired output*.

If you want to become a React master, check out Mosh’s React course. It’s the best React course out there!

Originally published on https://programmingwithmosh.com

Angular 9 Tutorial: Learn to Build a CRUD Angular App Quickly

What's new in Bootstrap 5 and when Bootstrap 5 release date?

Brave, Chrome, Firefox, Opera or Edge: Which is Better and Faster?

How to Build Progressive Web Apps (PWA) using Angular 9

What is new features in Javascript ES2020 ECMAScript 2020

ReactJS vs Angular vs Vue: Best Javascript Framework For Your Project

ReactJS vs Angular vs Vue: Best Javascript Framework For Your Project. This video covers the key differences between ReactJS, Angular and Vue with respect to the following: Use case, Performance, Data binding, Scripting language, Testing, Community support, Growth curve

What is JavaScript – All You Need To Know About JavaScript

In this article on what is JavaScript, we will learn the basic concepts of JavaScript.

JavaScript Tutorial: if-else Statement in JavaScript

This JavaScript tutorial is a step by step guide on JavaScript If Else Statements. Learn how to use If Else in javascript and also JavaScript If Else Statements. if-else Statement in JavaScript. JavaScript's conditional statements: if; if-else; nested-if; if-else-if. These statements allow you to control the flow of your program's execution based upon conditions known only during run time.