Dialog
A dialog puts a surface on top of the page and keeps focus inside it until it closes. Reach for it when a task needs the user's whole attention and belongs off the main flow — editing a record, confirming something irreversible, reading terms before accepting them. If the content can live on the page, leave it on the page: a dialog costs the user their place.
Dialog.Popup collapses Base UI's Portal → Backdrop + Viewport → Popup into one element, so the common case is four parts deep, not seven. AlertDialog shares every part except Root and Trigger.
Dialog.Root owns the open state, Dialog.Trigger opens it, and everything the user sees goes inside Dialog.Popup. Dialog.Footer lays out the action row; the two Dialog.Close buttons there are composed with Button through render, so the user gets a cancel and a confirm that both dismiss the dialog.
Root renders no DOM element, so it takes no className, style or ref — style the popup instead. It forwards every Base UI root prop: open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, triggerId and defaultTriggerId.
Import
import { Dialog, AlertDialog } from "@forte-ui/react";Examples
A close button in the corner
Dialog.Title is the popup's accessible name and Dialog.Description is announced straight after it, so between them they tell the user what this dialog is before they reach anything inside it. Sit the pair in a row with a Dialog.Close iconOnly and you have the header most dialogs want.
Three details there are load-bearing.
The heading column has to carry the popup's own gap. The stylesheet tightens Dialog.Description under Dialog.Title with a negative top margin — calc(var(--forte-space-1) - var(--forte-dialog-gap)) — which cancels the popup's gap and leaves a single step between the two. Wrap them in a plain <div> and the gap goes away while the margin stays, so the description rides up onto the title. Give the wrapper gap: var(--forte-dialog-gap) and the pair spaces itself exactly as it does when both are direct children of the popup.
iconOnly needs an aria-label. There is no text in the button to announce, so without one it is read out as "button". The styling already holds the 24px minimum hit target from WCAG SC 2.5.8, and pulls the button back into the popup's padding with a negative margin so the × sits in the corner rather than indented from it.
A close button at the top is also the initialFocus fix. Focus lands on the first tabbable element in the popup and the browser scrolls it into view — which is why a long dialog opens at the wrong end of itself unless you intervene. Put the × first in the DOM and it is the first tabbable element, so the dialog opens at the top with no initialFocus at all. See Scrolling outside the dialog.
A corner × and a footer are not alternatives. The × is the escape hatch that is always in the same place; the footer is where the decision lives. A dialog that only asks a question can skip the ×; one the user might simply be reading should have it.
Footer alignment
Dialog.Footer is a wrapping flex row with the standard action-row spacing, and align decides how the buttons are distributed along it.
end is the default and the one to reach for: the confirm lands closest to where the eye leaves the dialog. between pushes the two apart, which is what makes a destructive action harder to hit by accident — it is what the alert dialog above uses. center suits a single acknowledgement, and start a dialog read strictly top to bottom.
The row wraps, so on a narrow screen the buttons stack and the alignment applies per line. In a size="fullscreen" dialog the footer also gets margin-block-start: auto and sinks to the bottom edge, whichever align it carries.
Destructive confirmation
Use AlertDialog when the user has to answer before anything else happens. It is always modal and never closes on an outside press — there is deliberately no modal or disablePointerDismissal prop on AlertDialog.Root. Escape still closes it, so a dialog that offers no AlertDialog.Close would be dismissible by keyboard and by nothing else. Always give it at least one.
Dialog.Footer align="between" pushes the safe action away from the destructive one, which makes a mis-aimed click far less likely.
Sizes
size on Dialog.Popup sets the maximum width: sm (24rem), md (32rem, the default), lg (48rem) and fullscreen. Fullscreen drops the corner radius and the viewport padding, lets the surface fill the screen, and sinks the footer to the bottom edge — the usual mobile treatment for a long form. The popup is always inline-size: 100% up to that maximum, so every size shrinks to fit a narrow screen on its own.
Controlled
Pass open and onOpenChange to Dialog.Root to own the state yourself, and Dialog.Trigger leaves the tree entirely. That is the shape a dialog takes when nothing on screen is the button that opens it — a detail panel opened by clicking a row, a confirmation raised by a failed save, a wizard step bound to a route. Nothing else about the dialog changes; the trigger was only ever a convenience for setting a boolean.
Two things are easy to get wrong.
Accept every close. onOpenChange fires for Escape, an outside press and Dialog.Close alike. Filter one out and the dialog stays on screen after the user has already dismissed it — the gesture ran, your state disagreed, and the state wins. eventDetails.reason tells them apart if you need it ('escape-key', 'outside-press', 'close-press', 'trigger-press'), and disablePointerDismissal on Dialog.Root is the supported way to refuse one, rather than silently dropping it in the handler.
Let the content outlive the false. Base UI keeps the popup mounted until the closing gesture finishes, so state the dialog reads cannot be cleared at the same moment the dialog is told to close — the title and body would empty out in full view of the animation. Clear it in onOpenChangeComplete, which runs once the transition has ended.
Still close from inside the dialog with Dialog.Close rather than a button calling setOpen(false). The state change is identical; what differs is that Dialog.Close is announced as the dialog's close control, and it stays wired up if the dialog later becomes uncontrolled again.
Focus needs nothing extra in the usual case. With no trigger to return to, Base UI restores the element that was focused before the dialog opened — the row you clicked. Point finalFocus on Dialog.Popup at a ref for the cases where that element is not the right answer: a dialog opened by a timer or a route change, where nothing was focused, or one whose opening control has been removed from the page by the time it closes.
A form in a dialog
A form inside a dialog raises one question the dialog itself cannot answer: what closes it. The answer is that the form does.
The submit button is not a Dialog.Close. Dialog.Close closes on press — before the browser has validated anything — so composing the two would dismiss the dialog with an empty required field and submit nothing. Keep the submit button an ordinary Button type="submit", own the open state, and close from onFormSubmit, which only runs once every field passes. Cancel stays a real Dialog.Close: it is a close control and nothing else.
Wrap Dialog.Footer in the <Form> along with the fields, as the demo does, and the submit button is the form's own submit button with no form={id} attribute to keep in sync. Dialog.Footer is a plain <div>, so it nests inside a form without complaint.
An anchored popup opened inside a dialog — the Select here — needs nothing either. Both portal to <body>, and the dialog viewport sits at --forte-dialog-z-index: 40, one band under the 50 the anchored popups use, so the select's list is above the dialog whichever order the two portals happen to land in.
Nested dialogs
A dialog opened from inside another one stacks: the parent slides down by --forte-dialog-nested-offset per level, shrinks by --forte-dialog-nested-scale-step, and dims itself. Base UI drives this through the --nested-dialogs counter and the [data-nested-dialog-open] attribute, and suppresses the child's backdrop so the parent stays visible behind it rather than being scrimmed twice. Pass forceBackdrop to a nested Dialog.Popup if it genuinely needs its own scrim.
Escape closes the topmost dialog only, so a nested dialog steps back to its parent rather than dismissing the whole stack.
Scrolling outside the dialog
This is the default. Scrolling happens on the viewport that wraps the popup, not inside the popup. A tall dialog therefore scrolls as a whole, its top edge stays reachable, and a press in the empty space around it still counts as an outside press — so pointer dismissal keeps working. overscroll-behavior: contain stops a scroll that reaches the end of the dialog from chaining to the page behind it.
Style that scroll container with viewportClassName — for example to pin a dialog to the top of a tall screen instead of centring it.
Note the initialFocus on the popup, which is not decoration. On open, focus moves to the first tabbable element inside the popup, and the browser scrolls whatever it focuses into view. Here the first tabbable element is Decline, sixteen clauses down — so without it the terms open at the end of the terms, with the whole document already scrolled past. Pointing initialFocus at the popup itself leaves the viewport where it starts and still announces the title and description.
The default is right whenever the dialog fits on screen, which is why it is the default. Override it in any dialog long enough to scroll, or give the popup a tabbable element at the top — a Dialog.Close iconOnly in the corner does the job on its own, and is the header from A close button in the corner.
Scrolling inside the dialog
The other arrangement pins the title and the action row and scrolls only the body between them. It costs two declarations and no new parts: cap the popup with max-block-size: 100% — the viewport is position: fixed; inset: 0, so that is the screen less its own padding — and give the body a scroll container.
ScrollArea is doing the scrolling here rather than a bare overflow: auto, which buys the gradient edge fade, overlay scrollbars, a keyboard-reachable region, and an aria-label on it. flex: 1 1 auto hands it every pixel the title and footer do not use, and min-block-size: 0 is what lets it shrink — a flex item's automatic minimum size is its content, so without it the region refuses to go below the full height of the body and hands the overflow straight back to the popup, which is the arrangement you were trying to leave.
Prefer the default. Outside scroll keeps the whole dialog reachable, keeps the empty space around it an outside press, and never leaves a user scrolling a box inside a box. Reach for inside scroll when the actions have to stay visible while the body is read — a long agreement with Accept at the bottom of it, a picker whose Apply button must not scroll away.
Elements outside the popup
Some chrome belongs beside the panel rather than in it — a close button floating clear of the corner, a pager under a preview, a caption below a sheet. Render it inside Dialog.Popup anyway, and add a Dialog.Surface for the panel itself.
Dialog.Surface takes over the paint — background, radius, padding, gap, shadow — and Dialog.Popup becomes a transparent container that keeps everything else it was already doing: the width from size, the focus trap, the enter and exit gesture, the nested-stacking offsets. There is no prop to pair it with. The stylesheet asks :has(> .surface), so the popup switches on the presence of the element and the two cannot be set to disagree. Keep it a direct child.
The knobs do not move. --forte-dialog-bg, --forte-dialog-padding, --forte-dialog-radius, --forte-dialog-gap and --forte-dialog-shadow are still declared on the popup and still set on the popup — custom properties inherit, so the surface reads them from there.
Two things to know:
Pointer events are handed back one level. The container spans the popup's full width, so it is given pointer-events: none and its direct children get pointer-events: auto — otherwise a press in the empty space beside the close button would land on the container rather than the backdrop, and outside-press dismissal would stop working in a band across the screen. Wrap several controls in a row and that row is the direct child, so repeat the handoff on it: pointer-events: none on the wrapper, auto on the controls. The demo above does exactly this for its pager.
The viewport still clips. It is overflow-y: auto, which makes overflow-x compute to auto as well, so anything pushed outside the container with a negative offset can be cut off or add a scrollbar. Laying the outside elements out in the container — as flex children, the way the demo does — keeps them inside it and avoids the question. If you do need to escape the box, widen --forte-dialog-viewport-padding through viewportClassName to make room.
Programmatic dialogs
Some questions have no button on screen to hang a Dialog.Root off. A save fails and the app has to ask whether to retry; a navigation guard has to ask before throwing away a draft; a fetch wrapper gets a 409 and has to ask who wins. Writing those as JSX means hoisting a boolean, a payload and a "what did they answer" callback into a component that has nothing else to do with the dialog.
useDialog() is the other shape: open the dialog with a call, and await the answer.
const dialog = useDialog();
if (await dialog.confirm("Discard this draft?")) {
await discardDraft();
}Two pieces make one, exactly as with Toast: Dialog.Provider near the root of the app, and useDialog() wherever a question comes up.
Setup
import { Dialog } from "@forte-ui/react";
export default function App({ children }) {
return <Dialog.Provider>{children}</Dialog.Provider>;
}That is the whole setup. The provider renders no DOM element of its own and every dialog is portalled to <body>, so where it sits does not matter beyond being above the components that ask questions. Mount one, at the root.
labels is the one thing worth setting on it — the copy the built-in dialogs put on screen that the call site does not supply:
<Dialog.Provider
labels={{
ok: "Verstanden",
confirm: "Bestätigen",
cancel: "Abbrechen",
confirmInput: (value) => <>Tippen Sie <strong>{value}</strong> zum Bestätigen</>,
}}
>Set it once here rather than passing okLabel and cancelLabel to every call. This is the translation seam, and it is the reason dialog.confirm("Delete this?") can take a message and nothing else.
The three built-ins
alert() states something that has to be acknowledged, and resolves when it is. confirm() asks a yes-or-no question and resolves to a boolean. confirmWithInput() is confirm() with the confirm button disabled until the user types a value back — the pattern for a delete that cannot be undone.
await dialog.alert("Your export is ready");
const ok = await dialog.confirm({
title: "Delete “Orbit landing page”?",
description: "The project and its 42 deployments are removed immediately.",
tone: "danger",
confirmLabel: "Delete project",
});
const sure = await dialog.confirmWithInput({
title: "Delete “Orbit landing page”?",
confirmValue: "orbit-landing",
});All three take either a full options object or a bare message, and the two forms mean the same thing — a bare message becomes the title:
dialog.confirm("Discard this draft?");
dialog.confirm({ title: "Discard this draft?" });title and description are the same two fields a toast takes, and they mean the same thing here: the title is the dialog's accessible name, the description is announced after it. That is why a bare message becomes the title rather than the body — a dialog with no Dialog.Title is announced as just "dialog".
Four details are decided for you, and each is a decision the composed API leaves open:
All three are AlertDialogs. They interrupt to ask something, so they are always modal and never dismissed by a press outside them. Escape still closes them — which resolves confirm() to false, the same as Cancel.
tone is the only styling axis. It colours the answering button, and "danger" also moves the footer to align="between", pushing the safe answer away from the destructive one. There is no severity prop: variant and tone are the library's two axes and a third would be a third vocabulary for the same idea.
Focus lands on the safe answer. Cancel comes first in the DOM, so it is the first tabbable element in the popup and takes focus when the dialog opens. In confirmWithInput() the input comes first instead, which is where the user has to go anyway.
confirmWithInput() unlocks the keyboard and the pointer together. The value is matched with surrounding whitespace trimmed — a pasted value often carries a trailing space, and that is not a different answer — case-sensitively unless you pass caseSensitive: false. The confirm button is a form submit button, so Enter confirms; while it is disabled the browser suppresses implicit submission too, so there is no second condition to keep in sync.
Dialogs of your own
show() takes a component and a payload, and resolves with whatever that component passes to close().
target: staging
The component renders the contents of the dialog — one Dialog.Popup — and not the root. The provider owns the root, which is what buys three things the component would otherwise have to get right itself: the dialog stacks on whatever is already open, it stays in the tree until its exit transition ends rather than vanishing, and its promise settles even when it is dismissed instead of answered.
function ChooseEnvironment({ payload, close }: CustomDialogProps<{ current: string }, string>) {
return (
<Dialog.Popup size="sm">
<Dialog.Title>Deploy to…</Dialog.Title>
{/* … */}
</Dialog.Popup>
);
}
const chosen = await dialog.show(ChooseEnvironment, { current: "staging" });show() returns Promise<Result | undefined>, and the undefined is not pedantry — Escape, an outside press and the provider unmounting all end the dialog without an answer, so the call site has to say what happens then. Give it something to resolve with instead if undefined is awkward:
const chosen = await dialog.show(ChooseEnvironment, payload, { dismissValue: "staging" });There are two ways to end a custom dialog and they mean different things. close(result) is an answer — the promise gets that value. dismiss() is a dismissal — the promise gets dismissValue, exactly as Escape would. It exists because close() demands a Result and a cancel button has none to give. A plain Dialog.Close inside the popup works too and counts as a dismissal, which is the shape to reach for when the cancel button has no condition on it.
Pass { alert: true } to render an AlertDialog.Root instead: always modal, no outside-press dismissal, for a dialog of your own that also has to be answered.
Stacking
A dialog opened while another is on screen stacks on it — the parent steps back, shrinks and dims, exactly as nested dialogs do — because that is literally what it is. The provider renders its stack nested, each dialog inside the one below it.
Orbit landing page
That is not an implementation detail you can ignore, because it is what useDialog() inside a dialog gets you: a custom dialog can await dialog.confirm(…) and the confirmation lands on top of it rather than beside it. Base UI decides a dialog is nested by looking for a parent Root in the React tree, so a stack rendered as siblings would draw a second backdrop over the first dialog and skip the step-back entirely.
Escape closes the topmost dialog only, so a stack unwinds one level at a time. dialog.close() does the same thing from code; dialog.closeAll() dismisses the whole stack, resolving every pending promise on the way out.
A dialog opened after another one has been answered is not stacked on it, and that distinction is the reason this works at all. Each dialog's parent is decided once, when it opens, and it is whichever dialog is on screen at that moment — not simply the last one, which may be one that has already been answered and is still finishing its exit. So the most ordinary sequence there is —
if (await dialog.confirm("Publish to production?")) {
await dialog.alert("Publishing. You will get an email when the build finishes.");
}— renders the two as siblings: the first fades out while the second fades in, each with its own scrim, neither stepped back. Stack them instead and the second would be a child of a dialog that is about to be removed, and it would remount mid-enter as it took the parent's place in the tree.
Outside React
Plenty of the code that needs to ask a question is not a component: a fetch wrapper, a router guard, a store. Dialog.createManager() gives you the same API as a plain object.
idle
// dialogs.ts
import { Dialog } from "@forte-ui/react";
export const dialogs = Dialog.createManager();
// api.ts — no React anywhere
if (response.status === 409 && (await dialogs.confirm("Overwrite the newer version?"))) {
await save({ force: true });
}Connect it with manager on the provider. Pass the manager itself — unlike Toast.Provider, there is no Base UI primitive underneath to hand a different object to.
A dialog opened before a provider has mounted is queued, not dropped, which is where this parts company with Toast.createManager(). A dropped toast costs a message; a dropped dialog would leave its promise pending for the life of the page, and the await behind it would never return. It appears as soon as a provider mounts. If none ever does, the await never returns either — so mount the provider at the root, once.
useDialog() under a provider that was given a manager reaches the same stack, so the two are not separate queues.
What settles the promise
Every method resolves when the user answers or dismisses, not when the dialog finishes animating away. An await dialog.confirm() continues the moment the decision is made; the popup is still fading out behind the work it unblocked.
| It ends by | alert() | confirm() and confirmWithInput() | show() |
|---|---|---|---|
| the affirmative button | resolves | true | whatever close() was given |
Cancel, or dismiss() | — | false | dismissValue |
| Escape | resolves | false | dismissValue |
| an outside press | never happens | never happens | dismissValue, unless alert: true |
dialog.close() / closeAll() | resolves | false | dismissValue |
| the provider unmounting | resolves | false | dismissValue |
That last row is the one worth knowing about. A dialog that leaves the screen with its provider — a route change, a hot reload, a boundary catching an error — was answered by nobody, and its promise would otherwise stay pending forever. The provider settles every dialog it is holding as it unmounts, with the same value Escape would have produced.
A dialog settles exactly once. The answering buttons record the result before they start the close, and the close request that follows carries the dismissal value; the first of the two wins, so a confirm() answered true does not turn into false on the way out.
Accessibility
| Key | Behaviour |
|---|---|
| Space | Opens the dialog when the trigger is focused. Closes it when a Close button is focused. |
| Enter | Opens the dialog when the trigger is focused. Closes it when a Close button is focused. |
| Esc | Requests close on the topmost dialog, including an alert dialog, and returns focus to the trigger. |
| Tab | Moves to the next focusable element inside the popup, looping at the end while the dialog is modal. |
| Shift + Tab | Moves to the previous focusable element inside the popup, looping at the start. |
Dialog.Title renders an <h2> and becomes the popup's aria-labelledby; Dialog.Description renders a <p> and becomes its aria-describedby. Both are wired up automatically — but only because they are rendered inside Dialog.Popup. Move either one outside and the popup silently loses that association.
When the dialog is modal (the default, and the only option for AlertDialog), focus is trapped inside the popup, page scroll is locked, and the rest of the document is made inert. On open, focus moves to the first tabbable element in the popup — except when the dialog was opened by touch, where the popup itself is focused so the virtual keyboard does not spring up. On close, focus returns to the trigger. Override either end with initialFocus and finalFocus on Dialog.Popup. A dialog tall enough to scroll usually needs the first of those — see Scrolling outside the dialog.
The popup itself carries a focus ring, because it is the element that receives focus on a touch-opened or keyboard-opened dialog. It also draws a hairline border (--forte-dialog-border-color) so its edge keeps its contrast where the shadow cannot promise it: over an already-dark page in dark mode, or under a light scrim. In forced-colors mode, where the shadow is stripped by the OS entirely, that same border is repainted in CanvasText and becomes the boundary.
Theming
Every property below is declared on the element it styles — all but the first on the popup, --forte-dialog-viewport-padding on the scroll container around it. An element's own declaration beats an inherited value, so setting one on :root, on a theme class or on a wrapper has no effect. The popup is portalled to <body> as well, so an ancestor of the trigger could not reach it in any case.
Set the popup's knobs on the popup itself, through className on Dialog.Popup (an unlayered rule beats the library's @layer forte.components, whatever its specificity) or an inline style. --forte-dialog-viewport-padding goes through viewportClassName.
A Dialog.Surface changes none of this. It reads --forte-dialog-bg, --forte-dialog-fg, --forte-dialog-radius, --forte-dialog-padding, --forte-dialog-gap and --forte-dialog-shadow off the popup by inheritance, so the knobs stay where you already set them.
The global tokens these resolve to — --forte-color-*, --forte-radius-*, --forte-space-*, --forte-shadow-* — are the exception: the component only reads those and never redeclares them, so re-pointing them on :root or on a theme scope does re-skin every dialog.
| Property | Controls | Default |
|---|---|---|
--forte-dialog-backdrop-exit-duration | How long the scrim takes to clear; set through backdropClassName | var(--forte-duration-fast) |
--forte-dialog-backdrop-exit-ease | Curve the scrim clears on; set through backdropClassName | var(--forte-ease-exit) |
--forte-dialog-viewport-padding | Gap between the popup and the edge of the screen. size="fullscreen" drops it to 0px. | var(--forte-space-4) |
--forte-dialog-bg | Popup background | var(--forte-color-overlay) |
--forte-dialog-fg | Popup text colour | var(--forte-color-foreground) |
--forte-dialog-radius | Corner radius. size="fullscreen" squares it off to 0px. | var(--forte-radius-surface) |
--forte-dialog-padding | Padding inside the popup | var(--forte-surface-p) |
--forte-dialog-gap | Vertical gap between the popup's children | var(--forte-space-4) |
--forte-dialog-shadow | Popup shadow | var(--forte-shadow-4) |
--forte-dialog-border-width | Width of the hairline that separates the popup from the page | 1px |
--forte-dialog-border-color | Colour of that hairline. transparent when size="fullscreen". | var(--forte-color-border) |
--forte-dialog-font-size | Base font size inside the popup | var(--forte-font-size-2) |
--forte-dialog-max-inline-size | Maximum popup width; what the size prop sets — 24rem for sm, 48rem for lg, 100% for fullscreen. | 32rem |
--forte-dialog-nested-offset | How far each nested dialog pushes its parent down | var(--forte-travel-lg) |
--forte-dialog-nested-scale-step | How much each nested dialog shrinks its parent | 0.04 |
--forte-dialog-nested-duration | How long the parent takes to retreat under a child | var(--forte-duration-spring-precise) |
--forte-dialog-enter-travel | How far the popup rises as it opens. var(--forte-travel-page) when size="fullscreen". | var(--forte-travel-md) |
--forte-dialog-enter-scale | Scale the popup grows from. 1 when size="fullscreen". | var(--forte-scale-enter) |
--forte-dialog-enter-duration | Length of the opening gesture | var(--forte-duration-normal) |
--forte-dialog-enter-ease | Curve of the opening gesture | var(--forte-ease-spring-snappy) |
--forte-dialog-exit-travel | How far the popup sinks as it closes. var(--forte-travel-page) when size="fullscreen". | var(--forte-travel-md) |
--forte-dialog-exit-scale | Scale the popup shrinks to. 1 when size="fullscreen". | calc(1 - 0.05 * var(--forte-motion-ok)) |
--forte-dialog-exit-duration | Length of the closing gesture | var(--forte-duration-fast) |
--forte-dialog-exit-ease | Curve the closing geometry runs on | linear |
--forte-dialog-exit-fade-ease | Curve the closing fade runs on | var(--forte-ease-exit) |
Motion
The dialog opens and closes with the same gesture in opposite directions — it rises 8px and grows from 0.95 on the way in, sinks 8px and shrinks to 0.95 on the way out — so an open that gets interrupted halfway simply runs back down the path it came up. Opening rides a spring over --forte-duration-normal — the same 240ms the anchored popups take — because the dialog should not feel heavier to open than the Select sitting next to it. Closing runs shorter still, on --forte-duration-fast: an arrival has to read as a surface settling into place, while a dismissal the user already decided on only needs confirming.
Closing is where the curves stop being interchangeable, and the exit geometry is linear on purpose — the one place in the library that wants it. Two rules are in tension, and easing curves can only satisfy one at a time:
- Movement is only visible while the thing moving is still opaque. So the fade takes
--forte-ease-exit, which holds around0.75opacity through the midpoint rather than dropping away immediately. - The movement must not finish before the element does. An exit does not arrive anywhere — it is cut short when the popup unmounts — and an easing curve is a statement about arriving.
Break rule 1 (put an accelerating curve on the geometry, the obvious reading of "ease out of the way") and the popup travels about one pixel while it is still opaque, spending the rest after it has faded. Break rule 2 (put a decelerating curve on it) and the popup covers 88% of its travel in the first half, then crawls 0.08px through the final 40ms while still fading down from 68% opacity — it appears to freeze mid-flight and then get yanked out of the DOM. Both read as "there is no exit animation", for opposite reasons.
linear satisfies both: a uniform 1.33px per 40ms, still moving on the frame it disappears.
The scrim is part of the same gesture: it holds for the popup's full exit rather than --forte-duration-fast, because a page snapping back to full brightness underneath a still-leaving dialog is a much larger visual event than the dialog itself, and the eye follows it instead.
A size="fullscreen" sheet has no visible edges for a scale to work against, so it drops the scale entirely and travels further instead.
A dialog raised by useDialog() gets the same entrance as one opened by a trigger, and that takes one deliberate step. Base UI seeds its transition state from open on a root's first render, so a Dialog.Root that mounts already open is treated as having always been open — no [data-starting-style], no enter transition, the popup simply appears. It is the same reason a defaultOpen dialog does not animate in. The provider therefore mounts each dialog closed and opens it in a layout effect, which gives Base UI the false→true edge it watches for and costs no frame: the effect runs before paint, so the popup is painted once, already carrying its starting style.
Reduced motion needs no work from you: the travel tokens collapse to 0px, --forte-scale-enter to 1, --forte-dialog-exit-scale resolves to exactly 1 through --forte-motion-ok, and the durations shorten — leaving the fade, which is the part reduced-motion users still want. There is no component-level prefers-reduced-motion override to fight.
API reference
Dialog.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Pass render={<Button />} to compose the trigger with a styled button. Its own neutral styling steps aside when render is present, so the two never fight over the cascade. Every Base UI trigger prop (handle, payload, nativeButton, id, style) is forwarded.
AlertDialog.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Dialog.Popup
| Prop | Type | Default | Description |
|---|---|---|---|
backdrop | boolean | true | Render the scrim behind the popup. Turn it off for a non-modal dialog that should leave the page visibly usable. |
backdropClassName | string | Additional class name(s) for the backdrop element. The popup's own `className` cannot reach it, since the backdrop is a sibling rendered inside this component. | |
className | string | Additional class name(s) for the popup itself. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Element the portal renders into. Defaults to `document.body`; set it when the dialog must live inside a specific stacking or shadow root. | |
forceBackdrop | boolean | false | Render the backdrop even when this dialog is nested inside another one. Base UI suppresses nested backdrops by default — the parent dims itself through `[data-nested-dialog-open]` instead — so only set this when a nested dialog genuinely needs its own scrim. |
keepMounted | boolean | false | Keep the portal — and therefore the popup — in the DOM while the dialog is closed. Needed when something inside must stay mounted (an iframe, a media element, uncommitted form state). |
size | DialogSize | md | Maximum width of the popup. `"fullscreen"` drops the radius and the viewport padding and lets the surface fill the screen, which is the usual mobile treatment for a long form. |
viewportClassName | string | Additional class name(s) for the scrollable viewport that positions the popup. Use it to change alignment (e.g. pin the dialog to the top), or to re-point `--forte-dialog-z-index` — it defaults to 40, one band under the anchored popups, so a `Select` or `Tooltip` inside a dialog stays above it. The backdrop reads `--forte-dialog-backdrop-z-index` (39) from `backdropClassName`, since it is the viewport's sibling. |
initialFocus, finalFocus, render and the rest of Base UI's Popup props are forwarded unchanged. keepMounted keeps the portal in the DOM while the dialog is closed, which is what you need when something inside must survive a close — an iframe, a media element, uncommitted form state.
Dialog.Surface
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Dialog.Surface is not a Base UI part — it is a <div> carrying the panel's paint — so it takes every ordinary <div> prop. Use it only when something has to sit outside the panel; a dialog without one is already a panel.
Dialog.Title
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Dialog.Description
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Dialog.Close
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
iconOnly | boolean | false | Render as a square button sized for a single icon — the corner "×" treatment. Enforces the 24px minimum hit target from WCAG SC 2.5.8. Always pair with `aria-label`, since there is no text to announce. |
Dialog.Footer
| Prop | Type | Default | Description |
|---|---|---|---|
align | DialogFooterAlign | end | How the actions are distributed along the footer. `"between"` is the pattern for a destructive action pushed away from the safe one. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Dialog.Footer is not a Base UI part — it is a <div> with the standard action-row spacing — so it takes every ordinary <div> prop.
Dialog.Root and AlertDialog.Root
Neither root renders a DOM element, so neither accepts className, style or ref, and neither appears in the generated tables above. Dialog.Root forwards Base UI's root props unchanged; AlertDialog.Root forwards the same set minus modal and disablePointerDismissal. Both namespaces also expose createHandle() for driving a dialog from triggers rendered outside its Root.
useDialog()
Returns one object. It and every method on it keep the same identity for the life of the provider, so both the object and one pulled out by destructuring are safe in a dependency array.
| Member | Signature | What it does |
|---|---|---|
alert | (message | options) => Promise<void> | States something that has to be acknowledged. |
confirm | (message | options) => Promise<boolean> | Asks a yes-or-no question. |
confirmWithInput | (options) => Promise<boolean> | Asks one whose confirm button unlocks on a typed value. |
show | (Component, payload, options?) => Promise<Result | undefined> | Shows a component of your own. |
close | () => void | Dismisses the topmost dialog, exactly as Escape does. |
closeAll | () => void | Dismisses every dialog on the stack. |
message is either a ReactNode — which becomes the title — or the options object. Calling the hook outside a Dialog.Provider throws.
Dialog.Provider
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The part of the app that can open dialogs. | |
labels | Partial<DialogLabels> | Overrides for the built-in button labels — the only copy `alert()`, `confirm()` and `confirmWithInput()` put on screen that the call site does not supply. Set it once here instead of passing `okLabel` and `cancelLabel` to every call; this is the translation seam. | |
manager | DialogApi | A manager from `Dialog.createManager()`. Pass one and the provider drives that manager's stack instead of its own, which is what lets code outside React open a dialog. `useDialog()` reaches the same stack either way. |
Built-in dialog options
alert(), confirm() and confirmWithInput() share the first five; the rest are per method. Anything left out falls back to the provider's labels.
| Option | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | The question or statement, and the dialog's accessible name. |
description | ReactNode | — | A second line under it, and its accessible description. |
size | DialogSize | "sm" | Maximum width of the popup. |
tone | ButtonTone | "primary" | Tone of the answering button. confirmWithInput() defaults to "danger". |
className | string | — | Class name(s) for the popup — where a one-off --forte-dialog-* knob goes. |
okLabel | ReactNode | labels.ok | alert() only. Label of the single button. |
confirmLabel | ReactNode | labels.confirm | Label of the button that resolves true. |
cancelLabel | ReactNode | labels.cancel | Label of the button that resolves false. |
align | DialogFooterAlign | "between" when tone is "danger", else "end" | How the two buttons are distributed. |
confirmValue | string | required | confirmWithInput() only. What has to be typed. |
inputLabel | ReactNode | labels.confirmInput(confirmValue) | Label above the input. |
placeholder | string | confirmValue | Placeholder inside the input. |
caseSensitive | boolean | true | Whether the typed value has to match case. |
DialogLabels
The provider's labels prop takes any subset of these.
| Member | Type | Default |
|---|---|---|
ok | ReactNode | "OK" |
confirm | ReactNode | "Confirm" |
cancel | ReactNode | "Cancel" |
confirmInput | (confirmValue: string) => ReactNode | Type <strong>{value}</strong> to confirm |
CustomDialogProps
What a component handed to dialog.show() receives.
| Member | Type | What it does |
|---|---|---|
payload | Payload | The second argument show() was called with. |
close | (result: Result) => void | Answers the dialog and starts the exit. Safe to call twice. |
dismiss | () => void | Ends it with dismissValue, exactly as Escape does. |
DialogShowOptions is the third argument to show(): alert (default false) renders an AlertDialog.Root instead of a Dialog.Root, and dismissValue (default undefined) is what the promise resolves to when the dialog is dismissed rather than answered.
Dialog.createManager()
Returns everything useDialog() returns, as a plain object usable outside React. Hand it to Dialog.Provider's manager prop — the manager itself, not something inside it.