Updated 7 October 2026. We ran every behaviour described here in Chromium, Firefox and WebKit, with oks-ui 1.4.1 and Playwright 1.63. The claims held, with two differences in WebKit that are explained below. The full results are in the table near the end.
A modal dialog is a small box on screen, but for keyboard and screen-reader users it changes where the whole page lives. An accessible modal has five jobs. This guide explains each one, shows how oks-ui's Modal handles it, and says what we saw when we tested it in three real browsers.
import "oks-ui/tokens.css"; // the design tokens every component stylesheet reads
import { Modal } from "oks-ui/modal";
import "oks-ui/modal.css";
import { Button } from "oks-ui/button";
import "oks-ui/button.css";
The first line loads the design tokens that every component stylesheet reads. Without it the components still render, but with fallback colours: in my test a danger button came out the same blue as a primary one.
The short result
- Everything this guide says about the dialog itself held in Chromium, Firefox and WebKit: 40 of 42 results passed.
- The two that differ are both in WebKit, the engine behind Safari, and both are about focus. Tab skips buttons there by default, and focus returns to the page body instead of the button if the dialog was opened with a mouse click.
- Across 12 repeated runs in each browser (168 Tab presses each), focus never reached a control on the page behind the dialog.
## Job 1: tell assistive technology what it is
A dialog needs
role="dialog",aria-modal="true"and an accessible name, usually its title. oks-ui'sModalsets all three from its props. For confirmations that interrupt the user and need an answer, the correct role isalertdialog:
<Modal
isOpen={open}
onClose={() => setOpen(false)}
role="alertdialog"
title="Delete this project?"
actions={
<>
<Button variant="bordered" onPress={() => setOpen(false)}>Cancel</Button>
<Button color="danger" onPress={deleteProject}>Delete</Button>
</>
}
>
The project and all its files will be removed. This can't be undone.
</Modal>
Tested: in all three browsers the dialog had role="dialog", aria-modal="true" and the name "Rename file" taken from its title, and role="alertdialog" worked the same way for the confirmation.
Job 2: move focus into the dialog
When the dialog opens, keyboard focus must move inside it. Otherwise the next Tab press walks through a page the user can no longer see.
Modal moves focus to the dialog panel by default. If one control is the natural starting point, such as the first field of a form, point initialFocusRef at it:
const nameRef = useRef<HTMLInputElement>(null);
<Modal isOpen={open} onClose={close} title="Rename file" initialFocusRef={nameRef}>
<input ref={nameRef} defaultValue={file.name} />
</Modal>
Tested: focus moved into the dialog when it opened in all three browsers, and with initialFocusRef it landed on the field we chose.
Job 3: trap focus inside
While the dialog is open, Tab and Shift+Tab must cycle through its own controls and never escape to the page behind. This is the focus trap.
The tricky case is focus sitting on something that isn't itself tabbable, like the dialog panel after job 2. A naive trap doesn't recognise it, so Shift+Tab lets the browser move focus out of the dialog. oks-ui treats that position as a wrap point: from the panel, Shift+Tab goes to the last control and Tab to the first. (This was a real bug in oks-ui before version 1.2, fixed along with a trap that didn't attach when a modal was opened after mounting. The 1.2.0 changelog has the details.)
Tested: from the dialog panel, Tab went to the first control and Shift+Tab to the last, in all three browsers. We then pressed Tab and Shift+Tab 12 times each. In Chromium and Firefox focus cycled inside the dialog every time.
After Shift+Tab from the dialog panel, focus is on the last control, Save (Chromium).
WebKit behaved differently. By default its Tab key skips buttons, as Safari's does, so a lap through the dialog went: close button, the field, the page body, and back to the close button. Focus touched the document body once per lap, but it never reached a control on the page behind, and the next Tab brought it back into the dialog. If your dialogs are mostly buttons, check them in Safari with "Press Tab to highlight each item on a webpage" turned on, or use Option+Tab.
Job 4: close on Escape and restore focus
Escape should close the dialog. Modal does this by default; set closeOnEscape={false} only when closing by accident would lose work, and then give the user another obvious way out.
Closing is only half the job. Focus must go back to the element that opened the dialog, usually a button. Without this, focus falls to the top of the document and keyboard users lose their place. Modal remembers what was focused when it opened and returns focus there on close.
Tested: Escape closed the dialog in all three browsers, and with closeOnEscape={false} it stayed open. When we opened the dialog with the keyboard, focus returned to the button that opened it in all three.
One difference: when we opened the dialog with a mouse click in WebKit, focus returned to the page body, not the button. Safari does not give a button focus when it is clicked, so there was no focused element for the dialog to go back to. Chromium and Firefox returned to the button. We have not found a workaround we trust, so we list it as a known limit. If keyboard users on Safari matter to your app, test this case.
Job 5: stop the page behind from scrolling
Scrolling the page underneath an open dialog is disorienting, especially on phones. Modal locks page scrolling while it is open. The lock is counted, so a confirmation opened on top of a drawer keeps the page locked until both are closed.
Tested: with the dialog open, the mouse wheel moved the page 0 pixels, where the same wheel movement normally scrolls it 600. Once the dialog closed, scrolling worked again. With a modal opened on top of a drawer, the page stayed locked until both were closed. This was the same in all three browsers.
Clicking outside
By default a click on the backdrop closes the dialog, which is what most people expect. For forms where a stray click would lose typed input, turn it off with closeOnOutsideClick={false}.
Tested: in all three browsers a click on the backdrop closed the dialog, and with closeOnOutsideClick={false} it did not.
All the results
Here is every check, in each browser:
14 checks in three browsers, 40 of 42 results pass.
The 30-second keyboard test
Put the mouse away and try any dialog:
- Open it with the keyboard. Does focus land inside?
- Press Tab repeatedly. Does focus stay inside and wrap around?
- Press Shift+Tab from the very first position. Does it still stay inside?
- Press Escape. Does it close, and is focus back on the button that opened it? If all four pass, the dialog works for keyboard users. With a screen reader running, also check that the title is announced when the dialog opens. We did not test screen readers for this guide. To run the same steps against your own dialogs in all three browsers, here is the script we used, trimmed to its core. Change the address and the button name for your page:
import { chromium, firefox, webkit } from "playwright";
for (const [name, type] of [["Chromium", chromium], ["Firefox", firefox], ["WebKit", webkit]]) {
const browser = await type.launch();
const page = await browser.newPage();
await page.goto("http://localhost:3000/your-page");
// Open the dialog with the keyboard, the way a keyboard user would.
await page.getByRole("button", { name: "Open dialog" }).focus();
await page.keyboard.press("Enter");
const steps = [];
for (let i = 0; i < 8; i++) {
await page.keyboard.press("Tab");
steps.push(
await page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"], [role="alertdialog"]');
const active = document.activeElement;
if (active === document.body) return "page body";
if (!dialog?.contains(active)) return "OUTSIDE THE DIALOG";
return active.getAttribute("aria-label") || active.textContent.trim();
}),
);
}
console.log(name, "Tab order:", steps.join(" > "));
await page.keyboard.press("Escape");
await page.waitForTimeout(800);
console.log(name, "focus after Escape:", await page.evaluate(() => document.activeElement?.textContent?.trim()));
await browser.close();
}
For our dialog this printed, in Firefox and in WebKit:
Firefox Tab order: Close modal > File name > Cancel > Save > Close modal > File name > Cancel > Save
Firefox focus after Escape: Open basic
WebKit Tab order: Close modal > File name > page body > Close modal > File name > page body > Close modal > File name
WebKit focus after Escape: Open basic
How we tested
- oks-ui 1.4.1, Playwright 1.63.0, and the Chromium, Firefox and WebKit builds that come with it.
- A page with the dialogs from this guide, driven by keyboard, and by mouse where this guide says so: 14 checks in each browser.
- Twelve repeated runs of 14 Tab presses in each browser, to look for anything that comes and goes.
- Not tested: screen readers, phones and tablets, and how the dialog looks in other themes. See every option on the Modal component page.
Originally published at oks-ui.com.
Top comments (0)