React Modal Component: Complete Guide
Updated 9 October 2026. I built every example from the first version of this guide into a test page with oks-ui 1.4.1 and checked each claim in Chromium, Firefox and WebKit. The behaviour it describes held. Three details needed correcting, and two of them are traps in units: backgroundOpacity is a percentage from 0 to 100, not a fraction, and animationDuration is in seconds, not milliseconds. And dismissible={false} does not make a dialog "must be answered" on its own. The results are in a table near the end. A modal is a box that takes over the screen until the user deals with it. Used well it keeps people in their flow; used badly it traps them. This guide covers everything oks-ui's Modal does and when to reach for something else.
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. Every snippet on this page type-checks in strict TypeScript.
The shape of a modal
Modal is controlled: you own the open state.
const [open, setOpen] = useState(false);
<Button color="primary" onPress={() => setOpen(true)}>Invite people</Button>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
title="Invite people"
actions={
<>
<Button variant="bordered" onPress={() => setOpen(false)}>Cancel</Button>
<Button color="primary" onPress={send}>Send invites</Button>
</>
}
>
Anyone you invite can read and comment on your posts.
</Modal>
title renders the heading that names the dialog for screen readers, and actions is the row at the bottom. I checked both: the dialog had role="dialog" and aria-modal="true", and its accessible name was "Invite people", taken from the title. In the actions row, Cancel sat to the left of "Send invites", which was at the right-hand end, so put the confirming action last, where people expect it. The dialog also has a close button in its corner, named "Close modal" by default; closeLabel changes that text.
Sizes
size takes xs, sm, md (the default), lg, xl and full. You can also pass a number of pixels or any CSS width. In a 1400 pixel wide window the dialogs came out at 320, 420, 640, 860 and 1100 pixels for the first five, and as wide as the window for full. A number worked as written (500 gave 500 pixels), and so did a CSS width ("50vw" gave 700).
Confirmations:
sm. A short question does not need a wide box.Forms:
md.Tables or previews:
lgand up, but consider a page instead.
Confirmations
For a question that interrupts the user and needs an answer, use role="alertdialog" and name the thing in the title:
<Modal
isOpen={open}
onClose={close}
role="alertdialog"
size="sm"
title="Delete “Q3 report”?"
actions={
<>
<Button variant="bordered" onPress={close}>Cancel</Button>
<Button color="danger" onPress={remove}>Delete</Button>
</>
}
>
The file and its history are removed. This can't be undone.
</Modal>
Write what will happen, not "Are you sure?". In my test the role was alertdialog, the accessible name was the full title including the file name, the box was 420 pixels wide, and focus landed on the dialog itself when it opened. The message text is not linked to the dialog with aria-describedby, so a screen reader will announce the title but not necessarily the sentence under it.
Forms inside a modal
Two changes make a form dialog behave:
<Modal
isOpen={open}
onClose={close}
title="New project"
closeOnOutsideClick={false}
initialFocusRef={nameRef}
actions={
<>
<Button variant="bordered" onPress={close}>Cancel</Button>
<Button color="primary" isLoading={saving} onPress={save}>Create</Button>
</>
}
>
<input ref={nameRef} />
</Modal>
closeOnOutsideClick={false} stops a stray click from losing what someone typed, and initialFocusRef puts the cursor in the first field. I confirmed both: focus went to the input when the dialog opened, a click outside the box did nothing, and Escape still closed it.
Controlling how it closes
closeOnEscape(default on): leave it on; Escape is what people press. Withfalse, Escape did nothing.closeOnOutsideClick(default on): turn off for forms.dismissible={false}hides the close button. The first version said this was "for a dialog that must be answered". It is not enough for that. I turned it on and pressed Escape, and the dialog closed; I clicked outside, and it closed. Only the corner button disappeared. For a dialog that really must be answered, set all three:dismissible={false},closeOnEscape={false}andcloseOnOutsideClick={false}, and never without a visible action that closes it.
Appearance
backdrop passes options through to the overlay, and blur and backgroundOpacity adjust how much of the page shows through. I tried them both ways, as props on Modal and inside backdrop={{ ... }}, and the result was identical. Here is what the values mean, because two of them are easy to get wrong:
blurtakestrue,false,"sm","md","lg"or a number of pixels. The default was 8 pixels and"lg"gave 12.backgroundOpacityis a percentage from 0 to 100, not a fraction. The default is 80. I passed0.8, expecting 80 percent, and got 0.8 percent: the page behind was barely dimmed. Pass60for 60 percent.animationDurationis in seconds, from 0 to 10, not milliseconds. The default is 0.3. I passed900, expecting 900 milliseconds, and it was clamped to the maximum of 10 seconds, so the dialog took ten seconds to settle. Pass0.6for 600 milliseconds; that gave a duration of 600ms in the overlay's settings.animationTypetakes"fade","zoom","slideUp","slideDown","slideLeft"or"slideRight". Note the camel case.
<Modal blur="lg" backgroundOpacity={60} animationDuration={0.6} animationType="slideUp" /* ... */ />
Both animation props respect a reader's "reduce motion" setting. With prefers-reduced-motion on, the dialog appeared with no movement at all, in all three browsers, even with the duration set.
Backdrop settings, and a dialog with dismissible={false}.
What it handles for you
Focus moves into the dialog when it opens and is trapped while it is open, Escape closes it, focus returns to the button that opened it, and the page behind stops scrolling. The dialog carries role="dialog", aria-modal="true" and its title as the accessible name. I checked these again in the test page. When opened with the keyboard, focus was inside the dialog, a mouse wheel over the page did not scroll it, and closing put focus back on the opening button, in all three browsers. The accessible modal post tests the focus trap in detail, including a case in Safari: when the dialog is opened with a mouse click, focus returns to the page and not to the button.
When not to use a modal
Long forms. A dialog that scrolls is a page in disguise. Use a page.
Anything a user might want to link to or refresh. Modals have no address.
On phones, for anything but a short confirmation. A
Drawersliding from the bottom feels more natural and leaves more room. Withposition="bottom"it came up as a full-width sheet from the bottom edge.Success messages. A toast says "Saved" without asking the user to dismiss it. Mine left on its own after about six seconds, and it has a close button for people who want it gone sooner. It needs a
ToastProviderrendered once, as the settings page post explains.
Results
What I measured.
Check your own dialog
This script opens your dialog with the keyboard and reports what it does: its role and name, whether it has a close button, whether the page scrolls behind it, whether an outside click and Escape close it, and where focus goes afterwards. Install playwright first.
// npm i -D playwright
// node check-dialog.mjs http://localhost:3000/page "#open-button"
import { chromium, firefox, webkit } from "playwright";
const [url, opener] = process.argv.slice(2);
const DIALOG = '[role="dialog"], [role="alertdialog"]';
for (const [name, type] of [["Chromium", chromium], ["Firefox", firefox], ["WebKit", webkit]]) {
const browser = await type.launch();
const page = await (await browser.newContext({ viewport: { width: 1200, height: 800 } })).newPage();
const result = [];
await page.goto(url);
await page.locator(opener).focus();
await page.keyboard.press("Enter");
await page.waitForSelector(DIALOG);
await page.waitForTimeout(800); // let the entrance animation finish
const info = await page.evaluate((sel) => {
const dialog = document.querySelector(sel);
const named = dialog.getAttribute("aria-label") || document.getElementById(dialog.getAttribute("aria-labelledby") || "")?.textContent;
const close = [...dialog.querySelectorAll("button")].find((b) => /close/i.test(b.getAttribute("aria-label") || b.textContent));
return { role: dialog.getAttribute("role"), modal: dialog.getAttribute("aria-modal"), name: named?.trim() || null, closeButton: close ? (close.getAttribute("aria-label") || close.textContent.trim()) : null, focusInside: dialog.contains(document.activeElement) };
}, DIALOG);
result.push(`role=${info.role}, aria-modal=${info.modal}, name=${JSON.stringify(info.name)}, close button=${JSON.stringify(info.closeButton)}, focus inside=${info.focusInside}`);
await page.mouse.move(10, 400);
await page.mouse.wheel(0, 500);
await page.waitForTimeout(300);
result.push(`page scrolls behind it: ${(await page.evaluate(() => window.scrollY)) > 0}`);
await page.mouse.click(5, 5); // outside the dialog
await page.waitForTimeout(900);
const closedByOutside = (await page.locator(DIALOG).count()) === 0;
result.push(`outside click closes: ${closedByOutside}`);
if (closedByOutside) { await page.locator(opener).focus(); await page.keyboard.press("Enter"); await page.waitForSelector(DIALOG); await page.waitForTimeout(800); }
await page.keyboard.press("Escape");
await page.waitForTimeout(900);
const closedByEscape = (await page.locator(DIALOG).count()) === 0;
result.push(`Escape closes: ${closedByEscape}`);
result.push(`focus returns to the opener: ${await page.evaluate((o) => document.activeElement === document.querySelector(o), opener)}`);
console.log(`${name}:\n ${result.join("\n ")}`);
await browser.close();
}
On a dismissible={false} dialog it printed close button=null and then outside click closes: true and Escape closes: true, which is how I noticed the problem. Try every prop on the Modal component page.
Limits
I tested one dialog layout in a 1400 pixel window. Dialogs taller than the window, nested dialogs, and dialogs that open other dialogs were not tested here.
The animation checks measured the dialog's position over time in desktop browsers. I did not test on a phone.
I did not test with a screen reader. I checked the attributes it reads, not what it says.
Opening with a mouse in Safari returns focus to the page and not the button, as the accessible modal post describes.
Originally published at oks-ui.com.
