Modals in React Part 2: Accessibility

March 28th, 2021

Welcome back to Modals in React!

This is a two part tutorial to create a reusable, accessible React modal component.

Part 1: Contains the starting code for this article. Focuses on creating a Portal to house our Modal, and making it functional and reusable with props.

Part 2: This article, focuses on adding appropriate ARIA and controlling focus between the modal and the main page to make our component accessible (awesome).

This article assumes a basic familiarity with React, hooks and the concept of state.

We will:

  1. Add appropriate ARIA

  2. Close the Modal with the esc key, or by clicking outside the Modal window

  3. Freeze scrolling on the main page beneath the Modal

  4. Shift focus to the Modal buttons when it opens

  5. Trap focus inside the Modal while it's open

  6. Return focus to its former position when the Modal closes

ARIA

ARIA is a set of HTML attributes that improve accessibility for assistive technology users. It bridges the gap between native HTML elements and screen readers. Screen readers can understand our semantic HTML, like headings and paragraphs. Without a <modal> element, our component may appear to be a jumble of divs. ARIA help assistive technology understand complex components like this.

Following best practices outlined by WAI-ARIA

- The element that serves as the dialog container has a role of dialog

- All elements required to operate the dialog are descendants of the element that has role dialog

- The dialog container element has aria-modal set to true

- The element has either: a value set for the aria-labelledby property that refers to a visible dialog title or a label specified by aria-label

Let's implement this. The outer div (dark background) of our Modal component needs a role of dialog, and aria-modal set true. We also need to specify a good descriptive label for our component. The <h3> element which contains our title is a good choice. Set an id on the h3 and reference it with labelledby on the outer div.

Modal.js
return ReactDOM.createPortal(
    <div
      className='modal-bg'
      // add ARIA
      role='dialog'
      aria-modal='true'
      aria-labelledby='modal1-label'
    >
      <div className='modal-window'>
        <h3 
          // add id corresponding to aria-labelledby
          id='modal1-label'
          className='modal-title'
        >
          {props.title}
        </h3>
JavaScript

Great! Now screen readers can interpret our component as a Modal element.

Close the Modal

1. Clicking outside the Modal window will close it

We want the Modal to close when we click on the overlay - outside the dialog window. Let's try this with an event listener on the outer div. First, we need to add the setOpen method to the props we pass to the Modal. Next, add an onClick handler to set open to false.

EventCard.js
{open && (
          <Modal
            title={modalTitle}
            description={modalDescription}
            actions={modalActions()}
            // add props
            setOpen={setOpen}
            open={open}
          />
        )}
JavaScript
Modal.js
<div
      className='modal-bg'
      role='dialog'
      aria-modal='true'
      aria-labelledby='modal1-label'
      // add onClick handler
      onClick={()=> {props.setOpen(false)}}
    >
JavaScript

Let's test this in the browser. Start up your app with npm start if it's not already running. Try opening the Modal and then clicking on the overlay. It closes, right? Try opening the modal again, this time click on the white dialog window. This will also close the Modal, but that's not the behavior we want. The user needs to interact with the Modal content, including being able to click on it. Let's prevent this behavior from spilling into the dialog window with stopPropagation, which takes the event object.

Modal.js
   <div
      className='modal-bg'
      role='dialog'
      aria-modal='true'
      aria-labelledby='modal1-label'
      onClick={()=> {props.setOpen(false)}}
    >
      <div 
        // add onClick handler to stop Modal closing
        onClick={(event) => event.stopPropagation()}
        className='modal-window'
        >
JavaScript

2. Pressing the esc key will close the Modal

Let's add an event listener to the window for the 'keydown' event. On that event, we'll call a method which will close the Modal if the key === 'Escape'. The event listener should be removed when the Modal closes. We'll utilize the useEffect hook to set the listener when the component appears and remove it when the Modal closes.

If you're unfamiliar with keyboard events, try console.log(event) in the closeOnEscape method below to see the event object.

Modal.js
// import useEffect
import React, { useEffect } from 'react'
import ReactDOM from 'react-dom'

const Modal = (props) => {
  // add method to detect Escape key and close Modal
const closeOnEscape = (event) => {
    if (event.key === 'Escape') {
      props.setOpen(false)
    }
  }
  // add useEffect hook to add an event listener when component mounts
  useEffect(() => {
    window.addEventListener('keydown', closeOnEscape)

// return a function to remove the event listener when the Modal closes
    return () => {
      window.removeEventListener('keydown', closeOnEscape)
    }
  })

JavaScript

Tiny Refactor

Okay, I want to take a moment to clean up our logic a bit to prepare for the next steps.

We're about to add more code around opening and closing the Modal, like scroll locking. Let's move all our Modal opening code into one method, and the closing logic into another. This will help us stay organized and make it easier to understand.

First, let's create two methods: openModal and closeModal.

Move setOpen(true) into openModal, and replace the setOpen(true) everywhere it appears with the openModal function.

Do the same with setOpen(false) and closeModal.

Modal.js

if (event.key === 'Escape') {
  // change to closeModal
      props.closeModal()
    }
  }

  useEffect(() => {
    window.addEventListener('keydown', closeOnEscape)

    return () => {
      window.removeEventListener('keydown', closeOnEscape)
    }
  })

  return ReactDOM.createPortal(
    <div
      className='modal-bg'
      role='dialog'
      aria-modal='true'
      aria-labelledby='modal1-label'
      onClick={() => {
      // change to closeModal
        props.closeModal()
      }}
    >
JavaScript
EventCard.js

  // method to handle all opening Modal logic
  const openModal = () => {
    setOpen(true)
  }

  // method to handle all closing Modal logic
  const closeModal = () =>{
    setOpen(false)
  }

  const modalActions = () => {
    return (
      <>
        <button
          className='negative-button'
          onClick={() => {
            updateEvents(event.id)
            // change to closeModal method 
            closeModal() 
          }}
        >
          Delete
        </button>
        <button
          // change to closeModal method 
          onClick={() => { closeModal()}}
        >
          Cancel
        </button>
      </>
    )
  }

  return (
    <>
      <div className='card'>
        <div>
          <h4> {event.title} </h4>
          <p>{event.description}</p>
        </div>
            // change to openModal method 
        <button onClick={() => openModal()}>Delete</button>
        {open && (
          <Modal
          // delete open and setOpen props, replace with closeModal
            closeModal={closeModal}
JavaScript

Okay, now we're ready to add more functionality!

Freeze Scrolling

When our Modal is open, we want to stop any interaction with the main page underneath it. So, the user should not be able to scroll down the main page and check out more content while the Modal is open. Some sites implement this behavior to stop users who aren't logged in from accessing paid content. To do this, we will override the default browser behavior and disable scrolling. This can be accomplished by toggling a CSS class with overflow: hidden. We'll also add the important tag, just in case some conflicting CSS emerges. We want this class to take priority.

First, add the CSS class to our App.css file. Then, in our EventCard component, create a method: setScrollLock to toggle the class. When the Modal opens, set open true and toggle a class on our HTML. Finally, we'll call this method in our openModal and closeModal methods. When the Modal opens, the main page should freeze. When it closes, scrolling should be restored.

App.css
.scroll-lock {
  overflow: hidden !important;
}
CSS
EventCard.js

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

  const openModal = () => {
    setOpen(true)
    setScrollLock()
  }

  const closeModal = () =>{
    setOpen(false)
    setScrollLock()
  }

JavaScript

Focus on the Button

When the Modal opens, the focus should immediately shift from the main page to the action inside the Modal window. We're going to force focus onto our action buttons. In order to do this, we'll need a way to directly target the button to focus on when the Modal opens. We'll use a combination of the useRef and useEffect hooks to achieve this. useRef allows use to target a specific element easily in our JSX. The useEffect hook will utilize the reference to direct focus when the component mounts.

First, in the EventCard component, we'll set the tabIndex='-1' on our Modal. You can tab through clickable content on a webpage. Tabindex is used to set the order for how to tab through the page. By setting this to a negative value, we're taking our element out of that sequential order. This is useful when you want to manually control focus on the page, which is what we want to do.

Next, import { useRef } from react in the EventCard component. Then, create the shiftFocusRef. We'll set ref={shiftFocusRef} on the Cancel button in the modalActions method - which defines the buttons in the Modal. The purpose of our current Modal is to prevent a user from accidentally deleting an item. So, to further this goal, we set focus on the Cancel button, not the Delete button. Finally, pass the shiftFocusRef as a prop to the Modal, so we can utilize the focus method.

EventCard.js
// add useRef to import
import React, { useState, useRef } from 'react'
import Modal from './Modal'

const EventCard = ({ event, updateEvents }) => {
  // set shiftFocusRef to useRef
  const shiftFocusRef = useRef(null)
  ...
    const modalActions = () => {
    return (
      <>
        <button
          className='negative-button'
          onClick={() => {
            updateEvents(event.id)
            closeModal()
          }}
        >
          Delete
        </button>
        <button
          ref={shiftFocusRef}
          onClick={() => {
            closeModal()
          }}
        >
          Cancel
        </button>
      </>
    )
  }
    ...
    {open && (
          <Modal
            // set tabIndex and pass shiftFocusRef as a prop
            tabIndex='-1'
            shiftFocusRef={shiftFocusRef}
            closeModal={closeModal}
            title={modalTitle}
            description={modalDescription}
            actions={modalActions()}
          />
        )}
        ...
JavaScript

In the Modal component's useEffect hook set .focus() on the element currently being referenced by focusButton. Now, the Cancel button is focused when the Modal opens.

Modal.js
useEffect(() => {
    window.addEventListener('keydown', closeOnEscape)

    // add focus method to current focusButton
    props.shiftFocusRef.current.focus()

    return () => {
      window.removeEventListener('keydown', closeOnEscape)
    }
  })
JavaScript

Returning Focus

We're going to do something remarkably similar to return the focus to its previous location when the Modal closes. We'll define a ref, assign it to the button which triggers the Modal, and call .focus() on that element in our closeModal method. Ready?

In EventCard create the returnFocusRef. In the return statement, add ref={returnFocusRef} to the button. In the closeModal method all returnFocusRef.current.focus()

EventCard.js
  // add the returnFocusRef
const EventCard = ({ event, updateEvents }) => {
  const returnFocusRef = useRef(null)
  const shiftFocusRef = useRef(null)
  ...
  const closeModal = () => {
    setOpen(false)
    setScrollLock()
    // add the focus method
    returnFocusRef.current.focus()
  }
  ...
  return (
    <>
      <div className='card'>
        <div>
          <h4> {event.title} </h4>
          <p>{event.description}</p>
        </div>
        // add the ref
        <button ref={returnFocusRef} onClick={() => openModal()}>
          Delete
        </button>

JavaScript

Trapping Focus

The Modal should trap focus, and prevent the user from doing anything on the page until it's closed. Right now, if we open the Modal and tab through the page, you'll notice we can tab through not only the Modal window buttons, but all the buttons on the main page as well. We need to prevent this behavior. We'll create a list of tabbable elements inside the Modal, and prevent the user from tabbing out of it.

First, we'll create a list of tabbable elements that exist in our Modal window. We want this list to be created when the Modal appears, because the content will differ depending on what actions are being passed as props into the component. The actions are rendered in the 'modal-actions' div. First, create a ref for that div, and utilize it in the return statement. Then, in our Modal's useEffect hook, we'll add a querySelectorAll to the .current and pass it a list of tabbable elements to find.

Modal.js

const Modal = (props) => {
  // create a ref for the modal-actions div
  const tabItemRef = useRef(null)

  useEffect(() => {
    window.addEventListener('keydown', keyEvent)
    props.shiftFocusRef.current.focus()

    // use querySelectorAll to find all tabbable elements 
    // in the modal-actions div using the ref
    const tabList = tabItemRef.current.querySelectorAll(
      'a, button, textarea, input, select'
    )

    ...

    // set the tabItemRef on the modal-actions div in the return statement
    <div ref={tabItemRef} className='modal-actions'>
          {props.actions}
        </div>

JavaScript

We're using our useEffect hook because we only want this logic to run once - when the modal renders to the DOM. However, by creating the variable tabList in the useEffect hook, we lose the ability to reference it elsewhere in our component. The querySelector method will return a node list. I think an array would be easier to work with, because it gives us access to methods like indexOf. Initiate a variable outside the useEffect hook. Within useEffect, spread the tabList values into the tabArray. Now, we can reference tabArray throughout the file and make use of array methods.

Modal.js

const Modal = (props) => {
  const tabItemRef = useRef(null)
  // initiate tabArray
  let tabArray

  useEffect(() => {
    window.addEventListener('keydown', keyEvent)
    props.shiftFocusRef.current.focus()

    const tabList = tabItemRef.current.querySelectorAll(
      'a, button, textarea, input, select'
    )

    //set it equal to the tabList in array form
    // eslint-disable-next-line react-hooks/exhaustive-deps
    tabArray = [...tabList]
JavaScript

If you leave out the // eslint-disable-next-line react-hooks/exhaustive-deps you might notice that React yells at you that changes to tabArray will be lost with each render. This is the behavior we want, so we disable the warning. Each time it renders, we want a new list of the actions defined in that version of the Modal component.

Now, we need to create a function to detect when the user has reached the end of the tabArray, and reroute focus to the next element within the Modal. In our keyEvent function, we'll add an if statement to detect the tab key. Create a method handleTabKey, and pass it the event object.

Modal.js

const keyEvent = (event) => {
    if (event.key === 'Escape') {
      props.closeModal()
    }
    if (event.key === 'Tab') {
      handleTabKey(event)
    }
  }
JavaScript

handleTabKey needs to know when we get to the end of tabArray, and reroute the focus. Find the index of the active element, and set it to a variable. On tab, check if that index is the last item. If so, send the focus to the item at index 0. Users can also use shift+tab to navigate in reverse order. So, if the user is at index 0 and presses shift+tab, they should be routed to the last item.

Modal.js

const handleTabKey = (event) => {
    const activeIndex = tabArray.indexOf(document.activeElement)
    const tabLength = tabArray.length - 1

    if (!event.shiftKey && activeIndex === tabLength) {
      tabArray[0].focus()
      return event.preventDefault()
    }
    if (event.shiftKey && activeIndex === 0) {
      tabArray[tabLength].focus()
      return event.preventDefault()
    }
  }
JavaScript

Our current implementation only has two buttons, but try adding some more tabbable elements to the Modal actions. See how the user can tab through them without escaping the Modal window?

Finished!

Here's the finished code. I added a little fadein/fadeout animation. It's the same as the vanilla JavaScript modal animation we implemented in a previous article. I hope this has been helpful. As always, feel free to get in touch here or on twitter.

Happy coding!

Finished files:

Modal.js
import React, { useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'

const Modal = (props) => {
  const tabItemRef = useRef(null)
  let tabArray

  useEffect(() => {
    window.addEventListener('keydown', keyEvent)
    props.shiftFocusRef.current.focus()

    const tabList = tabItemRef.current.querySelectorAll(
      'a, button, textarea, input, select'
    )

    // eslint-disable-next-line react-hooks/exhaustive-deps
    tabArray = [...tabList]

    return () => {
      window.removeEventListener('keydown', keyEvent)
    }
  })

  const keyEvent = (event) => {
    if (event.key === 'Escape') {
      props.closeModal()
    }
    if (event.key === 'Tab') {
      handleTabKey(event)
    }
  }

  const handleTabKey = (event) => {
    const activeIndex = tabArray.indexOf(document.activeElement)
    const tabLength = tabArray.length - 1

    if (!event.shiftKey && activeIndex === tabLength) {
      tabArray[0].focus()
      return event.preventDefault()
    }
    if (event.shiftKey && activeIndex === 0) {
      tabArray[tabLength].focus()
      return event.preventDefault()
    }
  }

  return ReactDOM.createPortal(
    <div
      className='modal-bg'
      role='dialog'
      aria-modal='true'
      aria-labelledby='modal1-label'
      onClick={() => {
        props.closeModal()
      }}
      style={{ animation: `${!props.fadeout ? 'fadein' : 'fadeout'} 1s` }}
    >
      <div
        onClick={(event) => event.stopPropagation()}
        className='modal-window'
      >
        <h3 id='modal1-label' className='modal-title'>
          {props.title}
        </h3>
        <p className='modal-description'>{props.description}</p>
        <div ref={tabItemRef} className='modal-actions'>
          {props.actions}
        </div>
      </div>
    </div>,
    document.querySelector('#modal')
  )
}

export default Modal
JavaScript
EventCard.js
import React, { useState, useRef } from 'react'
import Modal from './Modal'

const EventCard = ({ event, updateEvents }) => {
  const returnFocusRef = useRef(null)
  const shiftFocusRef = useRef(null)
  const [open, setOpen] = useState(false)
  const [fadeout, setFadeout] = useState(false)
  const setScrollLock = () => {
    document.querySelector('html').classList.toggle('scroll-lock')
  }

  const openModal = () => {
    setOpen(true)
    setScrollLock()
  }

  const closeModal = () => {
    setFadeout(true)
    setTimeout(() => {
      setFadeout(false)
      setOpen(false)
      setScrollLock()
      returnFocusRef.current.focus()
    }, 200)
  }

  const modalTitle = 'Delete Event'
  const modalDescription = `Are you sure you want to delete "${event.title}"?`

  const modalActions = () => {
    return (
      <>
        <button
          className='negative-button'
          onClick={() => {
            updateEvents(event.id)
            closeModal()
          }}
        >
          Delete
        </button>
        <button
          ref={shiftFocusRef}
          onClick={() => {
            closeModal()
          }}
        >
          Cancel
        </button>
      </>
    )
  }

  return (
    <>
      <div className='card'>
        <div>
          <h4> {event.title} </h4>
          <p>{event.description}</p>
        </div>
        <button id={event.id} ref={returnFocusRef} onClick={() => openModal()}>
          Delete
        </button>
        {open && (
          <Modal
            fadeout={fadeout}
            eventId={event.id}
            tabIndex='-1'
            shiftFocusRef={shiftFocusRef}
            closeModal={closeModal}
            title={modalTitle}
            description={modalDescription}
            actions={modalActions()}
          />
        )}
      </div>
    </>
  )
}

export default EventCard
JavaScript
DisplayEvent.js
import React, { useState } from 'react'
import { events } from './events'
import EventCard from './EventCard'

function DisplayEvents() {

  const [eventList, setEventList] = useState(events)
  const updateEvents = (id) => {
    const newList = eventList.filter((event) => {
      return event.id !== id
    })
    setEventList(newList)
  }

  const renderEvents = () => {
    return eventList.map((event) => {
      return <EventCard event={event} key={event.id} updateEvents={updateEvents} />
    })
  }
  return <div>{renderEvents()}</div>
}

export default DisplayEvents

JavaScript
App.css
.App {
  max-width: 600px;
  margin: 20px auto;
}

button {
  border-radius: 3px;
  padding: 7px;
  border: solid 1px #BBBFC3;
  cursor: pointer;
  margin: 0 7px;
}

.negative-button {
  background-color: #ff6961;
}

.card {
  max-width: 375px;
  box-shadow: 2px 2px 2px 1px rgba(208,213,217, .2);
  padding: 10px;
  margin: 5px auto;
  border: solid 1px #BBBFC3;
  border-radius: 3px;
}

/* Modal Styles */

.modal-bg {
  background-color: hsla(170, 5%, 15%, 0.8);
  position: fixed;
  top: 0;
  left:0;
  right: 0;
  bottom: 0;
}

.modal-window {
  width: 375px;
  height: 200px;
  border-radius: 5px;
  background-color: #fff;
  margin: 100px auto;
  padding: 20px;
}

@media screen and (max-width: 500px) {
  .modal-window {
    width: 250px;
    height: 250px;
  }
}

.modal-title {
  text-align: center;
}

.modal-actions {
  padding: 10px;
  text-align: center;
}

.scroll-lock {
  overflow: hidden !important;
}

@keyframes fadein {
	from {
		opacity:0;
	}
	to {
		opacity:1;
	}
}

@keyframes fadeout {
	from {
		opacity:1;
	}
	to {
		opacity:0;
	}
}

.fade-in{
  animation:fadein 1s;
}

.fade-out{
  animation:fadeout .5s;
}
CSS
App.js
import './App.css';
import DisplayEvents from './DisplayEvents'

function App() {
  return (
    <div className="App">
      <DisplayEvents/>        
    </div>
  );
}

export default App;
JavaScript

© 2026 Rebecca Page