Last week I kick-started a short series on "Building an accessible Modal component with React Portals", based on my experiences in my day-to-day job at Sky and our open source CSS toolkit that I help maintain.

Within the first post we were able to utilise the newer features of ES6 and React such as Class Properties, React Portals and Fragments in order to build a Modal component that opens and closes using its respective buttons, but is only rendered on the page based on that.

This is part two of two in my series on Building an accessible Modal component with React Portals:

Recapping

If you didn't read the previous post then I would urge you to do so with the link above. However, if you're following on from last week then here's what we have so far:

Our current Modal component opens and closes via its respective buttons
Our current Modal component opens and closes via its respective buttons

and here are the requirements we have for our component:

  • ✅ When the Modal is not open, it is not rendered into the DOM.
  • ✅ When rendered, the Modal is appended to the end of document.body.
  • ❌ The Modal has relevant WAI-ARIA attributes in accordance with accessibility guidelines.
  • ❌ Pressing the escape key will close the Modal.
  • ❌ Clicking outside the Modal will close it.
  • ❌ When open, scrolling is frozen on the main document beneath the Modal.
  • ❌ When open, focus is drawn immediately to the Modal's close button.
  • ❌ When the Modal closes, focus returns to the Modal's trigger button.
  • ❌ Focus is trapped within the Modal when open.

As you can see,our component is coming along great and in this post we'll expand upon our work with the accessibility and focus management features essential in ensuring we have a Modal suitable for all users regardless of their circumstance.

WAI-ARIA Attributes

To start, we'll look at introducing the necessary WAI-ARIA attributes to our code so that screen readers and other devices can understand our component.

❌ The Modal has relevant WAI-ARIA attributes in accordance with accessibility guidelines.

If this is your first venture into the WAI-ARIA specification then I think this quote from a Stack Overflow thread sums them up pretty eloquently.

"WAI-ARIA stands for “Web Accessibility Initiative – Accessible Rich Internet Applications”. It is a set of attributes to help enhance the semantics of a website or web application to help assistive technologies, such as screen readers for the blind, make sense of certain things that are not native to HTML."

Faisal Naseer on Stack Overflow

The key takeaway from this is right at the end, "make sense of certain things that are not native to HTML". Until we have a standard Modal HTML element, we need to ensure our fellow screen readers are able to understand our more complex components. Let's get cracking.

aria-modal

Within our ModalContent component, we'll start by adding the aria-modal attribute to .c-modal-cover.

<aside aria-modal="true" className="c-modal-cover">

According to the WAI-ARIA spec, the aria-modal attribute indicates that not only is this component a modal element, but it explains that the user's interaction is limited to the contents of the element. Screen readers should then take this information to determine whether or not to set focus immediately to the element, depending on if focus has already been hijacked.

It's worth mentioning that aria-modal was only introduced into the ARIA 1.1 specification in November 2014 and so not all assistive devices will have support for it. If you require legacy support you can look at using aria-hidden instead.

In legacy dialog implementations where aria-hidden is used to make content outside a dialog inert for assistive technology users, it is important that:

  1. aria-hidden is set to true on each element containing a portion of the inert layer.
  2. The dialog element is not a descendant of any element that has aria-hidden set to true.

W3C's example implementation of the design pattern for modal dialogs.

tabindex

Moving on, let's talk tabindex. Technically not a WAI-ARIA attribute but I think it deserves to be grouped into this section as it's so tightly coupled with accessibility.

As with our aria-modal attribute, this can be added to .c-modal-cover. Just be mindful that tabindex should be referenced as tabIndex in React/JSX. Note the camelcase.

<aside
  className="c-modal-cover"
  aria-modal="true"
  tabIndex="-1"
>

The tabindex attribute allows you to indicate whether or not an element is supposed to be focusable by using tab and shift+tab keys in your browser to traverse the focus order (officially known as the sequential focus navigation), and if so, where it should appear in said order.

By setting our ModalContent to a negative value of tabindex="-1", we tell the browser that we do not want this to be ordered in the "sequential focus navigation" as we will be handling this ourselves using JavaScript.

"Sequential... Focus... what", you may be asking? Put another way, imagine we have a list of all focusable items on the page: maybe some navigation; links in our content area and sidebars and maybe some footer navigation. By setting a negative value of -1 to our tabindex we're taking our element out this list. You could liken it to how we use absolute positioning in CSS to take an element out of the document flow.

ARIA role

ARIA roles are next on the agenda. Similarly to aria-modal, the role attribute provides an element with additional semantic meaning to communicate to browsers their purpose. These days with the addition of HTML5 elements such as <article>, <main> and <nav> it's usually not a requirement to supply a role attribute as they're quite often covered by the HTML5 specification for those elements. However, in cases like a Modal where there isn't a specific HTML tag for this, we need to ensure our browser understands what we're building.

The exact role tag you should use differs depending on your requirements. Both dialog and alertdialog help the browser understand that the component's content is grouped independently and separate from the rest of the page's content, but there are more subtle differences between them.

99% of the time you'll be sticking with dialog. alertdialog should be used when the information you display requires the user's immediate attention. To give an example, you might use dialog when you're creating a popup to compose a new email, similarly to how Gmail looks. Whereas, you might use alertdialog when you're asking the user to confirm they'd like to delete their personal information from your website. That means you too, Zuckerberg.

Worth mentioning there are also differences between alertdialog and alert. alertdialog should only be used when you require the user's immediate attention to interact with something in your Modal. If there's nothing to interact with, the alert role will be more appropriate. Mozilla have a guide on how to use the alert role.

Now that we have the 'why' out of the way we can focus on the 'how'. Add the role attribute to the .c-modal-cover element within ModalContent.

<aside
  className="c-modal-cover"
  role={role}
  aria-modal="true"
  tabIndex="-1"
>

Pass role down as a prop, but give it a default value of dialog (thanks to ES6's Default parameters) as that's going to be used most of the time.

const ModalContent = ({
  onClose,
  role = 'dialog'
}) => {

Next, we need to pass that through to our Modal. Within Modal, add the role prop to our consumption of ModalContent.

<ModalContent
  onClose={this.onClose}
  role={role}
/>

This should then be destructured from our props, found above the component's return statement.

const { triggerText, role } = this.props;

Here's how the Modal's full render method should look now.

render() {
  const { isOpen } = this.state;
  const { triggerText, role } = this.props;
  return (
    <Fragment>
      <ModalTrigger
        onOpen={this.onOpen}
        text={triggerText}
      />
      {isOpen &&
        <ModalContent
          onClose={this.onClose}
          role={role}
        />
      }
    </Fragment>
  );
}

This provides the flexibility of overriding the role as and when required, rather than having to reference role='dialog' every time you want to use the Modal in your app.

Now when consuming Modal you should be able to add the role prop which overrides the ModalContent's role attribute for the .c-modal-cover element.

aria-label vs aria-labelledby

With any dialog or alertdialog, you must provide a label to describe what the component is trying to achieve on the page. Over on MDN's "Using the dialog role" article, they describe a label's job similarly to that of a <legend> element and how it helps to group the controls within a <fieldset>.

The label given to the dialog will provide contextual information for the interactive controls inside the dialog. In other words, the dialog's label acts as a grouping label for the controls inside it.

Using the dialog role - MDN

There are two ways in which to label a Modal:

  • aria-label
  • aria-labelledby

When implemented, both work in exactly the same way for the browser, it's the implementations themselves that differ depending on what you're building. I really like how Nicholas C. Zakas explains which to use in his article from 2013 aptly titled 'Making an accessible dialog box'. Here's the example he gives:

// Example from Nicholas C. Zakas' article.
<div id="my-dialog" role="dialog" aria-label="New Message">
    <-- Your dialog code here -->
</div>

<div id="my-dialog" role="dialog" aria-labelledby="dialog-title">
    <h3 id="dialog-title">New Message</h3>
    <-- Your dialog code here -->
</div>

With aria-label you are able to create a completely new description for your dialog element, whereas aria-labelledby is used when you already have a title or some form of label element within your dialog that can be used to avoid duplication. In those cases, aria-labelledby accepts the id of the element you want to use as the label.

If you already have an element within your dialog component that fits this job description, then go with aria-labelledby as you won't have to duplicate content. If that's not the case, then you're better off sticking with aria-label. For this component, we'll be going with aria-label as we cannot guarantee the contents of our component as that's left to the user consuming it.

Back within ModalContent, add the aria-label attribute to .c-modal-cover.

<aside
  className="c-modal-cover"
  role={role}
  aria-label={ariaLabel}
  aria-modal="true"
  tabIndex="-1"
>

We will then expose this as a prop to be used when you consume ModalContent.

const ModalContent = ({
  ariaLabel,
  onClose,
  role = 'dialog'
}) => {

And then reference that prop where it is consumed within the Modal component itself.

render() {
  const { isOpen } = this.state;
  const { ariaLabel, triggerText, role } = this.props;
  return (
    <Fragment>
      <ModalTrigger
        onOpen={this.onOpen}
        text={triggerText}
      />
      {isOpen &&
        <ModalContent
          ariaLabel={ariaLabel}
          onClose={this.onClose}
          role={role}
        />
      }
    </Fragment>
  );
}

I'll then utilise this new prop where I consume <Modal />. As I mentioned before, you could add these props directly to the component as demonstrated below, or you could follow my preference of spreading through an object.

// Option 1:
const modalProps = {
  ariaLabel: 'A label describing the Modal\'s current content',
  triggerText: 'This is a button to trigger the Modal'
};

<Modal {...modalProps} />

// Option 2:
<Modal
  ariaLabel="A label describing the Modal's current content"
  triggerText="This is a button to trigger the Modal"
/>

For the rest of the tutorial I'll continue spreading an object.

Close button label

Going back to the notion of aria-label vs aria-labelledby, a great example of how using aria-labelledby makes sense can be found in our ModalContent's close button.

<button className="c-modal__close" aria-label="Close Modal" onClick={onClose}>
  <span className="u-hide-visually">Close</span>
  <svg className="c-modal__close-icon" viewBox="0 0 40 40"><path d="M 10,10 L 30,30 M 30,10 L 10,30"></path></svg>
</button>

You'll notice in our current markup we've got both a visually hidden span element and a aria-label attribute for our <button>. Both of which are trying to achieve the same thing. Rather than have both, we've got a decision to make:

  • remove the visually hidden span and continue to update the label through aria-label;
  • or update aria-label to be aria-labelledby.

Personally, I prefer the second approach as it keeps the content within HTML tags rather than within attributes but either approach is fine. If you'd like to do the same, add an id to the span.

<span id="close-modal" className="u-hide-visually">Close Modal</span>

and update the aria-label attribute on the <button> to be aria-labelledby instead.

<button className="c-modal__close" aria-labelledby="close-modal" onClick={onClose}>
  <span id="close-modal" className="u-hide-visually">Close Modal</span>
  <svg className="c-modal__close-icon" viewBox="0 0 40 40"><path d="M 10,10 L 30,30 M 30,10 L 10,30"></path></svg>
</button>

Great, we should now have all the WAI-ARIA attributes our component requires. Just to recap we've:

  • added aria-modal to tell the browser that this component is a modal and when rendered, user interaction should be trapped within;
  • updated the component's tabindex to tell the browser that we do not want this to be part of the "sequential focus navigation";
  • setup a default role attribute of dialog with the option for users to override with alertdialog where required;
  • and finally we've added labels to our Modal via aria-label and aria-labelledby.

I think that deserves a tick.

✅ The Modal has relevant WAI-ARIA attributes in accordance with accessibility guidelines.

Adding children content

Now that we've handled the initial accessibility concerns of our component, we need to make the component useful. Currently, in ModalContent we're supplying some dummy content for demo purposes.

<div className="c-modal__body">
  CONTENT WILL GO HERE
</div>

What we really want is for the user to supply a their own content. Wherever you're consuming Modal in your App add some child content for the component to display.

const modalContent = (
  <div>
    <p>Hello world Lorem ipsum dolor sit amet, <a href="#1">first link</a> consectetur adipiscing elit. Phasellus sagittis erat ut ex bibendum consequat. Morbi luctus ex ex, at varius purus <a href="#2">second link</a> vehicula consectetur. Curabitur a sapien a augue consequat rhoncus. Suspendisse commodo ullamcorper nibh quis blandit. Etiam viverra neque quis mauris efficitur, lobortis aliquam ex pharetra. Nam et ante ex. Sed gravida gravida ligula, non blandit nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Integer consectetur efficitur tempor. Nunc sollicitudin felis congue facilisis faucibus. Mauris faucibus sit amet ante eleifend dapibus.</p>
  </div>
);

const modalProps = {
  ariaLabel: 'A label describing the Modal\'s current content',
  triggerText: 'This is a button to trigger the Modal'
};

<Modal {...modalProps}>{modalContent}</Modal>

Again to keep things neat and tidy in JSX I prefer to create a variable to hold my content, but inline works too.

Once you've got some content, we need to display it. Within Modal pass through children from this.props as we've done with the other props.

const { ariaLabel, children, triggerText, role } = this.props;

and pass the children down to a new prop for <ModalContent /> called content.

{isOpen &&
  <ModalContent
    ariaLabel={ariaLabel}
    content={children}
    onClose={this.onClose}
    role={role}
  />
}

Now we can get access to content within our ModalContent component.

const ModalContent = ({
  ariaLabel,
  content,
  onClose,
  role = 'dialog'
}) => {

and update our dummy content with our new content prop.

<div className="c-modal__body">{content}</div>

Give that a whirl and you should see the component come to life with your new content.

The Modal filled with content from props.
The Modal filled with content from props.

Enhanced closing events

Our component's coming along swimmingly. We can open it, close it, provide it with some content to display and it can be understood by a whole manner for devices. However, we've still got some core pieces of functionality to get through.

One such piece is extending the ways we can close our Modal. At the moment that can only be done by clicking the close button in the top right of the component, however, if we check back at our checklist there are two methods we're still missing.

❌ Pressing the escape key will close the Modal.

❌ Clicking outside the Modal will close it.

Escape key

To start, we'll create the escape key event by utilising React's SyntheticEvent API which comes bundled with the framework.

Within ModalContent expose a new prop called onKeyDown.

const ModalContent = ({
  ariaLabel,
  content,
  onClose,
  onKeyDown,
  role = 'dialog'
}) => {

As we did with the onClose prop, we'll keep all the logic within our Modal. For now, add that prop as a value to the onKeyDown event handler on .c-modal-cover.

<aside
  className="c-modal-cover"
  role={role}
  aria-label={ariaLabel}
  aria-modal="true"
  tabIndex="-1"
  onKeyDown={onKeyDown}
>

Now within Modal, we'll create that onKeyDown method.

onKeyDown = (event) => {
  return event.keyCode === 27 && this.onClose();
}

As with normal JavaScript events, the React SyntheticEvent API exposes the event object, which we can use to find if the escape key was pressed via javascript key codes and call the onClose function we already have available.

We can even clean this up a little by destructuring keyCode from event and removing the curly braces to automatically return the result.

onKeyDown = ({ keyCode }) => keyCode === 27 && this.onClose();

Now pass that function down to <ModalContent />...

{isOpen &&
  <ModalContent
    ariaLabel={ariaLabel}
    content={children}
    onClose={this.onClose}
    onKeyDown={this.onKeyDown}
    role={role}
  />
}

... and once you're focused on the ModalContent, you should be able to close the Modal by pressing the escape key.

✅ Pressing the escape key will close the Modal.

Clicking outside of the Modal

Now that we have the escape key working, we can focus (pun intended) our attention on the final use case for closing the modal – clicking outside of the component.

❌ Clicking outside the Modal will close it.

Due to the design of our component, the backdrop can only be seen on larger breakpoints, yet it's still a worthwhile addition to the component. To ensure there's no confusion, in the image below I've highlighted in blue the area which is considered "outside of the modal". That's to say, whenever you click on the backdrop, but not the Modal or the content a user supplies, the Modal should close.

Demonstrating the area that will be clickable to dismiss the Modal.
Demonstrating the area that will be clickable to dismiss the Modal.

If you recall back to when we were styling up the component we have two HTML elements to represent this.

<aside className="c-modal-cover">
  <div className="c-modal">
  </div>
</aside>  

In our structure, .c-modal represents the start of what we'd visually call the Modal, whereas .c-modal-cover is there to style up the backdrop. Based on this we can safely assume that we're on the backdrop when we're clicking within the .c-modal-cover but not within the .c-modal. This is the reason for requiring two HTML elements rather than one and allowing the backdrop to be styled via pseudo-elements.

We can translate this to React thanks to Refs which allow us to access children outside of the typical dataflow.

In this tutorial we're going to use a callback ref. However, with React 16.3, you can now use the new createRef API. Both can be used although I felt for this case a callback ref makes the most sense.

Within ModalContent, expose a new modalRef prop for us to use.

const ModalContent = ({
  ariaLabel,
  content,
  modalRef,
  onClose,
  onKeyDown,
  role = 'dialog'
}) => {

We can then assign that to the c-modal element using the ref attribute.

<div className="c-modal" ref={modalRef}>

Now we need to use that reference in Modal.

{isOpen &&
  <ModalContent
    ariaLabel={ariaLabel}
    modalRef={n => this.modalNode = n}
    content={children}
    onClose={this.onClose}
    onKeyDown={this.onKeyDown}
    role={role}
  />
}

To break that down, a React callback ref allows you to supply a function with a single argument being the node you assigned the ref to, in our case c-modal. We can then assign this to a property of our Modal component named modalNode for later use via this.modalNode = n.

Now that we have a way to differentiate between the backdrop and the component itself we can go ahead and set up our backdrop clicking functionality.

Within ModalContent expose a new prop called onClickAway.

const ModalContent = ({
  ariaLabel,
  content,
  modalRef,
  onClickAway,
  onClose,
  onKeyDown,
  role = 'dialog'
}) => {

Then use that new prop as the value of a new onClick event handler on our c-modal-cover element.

<aside
  className="c-modal-cover"
  role={role}
  aria-label={ariaLabel}
  aria-modal="true" 
  tabIndex="-1"
  onKeyDown={onKeyDown}
  onClick={onClickAway}
>

Now within Modal, create a new onClickAway method. Within which we'll check for if our newly created this.modalNode contains the node we clicked via event.target. If it does, we'll return and not do anything, however, if it doesn't we can once again call this.onClose.

onClickAway = (e) => {
  if (this.modalNode && this.modalNode.contains(e.target)) return;
  this.onClose();
};

Add this method to where we consume ModalContent and you should have the ability to click on the backdrop to close the Modal.

{isOpen &&
  <ModalContent
    ariaLabel={ariaLabel}
    modalRef={n => this.modalNode = n}
    content={children}
    onClickAway={this.onClickAway}
    onClose={this.onClose}
    onKeyDown={this.onKeyDown}
    role={role}
  />
}

✅ Clicking outside the Modal will close it.

Focused content

Now that we have our close events under wraps it's time to add some functionality to focus events for our component. First up, scroll locking.

Scroll locking

When our Modal is rendered to the screen we already know from our sections on accessibility that it should be the only thing a user can interact with.

Due to the default behaviour of the browser, if you open up the Modal and start scrolling, you'll see that the page scrolls even with the Modal being open. Whilst usually this isn't a problem, when dealing with anything that overlays the page the ideal experience would be that whilst the overlay is open, the rest of the page should be paused in its current state, then once you close said overlay the page is resumed and presented to the user in the exact same way they left it. The last thing we want is for a user to scroll the page whilst our Modal is open, then close the Modal and not know where they are because they've scrolled too far down the screen.

❌ When open, scrolling is frozen on the main document beneath the Modal.

To combat this we will remove the ability for the user to scroll the rest of the page when the Modal is open, and then reintroduce the functionality once the Modal is closed. Within our Modal, create a new method named toggleScrollLock. Within which we can toggle a class onto the html element of our page.

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

We can then extend onOpen and onClose methods to toggle this new class.

onOpen = () => {
  this.setState({ isOpen: true });
  this.toggleScrollLock();
};

onClose = () => {
  this.setState({ isOpen: false });
  this.toggleScrollLock();
};

Now that the toggleScrollLock is called on onOpen and onClose, anytime those methods are called, be it by a button or by a key event, the class u-lock-scroll will be toggled.

When the Modal is opened, the u-lock-scroll class is applied to the HTML element.
When the Modal is opened, the u-lock-scroll class is applied to the HTML element.

The only thing left to do now is to write a little CSS for our u-lock-scroll.

/**
 * Lock the scroll position by adding this class to the `<html>` element.
 */
.u-lock-scroll {
  overflow: hidden !important;
}

The !important rule is optional. Whilst typically frowned upon in traditional CSS over the years, when working with the concept of a utility class whose styles should never be overridden, the rule gives us that reassurance of that being the case.

✅ When open, scrolling is frozen on the main document beneath the Modal.

Component focus

We're now on the home stretch. We've locked our scrolling so that when the component is rendered to the page the rest of the page is not interactive, now we need to finalise that by setting up our focus management.

Clearleft's Danielle Huntrods sums up our requirements for focus management pretty nicely.

Scripted focus management should therefore

  1. move the keyboard focus from the triggering element (link or button) to the custom dialog (or one of the focusable elements within it)
  2. keep it within the custom dialog until the dialog is closed (this makes the dialog effectively modal)
  3. move it back to the triggering element when the user closes the dialog by activating one of the choices offered.

Accessibility in modal dialogs, by Danielle Huntrods (2016)

Which brings us nicely to our last three criteria for our accessible Modal component:

  • When open, focus is drawn immediately to the Modal's close button.
  • When the Modal closes, focus returns to the Modal's trigger button.
  • Focus is trapped within the Modal when open.

Opening focus

You may have noticed that currently if you open up the Modal you have to then click on the component in order to be able to use the escape key to close the Modal. This is because of where we've attached our escape key event to.

<aside
  className="c-modal-cover"
  role={role}
  aria-label={ariaLabel}
  aria-modal="true" 
  tabIndex="-1"
  onKeyDown={onKeyDown}
  onClick={onClickAway}
>

By attaching the event to our .c-modal-cover element it will only trigger if it or one of its children are currently focussed which isn't the case when we first open the Modal. It'll still be set to the Modal's trigger button.

❌ When open, focus is drawn immediately to the Modal's close button.

To ensure our close button is automatically focussed when we open the Modal, we'll attach another React callback ref similar to that we did for this.modalNode. Within ModalContent, expose a new prop for buttonRef.

const ModalContent = ({
  ariaLabel,
  buttonRef,
  content,
  modalRef,
  onClickAway,
  onClose,
  onKeyDown,
  role = 'dialog'
}) => {

and then add that to the button via it's ref attribute.

<button className="c-modal__close" aria-labelledby="close-modal" onClick={onClose} ref={buttonRef}>

Then within Modal, find where we consume ModalContent and using an arrow function assign the ref to a variable which we can use in a second.

{isOpen &&
  <ModalContent
    ariaLabel={ariaLabel}
    buttonRef={n => this.closeButtonNode = n}
    modalRef={n => this.modalNode = n}
    content={children}
    onClickAway={this.onClickAway}
    onClose={this.onClose}
    onKeyDown={this.onKeyDown}
    role={role}
  />
}

Finally, we can use our onOpen method within our Modal class to update the focus by leveraging the React setState method's second argument which allows you to supply a callback to be run once the state has been applied.

onOpen = () => {
  this.setState({ isOpen: true }, () => {
  });
  this.toggleScrollLock();
};

We can then use our new this.closeButtonNode variable to access our close button element and call the focus method provided by React's synthetic events.

onOpen = () => {
  this.setState({ isOpen: true }, () => {
    this.closeButtonNode.focus();
  });
  this.toggleScrollLock();
};

✅ When open, focus is drawn immediately to the Modal's close button.

Due to the component not being rendered unless the state is true, we need to make sure we only focus onto the close button after we set the state, otherwise, we're going to see some lovely errors explaining that we're trying to access a node that doesn't exist.

Closing focus

As with all things, there's a balance. Now that we've managed the focus when opening the Modal, we need to do the same when closing it too.

❌ When the Modal closes, focus returns to the Modal's trigger button.

Similarly to how we just dealt with the close button, we'll create a React callback ref for the Modal's trigger button which we'll then focus on when the Modal has been closed.

Open up the ModalTrigger class and expose a buttonRef prop.

const ModalTrigger = ({
  buttonRef,
  onOpen,
  text
})

We can then apply that prop to the ref attribute to the trigger's <button/> element.

const ModalTrigger = ({
  buttonRef,
  onOpen,
  text
}) => <button className="c-btn" onClick={onOpen} ref={buttonRef}>{text}</button>;

Back within the Modal class, find where we consume ModalTrigger and just as we did with the close button, use an arrow function to assign the ref to a variable.

<ModalTrigger
  onOpen={this.onOpen}
  buttonRef={n => this.openButtonNode = n}
  text={triggerText}
/>

We can then use this.openButtonNode within our onClose method to focus it when we close our Modal.

onClose = () => {
  this.setState({ isOpen: false });
  this.openButtonNode.focus();
  this.toggleScrollLock();
};

Notice that we don't put this one into the setState callback because our trigger button is always rendered, unless our close button.

✅ When the Modal closes, focus returns to the Modal's trigger button.

Give that a try in your browser and you should see the

When the Modal is closed, focus is given back to the trigger button.
When the Modal is closed, focus is given back to the trigger button.

Trap focus within Modal

Now that we have the initial setup of our focus management in the form of opening and closing, we need to ensure that whilst open, focus stays within the Modal.

❌ Focus is trapped within the Modal when open.

The WAI-ARIA Authoring Practices 1.1 guide explains that once a Modal component is open, a user should not be able to focus anything outside of it. In addition, when using the TAB and SHIFT + TAB elements to cycle through the sequential focus order, the user should be put into what we can call a "focus loop", where the browser will focus the next available target in the Modal but if there is no "next target" then it should cycle back to the last.

That took a bit to get my head around so if you're unsure then no worries we can learn through an example.

Currently, we have three targettable elements to be focused on within our Modal: the close button and the two links from our content. Thanks to our recent enhancements when we open the Modal focus will automatically be given to the close button. When the user then presses the TAB key, focus will be given to the first link. After which, the second link will be focussed. At this point there are no focusable elements after the second link, so focus must be given back to the first element which in our case is the close button.

On the flip side, if the Modal were to open (with focus automatically being given to the close button) and the SHIFT + TAB keys were to be pressed then right at the start there isn't an element before the close button, so focus must be given to the last element within the Modal which would be the second link. After which you'd do the same thing as above but backwards.

  1. Second link;
  2. first link;
  3. close button;
  4. second link;
  5. and so on so forth.

Whilst we could do this from scratch by getting a list of focussable targets within our Modal, its a lot easier when there's already an external package ready for us to use. focus-trap and it's React wrapper focus-trap-react are packages from David Clark and will help ensure we have everything we need to trap the focus within our Modal.

As a side note, David works on some great projects such as the crazy popular stylelint and a number of fully accessible components ready for you to use, including a Modal component which helped drive a lot of the direction for this tutorial. Go check him out!

To get started you'll want to install the npm package.

npm install focus-trap-react

Next, we'll look to update our ModalContent component to consume our new package. Within ModalContent replace the aside tag with FocusTrap.

<FocusTrap
  className="c-modal-cover"
  role={role}
  aria-label={ariaLabel}
  aria-modal="true" 
  tabIndex="-1"
  onFocus={onFocus}
  onKeyDown={onKeyDown}
  onClick={onClickAway}
>
  <div className="c-modal" ref={modalRef}>
    <button className="c-modal__close" aria-labelledby="close-modal" onClick={onClose} ref={buttonRef}>
      <span id="close-modal" className="u-hide-visually">Close</span>
      <svg className="c-modal__close-icon" viewBox="0 0 40 40"><path d="M 10,10 L 30,30 M 30,10 L 10,30"></path></svg>
    </button>
    <div className="c-modal__body">{content}</div>
  </div>
</FocusTrap>

By default FocusTrap renders as a <div> tag. If we want to update that to an aside we can pass the tag prop to FocusTrap.

<FocusTrap
  tag="aside"
>

As a side effect of using ReactFocusTrap, it already comes with close functionality baked in when you click the escape key, so we can go ahead and remove our implementation.

Go ahead and remove the onKeyDown prop from ReactFocusTrap

<FocusTrap
  tag="aside"
  className="c-modal-cover"
  role={role}
  aria-label={ariaLabel}
  aria-modal="true" 
  tabIndex="-1"
  onFocus={onFocus}
  onClick={onClickAway}
>

And do the same for the props coming through in the ModalContent component itself.

const ModalContent = ({
  ariaLabel,
  buttonRef,
  content,
  modalRef,
  onClickAway,
  onFocus,
  onClose,
  role = 'dialog'
}) => {

✅ Focus is trapped within the Modal when open.

Test that out in your browser and you should see focus is trapped within the Modal component when it's open.

When the Modal is open, focus is trapped within.
When the Modal is open, focus is trapped within.

Concluding thoughts

If you've got this far I commend you, we've covered a hell of a lot in this series. We've built a Modal component that is fully responsive and accessible for all users, in accordance with the W3C specifications for Accessible Rich Internet Applications.

Our component:

  • is only rendered to the DOM when the Modal has been triggered;
  • is appended to the end of the document.body;
  • has the relevant WAI-ARIA attributes in accordance with accessibility guidelines;
  • closes when the escape key has been pressed, thanks to ReactFocusTrap;
  • closes when the user clicks on its backdrop;
  • freezes scrolling on the main document when the Modal has been triggered;
  • draws focus immediately to the Modal's close button when it has been triggered;
  • draws focus immediately back to the Modal's trigger button after the user closes it;
  • and finally, traps focus within it when triggered.

We've also looked into some of the newer features of React 16: React Portals and Fragments, not to mention dipping our toes into newer ECMAScript features such as Class Properties, default parameters and destructuring. My hope now is that you're able to take what you've learned in this series and expand upon the ideas in your own projects. I'd love to hear what you work on, let me know!

You can view the finished component over on Github

Now we just need to test this stuff...

Until next time 👀


# Related posts

Like what you've seen? Want more? Check out similar posts on Assortment that you may find interesting:


# Comments

  1. #1 Posted:

    Alexander Haniotis

    Great article, thanks for sharing!

    Small note: to lock the body completely on mobile, we'll need to add a few more lines of CSS

    position: fixed !important;
    overflow: hidden !important;
    width: 100% !important;
    height: 100% !important;
    
    Reply to this comment
    1. #1-1 Posted:

      Luke Whitehouse

      Hi Alexander,

      Thanks, I’m glad you liked the series. Interestingly from my testing on different devices I found the single overflow to work, without the extras you shared, I wonder if there’s a quirk I dont know about? I’d love to hear more!

  2. #2 Posted:

    Attila Vago

    Amazing writeup about accessible modals. You deserve a medal or something!

    Reply to this comment

# Leave a comment

No wookies will get this, its just for your Gravatar image.

Basic markdown supported, go to FAQ for more info.