How It's Made

Making an accessible web modal

While a lot of customers tell us they use Instacart to add valuable time back into their day, a community of users with mobility issues or visual impairments tell us they rely on our app as a dependable way to get the groceries they need. The Customer Engineering Team at Instacart is tasked with building a seamless in-app experience for every customer — so we take in-app accessibility for these customers really seriously.

One of the largest accessibility challenges we face is building an elegant (and usable) web modal. For standard situations, we use React-Modal which provides accessibility support right out of the box. Problem solved. Unfortunately, it’s not that simple. Some design and functional situations call for custom modals. This custom work requires that we tackle accessibility issues head-on. Following these best-practices helps us ensure that our modals provide the high-level of accessibility customers need and deserve:

  • Ensure there is a close button available early in the modal DOM
  • Maintain a focus trap and close when ESC is pressed
  • Return focus to original element on close
  • Apply correct roles and labels
  • Include appropriate headings
  • Test using a screen reader

Leaving a standard modal can be very difficult and frustrating for keyboard users when there is no obvious exit mechanism. The modal should provide a close button for exiting easily. The button should be early in the content so the user does not need to tab through numerous elements to exit.

To optimize the keyboard user experience, it is preferable to focus on the close button when opening the modal.

In some cases, adding a close button within the modal or at the beginning of the content conflicts with ideal product design. One useful alternative is to create a hidden button that appears on focus.

The button appears when tabbed into

The button is not shown when focus leaves

We build an invisible button by setting the position off-screen and moving it back on-screen when it receives the focus. This provides keyboard users with a simple way to exit when they reach the element. The core design objectives are preserved while we simultaneously provide an excellent accessibility experience!

CloseButton.propTypes = {
hideRetailerChooser: PropTypes.func.isRequired,
}
function CloseButton({ hideRetailerChooser }) {
return (
<button aria-label=close store chooser onClick={hideRetailerChooser} style={styles.button}>
<SVGIcon name=x />
</button>
)
}
const buttonStyles = {
top: -9999,
left: -9999,
border: 0,
background: none,
position: absolute,
Accessibility Button Example Code

The focus trap is another key accessibility feature for modals. The trap ensures that focus cannot leave the modal until it is explicitly closed. Tabbing with the modal open will cycle through all interactive elements in the modal and finally back to the first element once the end is reached.

In the words of Admiral Ackbar “it’s a trap”, which ensures the user stays within the relevant content.

To achieve this, focus must be explicitly moved back to the first element in the modal if the user tabs past the last element. Similarly, focus must move to the last element when back-tabbing past the first element. In React this can be achieved by handling the onKeyDown event.

import React, { createRef, PureComponent } from 'react'
import PropTypes from 'prop-types'

function getFocusableElements(container) {
  return container.querySelectorAll(
    // https://gomakethings.com/how-to-get-the-first-and-last-focusable-elements-in-the-dom/
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  )
}

function nextFocusableElement(container, backward) {
  const focusable = getFocusableElements(container)
  const first = focusable[0]
  const last = focusable[focusable.length - 1]
  if (backward && document.activeElement === first) return last
  if (!backward && document.activeElement === last) return first
}

class FocusKeeper extends PureComponent {
  static propTypes = {
    children: PropTypes.node,
    contentLabel: PropTypes.string.isRequired,
    disabled: PropTypes.bool,
    onEscape: PropTypes.func.isRequired,
  }

  static defaultProps = {
    disabled: false,
  }

  wrapper = createRef()

  componentDidMount() {
    if (!this.props.disabled) {
      this.wrapper.current.focus()
    }
  }

  handleKeyDown = event => {
    switch (event.key) {
      case 'Escape': {
        this.props.onEscape()
        return
      }
      case 'Tab': {
        this.handleTab(event)
      }
    }
  }

  handleTab = event => {
    const backward = event.shiftKey
    const nextFocus = nextFocusableElement(this.wrapper.current, backward)
    if (nextFocus) {
      event.preventDefault()
      nextFocus.focus()
    }
  }

  render() {
    if (this.props.disabled) {
      return this.props.children
    }
    return (
      <div
        onKeyDown={this.handleKeyDown}
        ref={this.wrapper}
        tabIndex={-1}
        aria-label={this.props.contentLabel}
        role="dialog"
        data-testid="wrapper"
      >
        {this.props.children}
      </div>
    )
  }
}

export default FocusKeeper

This example works by finding the interactive elements through a querySelectorAll and comparing document.activeElementagainst the first or last interactive element to check if you need to force focus into the trap.

import React, { Component } from "react";

import ReactDOM from "react-dom";

import CustomModal from "./CustomModal.jsx";

import "./styles.css";

class App extends Component {

state = {

modalOpen: false

};

toggleModal = () => {

this.setState(({ modalOpen }) => {

return { modalOpen: !modalOpen };

});

};

render() {

return (

<div className="App">

<div className={`container ${this.state.modalOpen && "modal-open"}`}>

<h1>Main Page Content</h1>

<h2>Section of Content</h2>

<p>

Interesting information that you may want to learn more by opening a

modal.

</p>

<button onClick={this.toggleModal}>Learn More </button>

</div>

{this.state.modalOpen && (

<CustomModal

modalOpen={this.state.modalOpen}

toggleModal={this.toggleModal}

title="Interactive Modal"

>

<div className="modal-content">

A bunch of non-interactive and interactive content in the modal.

The trap will allow the user to tab forward and back-tab while

remaining on the page.

</div>

<a className="modal-link" href="#">

Read More Link

</a>

<form className="modal-form" action="">

<label for="firstname">First name:</label>

<input

type="text"

id="firstname"

name="first_name"

value="Admiral"

/>

<label for="lastname">Last name:</label>

<input type="text" id="lastname" name="last_name" value="Akbar" />

</form>

<div style={{ margin: 5 }}>

<button onClick={this.toggleModal}>Submit</button>

<button onClick={this.toggleModal}>Close</button>

</div>

</CustomModal>

)}

</div>

);

}

}

const rootElement = document.getElementById("root");

ReactDOM.render(<App />, rootElement);

Example of Focus Trap in Action

While the focus trap optimizes the user experience when the modal is open; it is also critical to maintain a seamless flow when the modal closes. Returning focus to the original element that opened the modal ensures that keyboard and assisted technology users do not lose their location. Without return focus control; users are forced to relocate themselves within the page after close. This makes modals a jarring, unwelcome navigation experience. Not good.

We achieve return focus control by storing the active element that opens the modal in state during component mount. When the component unmounts we re-focus on the stored element. This simple logic ensures that modals are a natural flow experience for our users.

componentDidMount() {
  this.setState({ returnElement: document.activeElement })
}componentWillUnmount() {
  this.state.returnElement.focus()
}

The preceding best-practices are focused on controlling active navigation of modals. It is also important to handle various declarative attributes of modals deliberately when optimizing apps for accessibility.

Modals must have correct roles and labels set so that accessibility software presents them properly to the user. These values need to be placed at the appropriate level and not repeated. Here are several critical attributes:

  • role: dialog — Identifies the container of the modal element for screen readers.
  • aria-modal: true —Lets screen readers know this content is separate from the rest of the page content.
  • tabindex="0" — Allows for programmatically focusing on the dialog
  • Appropriate aria-label or aria-labelledby — Identifies the content of the modal

W3C provides detailed explanations of these attributes and how they improve accessibility: https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html

In addition to roles and labels; modals need appropriate headings to identify their content since they are intentionally separate from the underlying view. The heading should be at the beginning of the content and included in each subsection as appropriate.

Sometimes existing design or technical constraints may discourage new headings for modals. While this might be fine for standard application rendering — it leaves users of accessibility features with a degraded experience. Fortunately, we have a best-practice for this situation: a non-visual heading.

Screen Reader Only Header — Provides useful information for assistive technologies

Screen reader only headings can be achieved by using the following styles.

{ position: ‘absolute’, clip: ‘rect(1px, 1px, 1px, 1px)’, overflow: ‘hidden !important’, }

The magic is clipping the header position and hiding the overflow. This renders the element invisible in standard application views but available to screen-readers. Win-win.

To ensure the best experience for assisted technology users; empathy is a must. There are few better ways to understand the struggles of users than stepping into their shoes — whether it’s for a block, a mile, or sometimes a marathon.The final step in accessibility development is thoroughly testing the full experience using a screen reader (VoiceOver, NVDA, JAWS, etc…). You will certainly appreciate the easy close button, the sane modal trap, and clear heading information you built into your app. We did with ours. Maybe you’ll even notice other accessibility challenges beyond these best-practices and add new cutting-edge solutions. Best of all — you will have optimized the experience for a critical segment of your users and hit another milestone in providing a seamless in-app experience for every customer 😄.

Want to build an even more accessible app with me and Instacart’s Customer Engineering team? Instacart is hiring! Check out our current engineering openings.

Thanks to Dan Loman.

Logan Murdock

Author

Logan Murdock is a member of the Instacart team. To read more of Logan Murdock's posts, you can browse the company blog or search by keyword using the search bar at the top of the page.

Most Recent in How It's Made

One Model to Serve Them All: How Instacart deployed a single Deep Learning pCTR model for multiple surfaces with improved operations and performance along the way

How It's Made

One Model to Serve Them All: How Instacart deployed a single Deep Learning pCTR model for multiple surfaces with improved operations and performance along the way

Authors: Cheng Jia, Peng Qi, Joseph Haraldson, Adway Dhillon, Qiao Jiang, Sharath Rao Introduction Instacart Ads and Ranking Models At Instacart Ads, our focus lies in delivering the utmost relevance in advertisements to our customers, facilitating novel product discovery and enhancing…...

Dec 19, 2023
Monte Carlo, Puppetry and Laughter: The Unexpected Joys of Prompt Engineering

How It's Made

Monte Carlo, Puppetry and Laughter: The Unexpected Joys of Prompt Engineering

Author: Ben Bader The universe of the current Large Language Models (LLMs) engineering is electrifying, to say the least. The industry has been on fire with change since the launch of ChatGPT in November of…...

Dec 19, 2023
Unveiling the Core of Instacart’s Griffin 2.0: A Deep Dive into the Machine Learning Training Platform

How It's Made

Unveiling the Core of Instacart’s Griffin 2.0: A Deep Dive into the Machine Learning Training Platform

Authors: Han Li, Sahil Khanna, Jocelyn De La Rosa, Moping Dou, Sharad Gupta, Chenyang Yu and Rajpal Paryani Background About a year ago, we introduced the first version of Griffin, Instacart’s first ML Platform, detailing its development and support for end-to-end ML in…...

Nov 22, 2023