Fixing a Hypothetical Modal: Focus, Keyboard and ARIA Basics
I want to walk through one screen, because accessibility advice is easier to absorb when you can see it applied. The screen is hypothetical, but the problems are very common ones.
Say there's a SaaS dashboard with an "Invite teammate" button. Clicking it opens a modal with an email field, a role dropdown and a "Send invite" button. In Figma it looks great. The overlay is dimmed, the card is centered, and there's a small X in the corner.
Now imagine it was built quickly, by me with an AI tool or by anyone else, and nobody tested it without a mouse. Here is what likely goes wrong.
01The "before" version
Picture the markup as a plain div pile:
<div class="overlay">
<div class="modal">
<div class="close" onclick="closeModal()">×</div>
<div class="title">Invite teammate</div>
<input placeholder="Email address">
<div class="btn" onclick="send()">Send invite</div>
</div>
</div>
It looks right and works with a mouse. For a keyboard user, it's close to unusable. Let me list the failures.
- The trigger works, but focus doesn't move. After pressing Enter on "Invite teammate", focus stays on the button behind the overlay. The next Tab goes to something hidden under the dimmed layer.
- Tab escapes the modal. Focus wanders through the page behind it.
- The close "button" is a div. It can't be focused or activated with the keyboard.
- Escape does nothing. Many people expect it to close the modal.
- The input has only a placeholder. The placeholder disappears when typing and isn't a reliable label.
- A screen reader doesn't know a dialog opened. It just reads whatever comes next, if anything.
- Closing drops focus into nowhere. The user loses their place on the page.
None of these show up in a design review. All of them show up the first time someone uses Tab.
02Fix 1: use real elements
The cheapest improvements come from swapping divs for what they should have been.
<button type="button" class="close" aria-label="Close">×</button> <label for="email">Email address</label> <input id="email" type="email" autocomplete="email"> <button type="submit">Send invite</button>
A button is focusable, responds to Enter and Space, and is announced as a button. A label tied to an input gives it a name and enlarges the click target. The aria-label on the close button is needed because the visible content is just "×", which a screen reader would read poorly.
This is the main lesson I'd give designers: before reaching for ARIA, check whether a native element already does the job.
03Fix 2: let the browser do the modal
The native dialog element is the next step:
<dialog id="invite" aria-labelledby="invite-title">
<h2 id="invite-title">Invite teammate</h2>
<form method="dialog">
...
</form>
</dialog>
document.querySelector('#invite').showModal();
Opening it with showModal() gives you a lot for free, and this is documented browser behaviour:
- It renders in the top layer with a
::backdropyou can style as your overlay. - Focus moves into the dialog.
- The rest of the page becomes inert while it's open.
- Escape closes it.
The aria-labelledby pointing at the heading gives the dialog an accessible name, so a screen reader announces something like "Invite teammate, dialog".
That's most of the "before" list fixed with a few lines. If you ever hand-roll a modal with divs, you have to build every one of those behaviours yourself and keep them working. I wouldn't.
04Fix 3: return focus where it came from
One thing worth checking even with native dialog is where focus lands after closing. The expected behaviour is that it returns to the button that opened it. Browsers generally do this with showModal(), but if you open the dialog from code that removes or re-renders the trigger, focus can get lost. Test it.
If you're building your own version, the rule is simple: save document.activeElement before opening, and call .focus() on it after closing.
05Fix 4: make focus visible
The Figma file probably has no focus state at all. That's a design gap, and it's ours to fill.
A common mistake is outline: none on everything because the default ring looked ugly. If you remove it, you must replace it. Something like:
button:focus-visible,
input:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
:focus-visible shows the ring for keyboard users without flashing it on every mouse click in most browsers. WCAG 2.2 also has a criterion (2.4.11, Focus Not Obscured) that cares about focus not being hidden by other content, and 1.4.11 asks for 3:1 contrast on UI component boundaries and states. So the ring color matters against both the card and the page.
In my own workflow, I'd now add a focus state to every interactive component in the design file, not just hover and pressed. If you're already thinking about states, the same habit applies to motion and micro-interactions: they need to work when the input isn't a mouse.
06Fix 5: errors that get announced
Suppose the email is invalid. The "before" version might turn the border red and show text underneath. A sighted mouse user sees it. A screen reader user hears nothing.
A small improvement:
<input id="email" type="email" aria-describedby="email-error" aria-invalid="true"> <p id="email-error">Enter an email like name@company.com</p>
aria-describedby links the message to the field so it's read along with it, and aria-invalid signals the state. Don't rely on color alone either. The message text carries the meaning, which also satisfies the WCAG rule about not using color as the only cue.
07The "after" checklist
Here's the keyboard-only test I'd run on this modal, with no mouse:
- Tab to "Invite teammate" and press Enter. Does focus move into the modal?
- Press Tab repeatedly. Does focus stay inside?
- Can you see where focus is at every step?
- Does Shift+Tab go backward sensibly?
- Does Escape close it?
- After closing, are you back on the trigger?
- Submit with a bad email. Is the problem communicated in text?
If all seven pass, you've covered the basics. You haven't covered everything. A real screen reader test with VoiceOver or NVDA would still be worth doing, and I wouldn't claim a screen passes accessibility from this list alone.
08What I'd take away as a designer
You don't need to memorize ARIA. You need to design the states that Figma tends to skip (focus, error, closed-and-returned) and ask for native elements by name. Saying "use a dialog element and a real button" in a handoff or an AI prompt changes the output more than saying "make it accessible".
That's also why I like using an AI tool for this kind of component: it will happily generate the div version unless you tell it otherwise. If you want a way to check what it gives you, this audit prompt approach is a good companion.
09Frequently asked questions
Should I use the native dialog element or build my own modal?
Start with the native dialog element and showModal() where you can. The browser handles the backdrop, Escape key and keeping interaction inside the dialog. A custom modal means rebuilding all of that yourself.
Do I need ARIA on every component?
No. Native HTML elements like button, dialog and label already carry the right roles and behaviour. ARIA is for filling gaps that HTML can't cover, and wrong ARIA is often worse than none.
How can a designer test keyboard accessibility without being a developer?
Put the mouse aside and use Tab, Shift+Tab, Enter, Space and Escape on the page. If you can't see where focus is, can't reach something, or get stuck, you've found a real problem. Add a screen reader pass later if you can.
Denny Pratama
Product designer in Indonesia. Since 2018 I’ve designed SaaS products, dashboards and marketing sites, and now I build my own designs too.