UIDex

Modal Dialog

النافذة الحوارية

role="dialog" aria-modal="true"

also calledmodal، dialog box، modal window، overlay dialog، confirmation dialog، lightbox، مودال، مربّع حوار، صندوق حوار، نافذة منبثقة حاجبة، نافذة تأكيد، ديالوج

A modal dialog is a layer that opens over the page and takes interaction hostage: everything underneath goes out of service until it closes. Modality itself is what defines it: aria-modal="true" together with a real focus trap and a scrim that swallows clicks. Shape and a sudden entrance do not make a dialog modal. It is therefore not a non-modal popover, which dismisses the moment you click away and leaves the rest of the page usable and keyboard-reachable. A drawer and a bottom sheet are a separate case, defined by where they live: they slide from one edge and stay welded to it, and they may or may not be modal, whereas a dialog floats in the middle of the viewport belonging to no edge. It is not role="alertdialog" either, the narrow type for an urgent message needing an immediate answer: an alertdialog interrupts a screen reader mid-sentence, so it is the wrong choice for an ordinary edit form. The practical test: if the user could reasonably ignore what is inside and carry on working, it should never have been modal.

If you called it…

"the box that pops up and greys out the page""the are-you-sure-you-want-to-delete window""the popup I cannot click behind""the thing with an X in the corner and two buttons at the bottom""the form that opens on top without leaving the page"

Live specimen

Interact with the demo. Every part is real and numbered.

لوحة المشروع
RTLالزرّ الأساسي عند النهاية المنطقية، جرّب تبديل اللغة من الأعلى

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a modal dialog. Render it through a portal at <body> level so no ancestor with overflow: hidden or a transform can clip it, and set dir on <html> rather than an inner wrapper so the dialog still inherits page direction after it leaves the tree. The container carries role="dialog", aria-modal="true", an aria-labelledby pointing at the visible heading id, and an aria-describedby pointing at the body text. On open: move focus to the least dangerous button or to the container itself via tabIndex={-1}, trap Tab inside the dialog by hand because aria-modal does not trap focus, mark the rest of the page inert, and lock body scroll. On close: return focus to the element that opened it. Make Escape, the × button, and a scrim click all perform the same close, unless the form holds unsaved edits, in which case confirm first. Use logical properties throughout: inset-inline-end for the close button, padding-inline and margin-inline for spacing, text-align: start for text, justify-content: flex-end in the footer with fixed DOM order (secondary then primary) and no row-reverse, and padding-inline-end or scrollbar-gutter: stable for scrollbar compensation. Animate entry with translateY plus scale and no hard-coded translateX, and honour prefers-reduced-motion. Scroll the body region only, with overscroll-behavior: contain, keeping header and footer pinned.

How it behaves right-to-left

This section is ours alone.

Footer button order

mirrors

The footer is a flex row: keep DOM order fixed (secondary first, primary last) and set justify-content: flex-end. flex-end follows direction by itself, so the primary settles at the logical end of the line, far right in English and far left in Arabic. That is the correct order, because the primary action always stands at the end of the reading direction. The common mistake is "fixing" the flip with flex-direction: row-reverse under [dir="rtl"]: the reversal happens twice, the primary lands back at the start, and visual order splits from Tab order. Likewise, replace margin-left: auto with margin-inline-start: auto.

The close button corner

mirrors

The × sits in the header corner at the logical end: top right in English, top left in Arabic. Pin it with position: absolute; right: 12px and it stays stranded on the wrong side after the flip. Use inset-inline-end: 12px instead, or better, make it the last child of a flex header with justify-content: space-between and drop absolute positioning entirely. The × glyph itself is symmetric about its vertical axis, so never run scaleX(-1) over it.

The scrim does not mirror

never mirrors

The scrim covers the entire viewport, so there is nothing in it to flip: inset: 0 and ::backdrop are completely direction-neutral and need no special rule under [dir="rtl"]. The only mistake available here is painting it with a directional gradient such as linear-gradient(to right, …) or giving it a horizontally offset shadow, which turns a neutral layer into a directional one for no reason. Gradient functions accept no logical keywords at all: there is no to inline-end in CSS, only physical sides and angles. So either keep the scrim a flat colour with alpha, or drive it from a --scrim-angle custom property set to 90deg and flipped to 270deg under [dir="rtl"], passed in as linear-gradient(var(--scrim-angle), …).

The portal escapes the direction

mirrors

The dialog is rendered through a portal hung off <body>, which lifts it out of the very DOM subtree you set dir="rtl" on. When direction lives on an inner wrapper, say a <div dir="rtl"> around the app, the dialog is born outside it and inherits ltr from the root: text aligns left, the close button jumps to the opposite corner, the footer order reverses, and every logical property you wrote is still correct, because the fault is the missing direction context and not the CSS. Set dir on <html> itself (<html lang="ar" dir="rtl">) so anything injected into body inherits it, or pass dir explicitly to the portal container or the dialog root. To check, open the dialog and read getComputedStyle(el).direction. If it comes back ltr, the bug is here and no other rule you change will help.

Scroll lock and scrollbar compensation

mirrors

Locking page scroll behind the dialog hides the vertical scrollbar, and the content jumps sideways by its width. The usual compensation, padding-right: 15px, is only correct in English. The vertical scrollbar sits at the logical inline-end edge, which is the left side of a dir="rtl" page, so the sound fix is padding-inline-end, or simply scrollbar-gutter: stable on the root element. Note the bug never reproduces on macOS with overlay scrollbars, so test it on Windows before declaring it fine.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

ARIArole="dialog" aria-modal="true"Hides the rest of the page from screen readers. It does not trap focus for you.
HTMLdialog.showModal()The native element: top layer, ::backdrop, Escape, and background inerting for free.
HTMLinertThe attribute that removes the rest of the page from focus and the a11y tree at once.
Radix UI<Dialog.Portal> + <Dialog.Overlay>Compound parts: Dialog.Content, Dialog.Title, Dialog.Description, Dialog.Close. It warns when the title is missing.
shadcn/ui<DialogContent> + <DialogFooter>Built on Radix; DialogFooter is the button row whose visual order mirrors in RTL.
Material UI<Dialog open onClose> + <DialogActions>Material calls the footer DialogActions, and separates Dialog from Drawer by placement, not by modality.
CSSscrollbar-gutter: stableReserves the scrollbar space up front so the page does not jump on lock, and it works in both directions.

See also

Updated · 2026-08-19