Skip to main content

Dialog

A window overlaid on either the primary window or another dialog window, rendering the content underneath inert. Built on top of Radix UI Dialog.

When to use

Use Dialog for a centered modal that interrupts the user to gather input or confirm a non-destructive choice. For destructive confirmations (delete, irreversible actions), use AlertDialog. For side-panel content (filter panels, detail/inspector views, navigation drawers), use Sheet.

import { Dialog } from "@ngrok/mantle/dialog";import { Button } from "@ngrok/mantle/button"; <Dialog.Root>	<Dialog.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			Open dialog		</Button>	</Dialog.Trigger>	<Dialog.Content>		<Dialog.Header>			<Dialog.Title>Are you absolutely sure?</Dialog.Title>			<Dialog.CloseIconButton />		</Dialog.Header>		<Dialog.Body>			This action cannot be undone. This will permanently delete your account and remove your data			from our servers.		</Dialog.Body>		<Dialog.Footer>			<Dialog.Close asChild>				<Button type="button" intent="neutral" appearance="outlined">					Cancel				</Button>			</Dialog.Close>			<Dialog.Close asChild>				<Button type="button" intent="danger" appearance="filled">					Delete				</Button>			</Dialog.Close>		</Dialog.Footer>	</Dialog.Content></Dialog.Root>;

Layering and z-index

Dialog.Content renders its floating layer at Tailwind z-60, Mantle's overlay tier, above every float (z-50). Floats composed inside the content portal into the positioner (data-slot="dialog-positioner") and paint above the content. When multiple overlays are open, the most recently mounted overlay renders on top.

Dialog, Sheet, and AlertDialog each render their own backdrop, so stacking modal layers compounds the dim and blur effect on the layers underneath.

UI above an open dialog

UI that must take clicks above an open dialog, such as a consent banner, carries the overlay-prompt class. A click inside it does not count as an outside click. Add pointer-events-auto too, because the open dialog sets pointer-events: none on <body>. mantle's Toaster does both.

<div className="overlay-prompt pointer-events-auto fixed inset-x-0 bottom-0 z-max">	<ConsentBanner /></div>

The class reaches pointer input only. The open dialog traps keyboard focus and hides outside content from assistive technology with aria-hidden, so a keyboard or screen reader user cannot reach the banner. Put a control that every user must reach inside the dialog content.

Handling autofill overlays

When a dialog contains form inputs, browser autofill or password-manager plugins (e.g. 1Password) may render overlays as siblings to the dialog content. By default, clicking these overlays triggers onPointerDownOutside, which closes the dialog. Use isDialogOverlayTarget to prevent this:

import { Dialog, isDialogOverlayTarget } from "@ngrok/mantle/dialog"; <Dialog.Content	onPointerDownOutside={(event) => {		// Allow closing only when clicking the actual overlay backdrop,		// not browser autofill/password-manager popups		if (isDialogOverlayTarget(event.target)) {			return;		}		event.preventDefault();	}}>	{/* dialog content with form inputs */}</Dialog.Content>;

Composition

Compose the parts of a Dialog together to build your own:

Dialog.Root├── Dialog.Trigger└── Dialog.Content    ├── Dialog.Header    │   ├── Dialog.Title    │   ├── Dialog.Description    │   └── Dialog.CloseIconButton    ├── Dialog.Body    └── Dialog.Footer        └── Dialog.Close

Header with a description

Dialog.Title and Dialog.Description stack in a column inside Dialog.Header. Every other child shares the title's row. A long title truncates beside Dialog.CloseIconButton, and the description wraps under both.

The first dialog pairs a short title with a long description. The second pairs a long title with a one-line description in a wider dialog. In both, the title keeps its width and the description sits under it.

import { Button } from "@ngrok/mantle/button";import { Code } from "@ngrok/mantle/code";import { Dialog } from "@ngrok/mantle/dialog"; <Dialog.Root>	<Dialog.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			Decommission a runner		</Button>	</Dialog.Trigger>	<Dialog.Content>		<Dialog.Header>			<Dialog.Title>Request decommission?</Dialog.Title>			<Dialog.Description>				<Code>runner-ecs-us-east-1-7f3a9c</Code> drains before it leaves the pool. It keeps its				assigned replicas until it acknowledges the request, then the scheduler removes it.			</Dialog.Description>			<Dialog.CloseIconButton />		</Dialog.Header>		<Dialog.Body>			<p>				Queued work on this runner finishes first. New work goes to the other runners in the pool.			</p>		</Dialog.Body>		<Dialog.Footer>			<Dialog.Close asChild>				<Button type="button" appearance="outlined" intent="neutral">					Cancel				</Button>			</Dialog.Close>			<Dialog.Close asChild>				<Button type="button" appearance="filled" intent="danger">					Request decommission				</Button>			</Dialog.Close>		</Dialog.Footer>	</Dialog.Content></Dialog.Root>; <Dialog.Root>	<Dialog.Trigger asChild>		<Button type="button" appearance="outlined" intent="neutral">			Rotate a signing key		</Button>	</Dialog.Trigger>	<Dialog.Content preferredWidth="max-w-3xl">		<Dialog.Header>			<Dialog.Title>				Rotate the signing key for every endpoint on the staging cluster and revoke the current key				after the next deploy?			</Dialog.Title>			<Dialog.Description>				Connected clients re-authenticate on their next request.			</Dialog.Description>			<Dialog.CloseIconButton />		</Dialog.Header>		<Dialog.Body>			<p>The current key stays valid for one hour after the rotation.</p>		</Dialog.Body>		<Dialog.Footer>			<Dialog.Close asChild>				<Button type="button" appearance="outlined" intent="neutral">					Cancel				</Button>			</Dialog.Close>			<Dialog.Close asChild>				<Button type="button" appearance="filled" intent="neutral">					Rotate key				</Button>			</Dialog.Close>		</Dialog.Footer>	</Dialog.Content></Dialog.Root>;

Full-page dialog

Every dialog already sits 16px from each viewport edge: Dialog.Content positions itself inside a fixed inset-4 wrapper, so you never add that margin yourself. appearance decides how much of the box it claims from there:

appearanceWidthViewport insetCorners and border
"centered"capped at preferredWidth16pxrounded and bordered
"full-page"fills the box16pxrounded and bordered
"full-bleed"fills the boxnonesquare, no border

The three values move together as one decision, because a dialog that fills the viewport has no width left to cap and no corner left to round. preferredWidth therefore belongs to "centered" only: passing it beside "full-page" or "full-bleed" is a type error, not a prop that silently does nothing.

// a card, capped — the default<Dialog.Content preferredWidth="max-w-2xl"> // fills the 16px-inset box, still a rounded card<Dialog.Content appearance="full-page"> // fills the whole viewport, square and unbordered<Dialog.Content appearance="full-bleed">

The parts keep their roles at every appearance. Because Dialog.Content is a flex column with Dialog.Body at flex-1 overflow-y-auto, the header and footer stay pinned while the body scrolls.

When the content needs the whole viewport, reach for this: a request log, a diff, a media preview, a multi-column editor. When the content deserves its own URL, a back button, or a shareable link, a route beats a dialog.

"full-bleed" keeps its backdrop. The content opens at zoom-in-95, so a sliver of viewport edge stays uncovered for the length of the animation, and the overlay is what fills it.

The switch in the demo body swaps "full-page" for "full-bleed".

import { Badge } from "@ngrok/mantle/badge";import { Button } from "@ngrok/mantle/button";import { Dialog } from "@ngrok/mantle/dialog";import { Field } from "@ngrok/mantle/field";import { Switch } from "@ngrok/mantle/switch";import { useState } from "react"; const sampleRequests = [	{ method: "GET", path: "/api/v2/endpoints", status: 200, durationMs: 12 },	{ method: "POST", path: "/api/v2/tunnels", status: 201, durationMs: 84 },	{ method: "GET", path: "/api/v2/reserved_domains", status: 200, durationMs: 21 },	{ method: "DELETE", path: "/api/v2/endpoints/ep_2f8c1a", status: 204, durationMs: 33 },	{ method: "GET", path: "/api/v2/edges/https/edghts_1x9d", status: 502, durationMs: 1204 },	{ method: "PATCH", path: "/api/v2/api_keys/ak_71bd", status: 200, durationMs: 47 },]; // The sample repeats so the log overflows `Dialog.Body` at full height.const requestLog = Array.from({ length: 7 })	.flatMap(() => sampleRequests)	.map((request, index) => ({ ...request, id: `req_${4200 + index}` })); function RequestLogDialog() {	const [fullBleed, setFullBleed] = useState(false); 	return (		<Dialog.Root>			<Dialog.Trigger asChild>				<Button type="button" appearance="filled" intent="neutral">					Open full-page dialog				</Button>			</Dialog.Trigger>			<Dialog.Content appearance={fullBleed ? "full-bleed" : "full-page"}>				<Dialog.Header>					<Dialog.Title>Request log</Dialog.Title>					<Dialog.CloseIconButton />				</Dialog.Header>				<Dialog.Body className="p-0">					<div className="border-dialog-muted bg-dialog sticky top-0 border-b px-6 py-3">						<Field.Item name="fullBleed">							<Field.Label className="flex items-center gap-2">								<Field.Control>									<Switch checked={fullBleed} onCheckedChange={setFullBleed} />								</Field.Control>								Full bleed							</Field.Label>						</Field.Item>					</div>					<div className="divide-card-muted divide-y font-mono text-sm">						{requestLog.map((request) => (							<div								key={request.id}								className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-3 px-4 py-2 sm:px-6"							>								<span className="text-muted">{request.method}</span>								<span className="text-strong truncate">{request.path}</span>								<Badge appearance="muted" color={request.status >= 500 ? "danger" : "success"}>									{request.status}								</Badge>								<span className="text-muted text-right">{request.durationMs}ms</span>							</div>						))}					</div>				</Dialog.Body>				<Dialog.Footer>					<Dialog.Close asChild>						<Button type="button" appearance="outlined" intent="neutral">							Close						</Button>					</Dialog.Close>				</Dialog.Footer>			</Dialog.Content>		</Dialog.Root>	);}

Combining with a Tooltip

In some cases, you might wish to have a tooltip over the dialog trigger. If the dialog trigger is an IconButton, a tooltip adds context about what the button does. Compose the two so the dialog trigger is also the tooltip trigger.

import { Dialog } from "@ngrok/mantle/dialog";import { Button, IconButton } from "@ngrok/mantle/button";import { Tooltip } from "@ngrok/mantle/tooltip";import { TrashSimpleIcon } from "@phosphor-icons/react/TrashSimple"; <Dialog.Root>	<Tooltip.Root>		<Tooltip.Trigger asChild>			<Dialog.Trigger asChild>				<IconButton					type="button"					appearance="outlined"					intent="neutral"					label="Delete"					size="sm"					icon={<TrashSimpleIcon />}				/>			</Dialog.Trigger>		</Tooltip.Trigger>		<Tooltip.Content>			<p>Delete</p>		</Tooltip.Content>	</Tooltip.Root>	<Dialog.Content>		<Dialog.Header>			<Dialog.Title>Are you absolutely sure?</Dialog.Title>			<Dialog.CloseIconButton />		</Dialog.Header>		<Dialog.Body>			This action cannot be undone. This will permanently delete your account and remove your data			from our servers.		</Dialog.Body>		<Dialog.Footer>			<Dialog.Close asChild>				<Button type="button" intent="neutral" appearance="outlined">					Cancel				</Button>			</Dialog.Close>			<Dialog.Close asChild>				<Button type="button" intent="danger" appearance="filled">					Delete				</Button>			</Dialog.Close>		</Dialog.Footer>	</Dialog.Content></Dialog.Root>;

Stacking a Sheet over a Dialog

Dialog and Sheet share the overlay tier (z-60), so the most recently mounted overlay renders on top. Open the dialog below first, then open the sheet from inside it: the sheet stacks over the dialog rather than disappearing behind it. Each overlay renders its own backdrop, so the dialog beneath is dimmed by the sheet's overlay.

import { Dialog } from "@ngrok/mantle/dialog";import { Sheet } from "@ngrok/mantle/sheet";import { Button } from "@ngrok/mantle/button"; <Dialog.Root>	<Dialog.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			Open dialog		</Button>	</Dialog.Trigger>	<Dialog.Content>		<Dialog.Header>			<Dialog.Title>Dialog</Dialog.Title>			<Dialog.CloseIconButton />		</Dialog.Header>		<Dialog.Body className="space-y-4">			<p>Open the sheet below — it stacks on top of this dialog.</p>			<Sheet.Root>				<Sheet.Trigger asChild>					<Button type="button" appearance="outlined" intent="neutral">						Open sheet					</Button>				</Sheet.Trigger>				<Sheet.Content>					<Sheet.Header>						<Sheet.TitleGroup>							<Sheet.Title>Sheet over a dialog</Sheet.Title>							<Sheet.Actions>								<Sheet.CloseIconButton />							</Sheet.Actions>						</Sheet.TitleGroup>						<Sheet.Description>							This sheet renders on top of the dialog that opened it.						</Sheet.Description>					</Sheet.Header>					<Sheet.Body>						<p>Close this sheet to return to the dialog underneath.</p>					</Sheet.Body>					<Sheet.Footer>						<Sheet.Close asChild>							<Button type="button" appearance="outlined" intent="neutral">								Close							</Button>						</Sheet.Close>					</Sheet.Footer>				</Sheet.Content>			</Sheet.Root>		</Dialog.Body>		<Dialog.Footer>			<Dialog.Close asChild>				<Button type="button" appearance="outlined" intent="neutral">					Close				</Button>			</Dialog.Close>		</Dialog.Footer>	</Dialog.Content></Dialog.Root>;

With TanStack Form

Use TanStack Form with Field to gather validated input in a dialog. Dialog.Content is a flex column, so wrap Dialog.Body and Dialog.Footer in a display: contents form: the footer's submit button participates in the form while the body keeps its scrollable layout. Close the dialog from the form's onSubmit (it only runs once validation passes) and reset the form when the dialog closes so a cancelled draft doesn't leak into the next open. The form sets noValidate so the browser's native type="email" validation doesn't block the submit event: the schema owns validation and errors render consistently through Field.Errors.

If password managers or browser autofill overlays interfere with outside-click dismissal, see Handling autofill overlays.

import { Button } from "@ngrok/mantle/button";import { Dialog } from "@ngrok/mantle/dialog";import { Field, toErrorMessages } from "@ngrok/mantle/field";import { Input } from "@ngrok/mantle/input";import { Select } from "@ngrok/mantle/select";import { useForm } from "@tanstack/react-form";import { useState } from "react";import { z } from "zod"; const formSchema = z.object({	email: z.email("Enter a valid email address."),	role: z.string().min(1, "Choose a role."),}); function Example() {	const [open, setOpen] = useState(false);	const form = useForm({		defaultValues: {			email: "",			role: "",		},		validators: {			onSubmit: formSchema,		},		onSubmit: ({ value }) => {			// Handle the invite with `value`, then close the dialog			handleOpenChange(false);		},	}); 	const handleOpenChange = (nextOpen: boolean) => {		setOpen(nextOpen);		if (!nextOpen) {			form.reset();		}	}; 	return (		<Dialog.Root open={open} onOpenChange={handleOpenChange}>			<Dialog.Trigger asChild>				<Button type="button" appearance="filled" intent="neutral">					Invite a teammate				</Button>			</Dialog.Trigger>			<Dialog.Content>				<Dialog.Header>					<Dialog.Title>Invite a teammate</Dialog.Title>					<Dialog.CloseIconButton />				</Dialog.Header>				<form					className="contents"					noValidate					onSubmit={(event) => {						event.preventDefault();						event.stopPropagation();						void form.handleSubmit();					}}				>					<Dialog.Body className="space-y-4">						<form.Field name="email">							{(field) => (								<Field.Item name={field.name}>									<Field.Label>Email</Field.Label>									<Field.Control>										<Input											type="email"											placeholder="teammate@example.com"											value={field.state.value}											onBlur={field.handleBlur}											onChange={(event) => field.handleChange(event.target.value)}										/>									</Field.Control>									<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />								</Field.Item>							)}						</form.Field>						<form.Field name="role">							{(field) => (								<Field.Item name={field.name}>									<Field.Label>Role</Field.Label>									<Field.Control>										<Select.Root											value={field.state.value}											onBlur={field.handleBlur}											onValueChange={field.handleChange}										>											<Select.Trigger>												<Select.Value placeholder="Select a role" />											</Select.Trigger>											<Select.Content width="trigger">												<Select.Item value="admin">Admin</Select.Item>												<Select.Item value="developer">Developer</Select.Item>												<Select.Item value="read-only">Read-only</Select.Item>											</Select.Content>										</Select.Root>									</Field.Control>									<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />								</Field.Item>							)}						</form.Field>					</Dialog.Body>					<Dialog.Footer>						<Dialog.Close asChild>							<Button type="button" appearance="outlined" intent="neutral">								Cancel							</Button>						</Dialog.Close>						<Button type="submit" appearance="filled" intent="neutral">							Send invite						</Button>					</Dialog.Footer>				</form>			</Dialog.Content>		</Dialog.Root>	);}

Polymorphism

Dialog.Trigger, Dialog.Close, Dialog.Overlay, Dialog.Content, Dialog.Title, Dialog.Description, and Dialog.CloseIconButton accept asChild, which renders your single child in place of the part's default element while keeping its styling and behavior.

Dialog.Trigger asChild is by far the most common: it makes your own Button open the dialog instead of nesting a button inside one. Use Dialog.Close asChild to let any element dismiss the dialog, and Dialog.Title asChild when the heading level must fit the surrounding page outline.

Dialog.Root and Dialog.Portal render no DOM of their own (Root is a state provider and Portal only relocates its children), so neither takes asChild. Dialog.Header, Dialog.Body, and Dialog.Footer are plain layout <div>s that do not currently accept it.

API Reference

Dialog.Root

The root stateful component that manages the open/closed state of the dialog.

Dialog renders its floating layer at Tailwind z-60, Mantle's overlay tier, above every float (z-50). Floats composed inside the content portal into the positioner (data-slot="dialog-positioner") and paint above the content. When multiple overlays are open, the most recently mounted overlay renders on top.

PropTypeDefaultDescription
open?booleanThe controlled open state of the dialog. Must be used in conjunction with onOpenChange.
onOpenChange?(open: boolean) => voidEvent handler called when the open state of the dialog changes.
defaultOpen?booleanfalseThe open state of the dialog when it is initially rendered. Use when you do not need to control its open state.
modal?booleantrueThe modality of the dialog. When set to true, interaction with outside elements will be disabled and only dialog content will be visible to screen readers.

Dialog.Trigger

A button that opens the dialog.

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the trigger functionality onto your own component.

Dialog.Content

The container for the dialog content. Renders on top of the overlay and is centered in the viewport.

Dialog.Content renders its floating layer at Tailwind z-60, Mantle's overlay tier, above every float (z-50). Floats composed inside the content portal into the positioner (data-slot="dialog-positioner") and paint above the content. When multiple overlays are open, the most recently mounted overlay renders on top.

Dialog.Content renders inside a positioner element (data-slot="dialog-positioner") that lays out the content and receives floats portaled from inside the dialog. The full stacking contract lives at Stacking Layers.

PropTypeDefaultDescription
appearance?"centered" | "full-page" | "full-bleed""centered"How much of the viewport the dialog claims. See Full-page dialog.
preferredWidth?`max-w-${string}`"max-w-lg"The maximum width of a "centered" dialog, as a Tailwind max-w- class. Accepted for appearance="centered" only; a filled dialog has no width to cap.
onEscapeKeyDown?(event: KeyboardEvent) => voidEvent handler called when the escape key is down. It can be prevented by calling event.preventDefault.
onInteractOutside?(event: Event) => voidEvent handler called when the user interacts outside the component. It can be prevented by calling event.preventDefault.
onPointerDownOutside?(event: PointerDownOutsideEvent) => voidEvent handler called when a pointer event starts outside the component. It can be prevented by calling event.preventDefault. See Handling autofill overlays.
Data AttributeValueDescription
data-appearance"centered" | "full-page" | "full-bleed"Reflects the resolved appearance.
data-state"open" | "closed"Stamped by Radix; drives the open and close animation.

Dialog.Header

Contains the title, the description, and the close button of the dialog. Dialog.Title and Dialog.Description stack in a column. Every other child shares the title's row, so a long title truncates beside Dialog.CloseIconButton. See Header with a description.

PropTypeDefaultDescription
childrenReactNodeThe content to render inside the dialog header.

Dialog.Body

Contains the main content of the dialog.

PropTypeDefaultDescription
childrenReactNodeThe content to render inside the dialog body.

Dialog.Footer

Contains the footer content of the dialog, including action buttons.

PropTypeDefaultDescription
childrenReactNodeThe content to render inside the dialog footer. Typically contains action buttons.

Dialog.Title

An accessible name to be announced when the dialog is opened.

PropTypeDefaultDescription
childrenReactNodeThe title text for the dialog.
asChild?booleanfalseUse the asChild prop to render a custom element instead of the default heading.

Dialog.Description

An accessible description to be announced when the dialog is opened.

PropTypeDefaultDescription
childrenReactNodeThe description text for the dialog. Enhances accessibility by providing additional context.
asChild?booleanfalseUse the asChild prop to render a custom element instead of the default div.

Dialog.Close

A button that closes the dialog when clicked.

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the close functionality onto your own component.

Dialog.CloseIconButton

An icon button that closes the dialog when clicked.

PropTypeDefaultDescription
size?"xs" | "sm" | "md" | "lg" | "xl""md"The size of the close icon button.
label?string"Close Dialog"The accessible label for the close button. Important for screen readers.
appearance?"filled" | "ghost" | "outlined""ghost"The visual appearance of the close icon button.
intent?"neutral""neutral"The tone of the close icon button. IconButton draws the neutral tone only.