The dialog role, the focus trap, Escape, the scroll lock and the return of focus all come built in. What is left to you is the dialog's name, which exists only when you pass a title, and the few props that turn the rest off.
What Modal renders
The dialog renders in a portal on document.body. Inside it, the panel is a <div role="dialog" aria-modal="true" tabIndex={-1}> with the mask and the scroll wrapper beside it. The close control in the header is a real <button type="button" aria-label="Close">.
aria-modal="true" tells a screen reader that the rest of the page is out of reach while the dialog is open. It is always set.
The mask is a plain div with no role, no name and nothing to focus. A click outside is never a keyboard route, so Escape and the close button both matter.
The close button's name is the string Close. To translate it, pass the object form: closable={{ "aria-label": "Kapat" }}. Its aria-* attributes replace the defaults on the button.
Naming the dialog
Without a title, the dialog has no name.aria-labelledby points at the title only when there is one, so an untitled dialog is announced as “dialog” and nothing else.
Give every Modal a title. It is the cheapest accessibility fix here and the easiest to miss, because nothing warns you.
Where the design shows no heading, keep the prop and hide the text: title={<span className="sr-only">Delete project</span>}. title takes any node, and the label follows the node whether it is visible or not.
Make the title say what the dialog is for, not what kind of dialog it is. “Delete project” is a name. “Confirm” is a category.
The body is not the name. A long explanation in children is read after the dialog announces itself, so the title has to carry the point on its own.
Focus on open, and where it returns
On open, focus goes to the panel, not to its first control, once the open animation has finished. The panel's tabIndex={-1} is what allows it, so people hear the dialog's name before its buttons. If focus is already inside the dialog, it stays where it is.
On close, focus returns to what was active before, usually the button that opened the dialog. That is focusable.focusTriggerAfterClose, and it defaults to true.
Without a mask, focus does not return. Focus is restored only when a mask was rendered, so with mask={false} people are left wherever the document puts them. Move focus yourself in afterClose.
The same happens when the trigger is gone, which is the usual result of a delete confirmation: the row that opened the dialog no longer exists, and focus falls back to the document. Send focus somewhere useful, like the list heading or the next row.
The focus trap
While the dialog is open, Tab stays inside it. Two settings can turn that off without you noticing.
focusable.trap follows the mask. It defaults to whether a mask is rendered, so mask={false} also turns the trap off. A dialog without a mask that should still trap has to ask for it: focusable={{ trap: true }}.
The trap needs the scroll wrapper to be position: fixed. On the default portal it always is. Rendered inline with getContainer={false}, the wrapper sits in your own layout, where an ancestor with a transform or a filter can change how it is positioned, and the trap then stops working with no error. Test Tab before you ship an inline dialog.
Do not add a trap of your own on top. Two traps fight over Tab, and focus gets stuck on one button.
Escape and the scroll lock
Escape closes the dialog through onCancel, like the mask and the Cancel button, and keyboard (default true) turns it on and off. Only the topmost dialog in a stack gets the key, so a stack closes one layer at a time.
confirmLoading makes Escape do nothing while it is set. For a one-second request that is fine. For anything longer, a keyboard user cannot leave, so keep the wait short, or leave the cancel path open and ignore the late response.
scrollLock (default true) locks the page behind the mask. Turning it off lets people scroll a page they cannot otherwise touch, which is confusing with a screen reader and worse with a pointer.
The dialog itself still scrolls when it is taller than the viewport. Do not lock that as well.
The confirm dialogs
A confirm, info, success, error or warning dialog takes its name from title and reads content as the body. The same rule applies: no title, no name.
These have no close button in the header, so the footer, Escape and the mask are the only ways out. Keep the last two on for a confirm.
focusable.autoFocusButton picks which footer button gets focus on open: ok, cancel or null for neither. On a destructive confirm, focus Cancel or nothing, so pressing Enter out of habit does not confirm the delete.
Open them through App.useApp(). Imported straight from antd, they render outside your provider and come out unthemed, and an unthemed dialog is one whose contrast nobody checked.
The status icon beside the title is decoration. It must never be the only thing that says a dialog is a warning.
Contrast and motion
The mask dims the page, not the dialog. Check the dialog's text against the dialog's own background, never against the masked page behind it.
A blurred mask (mask={{ blur: true }}) makes the page behind harder to read, which is its purpose, and changes nothing about the dialog's own contrast.
Every color comes from the active theme, so contrast depends on the theme. The default antuikit theme is checked for contrast. A custom seed is not, and a danger button picked for its hue can fall below 4.5:1 with nothing warning you.
The dialog zooms in and the mask fades. Both animations stop with motion: false in your token file's seed. Set it for people who ask for reduced motion.
Keep the animation short. Focus moves into the dialog when the animation ends, so a slow zoom delays what a screen reader can read.