Bump up modal accessibility with aria attributes and focus trap. Implementations in vanilla JavaScript and React.
2024 and here we are talking about modals, the most loved UI.
After all, how much you need to know about a window blocking all your actions?
Well, it turns out there are a few details to smooth a modal experience and get people through.
But first, let’s start from the basics.
Let’s dialog
A piece of UI floating over your page is called a dialog
.
A dialog
may allow you to interact with the rest of the page or block you.
If it does block you, it’s called a modal.
How do you tell assistive technology the user has in front a dialog
?
You use the dialog html tag or the dialog role.
How do you tell assistive technology the dialog
is a modal?
You use the the aria-modal
attribute.
And, how do you tell assistive technology which dialog
is which when you got many dialog
in the page?
You give a name to your dialog
using aria-label
or aria-labelledby
.
<div
role="dialog"
aria-modal="true"
aria-label="Your dear modal">
In total, with role="dialog"
, aria-modal="true"
and aria-label
you got all the attributes in place for a modal.
The interactions
As the modal blocks the rest of the page, you need to cover two groups of interactions to avoid the user get lost: the open/close focus and the focus trap.
The open/close focus consists of get the focus within the modal when the modal opens up and bringing back the focus where it was when the modal closes.
In a way, this is just common sense.
After all, when the user opens up a modal the UI will use all the visual space.
If the focus remains on the button that triggered the interaction, then you will have a mismatch between the visual and the screen reader.
This is because the screen reader would be on the button but the visual would be the modal.
In addition, there’s no guarantee that on the next tab the user would gain focus within the modal; maybe for convenience, the modal was attached at the bottom of the body
element and now you need to tab all the page through to get in.
Same story when the modal closes. You want to go back to where you were at. Because imagine you opened up a modal by mistake, you close it, and now you have to retab all the page through to be where you were.
Quite painful.
The focus trap instead consists of staying within the modal when the user keep tabbing and close the modal when the user press esc.
To get your head around this, imagine you were within the modal, you’re tabbing and you get out. Now you have the screen reader on the back of the page but visually you still have the modal.
You would still have the mismatch experience.
The esc
interactions instead is just a safety net.
You found what you were looking for, or you opened up the modal by mistake, it doesn’t matter, you just want to get out. Now.
esc
is there for you.
open/close interactions:
- focus within the modal when the modal opens up
- return the focus back where it was when the modal close
focus trap interactions:
- staying within the modal when the user keep tabbing
- close the modal when the user press esc
The code
Vanilla JavaScript
function modal() {
/*
DOM
*/
const body = document.querySelector("body");
const dial = document.querySelector("[data-id=dialog]");
const btn = document.querySelector("[data-id=btn]");
const frst = document.querySelector("[data-id=first-itm]");
const last = document.querySelector("[data-id=last-itm]");
/*
FUNCTIONS
- setDisplay := set the css display property.
This is used to show and hide the
modal dialog.
*/
const setDisplay =
(value) =>
dial.style.display = value;
const show =
() =>
setDisplay("block");
const hide =
() =>
setDisplay("none");
/*
HANDLERS
- handleShowModal := show the modal and
focus on the first item
- handleEsc := close modal on esc and get
the focus back on the button
- handleLastTab := go back to the first item
- handleShiftTab := go back to the last item
*/
const handleShowModal =
() =>
(show(), frst.focus());
const handleEsc =
(e) =>
e.key === 'Escape'
&& (hide(), btn.focus());
const handleLastTab =
(e) =>
!e.shiftKey
&& e.key === 'Tab'
&& (e.preventDefault(), frst.focus());
const handleShiftTab =
(e) =>
e.shiftKey
&& e.key === 'Tab'
&& (e.preventDefault(), last.focus());
/*
EVENTS
*/
btn.addEventListener("click", handleShowModal);
body.addEventListener("keydown", handleEsc);
last.addEventListener("keydown", handleLastTab);
first.addEventListener("keydown", handleShiftTab);
}
<!-- In your HTML -->
<button
data-id="btn"
aria-controls="modal-01">Show Modal</button>
<div
data-id="dialog"
id="modal-01"
role="dialog"
aria-modal="true"
aria-label="My Modal"
style="display:none">
<button data-id="first-item">Do something</button>
.
.
.
<button data-id="last-item">Close</button>
</div>
handleShowModal
and handleEsc
take care of the open/close interactions, while handleLastTab
and handleShiftTab
take care of the focus trap.
We registered the callbacks on keydown
because it’s the first KeyboardEvent
that gets fired and we used KeyboardEvent.key and KeyboardEvent.shiftKey properties to detect the keyboard interactions.
React
import { useState, useRef, useEffect, KeyboardEvent as ReactKeyboardEvent, RefObject } from 'react';
function useFocus<T extends HTMLElement>(): [RefObject<T>, () => void] {
const ref = useRef<T>(null);
const focusIfPossible =
(): boolean =>
(ref.current != null)
&& (ref.current.focus != null)
&& (ref.current.focus(), true);
const createFocusAndRetry =
(attempts: number) =>
(): void => {
!focusIfPossible()
&& attempts
&& setTimeout(
createFocusAndRetry(attempts - 1),
attempts
);
};
const setFocus = createFocusAndRetry(1);
return [ref, setFocus];
}
const Modal = (props) => {
/*
HOOKS
*/
const [frst, setFirstFocus] = useFocus<HTMLAnchorElement>();
const [last, setLastFocus] = useFocus<HTMLButtonElement>();
const [btn, setBtnFocus] = useFocus<HTMLButtonElement>();
/*
STATES
*/
const [isExpanded, setExpanded] = useState(false);
/*
FUNCTIONS
*/
const handleOpen =
() =>
(setExpanded(true), setFirstFocus());
const handleLastTab =
(e: ReactKeyboardEvent) =>
!e.shiftKey
&& e.key === 'Tab'
&& (e.preventDefault(), setFirstFocus());
const handleShiftTab =
(e: ReactKeyboardEvent) =>
e.shiftKey
&& e.key === 'Tab'
&& (e.preventDefault(), setLastFocus());
/*
EFFECTS
*/
useEffect(() => {
const handleEsc =
(e: KeyboardEvent) =>
e.key === 'Escape'
&& isExpanded
&& (setExpanded(false), setBtnFocus());
document.querySelector("body")?.addEventListener('keydown', handleEsc);
return () => {
document.querySelector("body")?.removeEventListener('keydown', handleEsc);
};
}, [setBtnFocus, isExpanded]);
return (
<div>
<button
onClick={handleOpen}
aria-expanded={isExpanded}
aria-owns="modal"
ref={btn}>Show Modal</button>
{
isExpanded &&
<div
data-id="dialog"
id="modal"
role="dialog">
<a
href="/"
ref={frst}
onKeyDown={handleShiftTab}>First item link example</a>
.
.
.
<button
ref={last}
onKeyDown={handleLastTab}>Last item button example</button>
</div>
}
</div>
);
}
The useFocus
custom hook exists as ref.current
init as null
. As such, when we try to use ref.current.focus()
TypeScript complains as ref.current
may be null
.
And so, setFocus
checks for ref.current
not being null
and retry the focus if the first attempt fails.
Functions like handleOpen
wouldn’t work without the retry functionality.
This is because when isExpanded
is false
the whole modal is not rendered and the frst ref
is null
. Thus focus wouldn’t trigger.
The only thing I’m uneasy by is the use of setTimeout
as it feels like cheating.
I wish there was a cleaner API to use from React.
As an alternative, we could extract the dialog
in another component with a useEffect
and an onLoad
prop
. Unfortunately, with this method, the hook would not be portable as you would need a similar component all the time.
const Dialog = ({children, onLoad}: {children: ReactNode, onLoad: () => void}) => {
useEffect(() => onLoad(), [onLoad]);
return (<>{children}</>);
}
Next, the import rename KeyboardEvent as ReactKeyboardEvent
.
This is there because we did use React events and DOM events at the same time and as they have the same name, one has to be renamed.
Note that to add support for the DOM events you’ll need to add the dom
library within the compilerOptions
property in tsconfig.json
.
# tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom"],
.
.
.
},
}
Summary
To conclude, we saw what’s a dialog, a modal dialog and we explored the use of role="dialog"
, aria-modal="true"
and aria-label
.
Then, we looked at how open/close interactions and focus trap help with accessibility. We ended up with two code implementations, one in vanilla JavaScript and one in React.
Along the way, we saw how to use the KeyboardEvent
properties, shiftKey
and key
and the KeyboardEvent
from the DOM within React.
If you are interested in the subject, I would recommend reading <dialog>: The Dialog element, Dialog (Modal) Pattern and the previous post on aria-expanded.
Links
- <dialog>: The Dialog element
- ARIA: dialog role
- ARIA: alertdialog role
- aria-modal
- Dialog (Modal) Pattern