Modal
Dialog, AlertDialog and Drawer. Surfaces that take over the screen, with a focus trap, an inert page, scroll lock and focus return. Built on Base UI.
Usage
import {
Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose,
} from "@azmr/ui/modal";These are interactive, so they are client components. Every modal surface does the same four things while it is open:
- Traps focus. Tab and Shift+Tab stay inside. At the ends focus wraps.
- Makes the page inert. The page behind is hidden from assistive technology and cannot be focused.
- Locks scroll. The page does not scroll behind it.
- Returns focus. Closing puts focus back on the control that opened it.
They render in a portal above the page, with a scrim (--scrim) behind them on the --z-overlay layer and the surface on --z-modal.
| You need | Use |
|---|---|
| A task or form that needs the person's full attention | Dialog |
| A yes or no before something destructive or irreversible | AlertDialog |
| A side panel or a bottom sheet (filters, details, a mobile menu) | Drawer |
Dialog
"use client";
import { Button } from "@azmr/ui/button";
import { Input } from "@azmr/ui/input";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@azmr/ui/modal";
export default function DialogBasic() {
return (
<Dialog>
<DialogTrigger render={<Button>Edit profile</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Change how you appear to the rest of your team.</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={(event) => event.preventDefault()}>
<div className="grid gap-1.5">
<label htmlFor="display-name" className="text-sm font-medium">
Display name
</label>
<Input id="display-name" defaultValue="Aroha Ngata" />
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose render={<Button type="submit">Save changes</Button>} />
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}Name every dialog with DialogTitle and describe it with DialogDescription; they become the dialog's accessible name and description. Escape, the corner close button (showClose, on by default) and a click on the scrim all close it. Set disablePointerDismissal on Dialog when an accidental click outside must not lose the person's work.
size is sm, md (default), lg, xl or full. Every size stays inside the viewport, and a dialog taller than the screen scrolls inside the viewport instead of running off it.
Stacked dialogs
import { Button } from "@azmr/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@azmr/ui/modal";
export default function DialogStacked() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline">Open the first dialog</Button>} />
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>Invite people</DialogTitle>
<DialogDescription>
Press Escape to close only the top dialog: the one underneath stays open.
</DialogDescription>
</DialogHeader>
<Dialog>
<DialogTrigger
render={
<Button variant="secondary" className="justify-self-start">
Review permissions
</Button>
}
/>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Permissions</DialogTitle>
<DialogDescription>Invited people can view and comment.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button>Got it</Button>} />
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);
}A dialog can open another. Escape closes only the top one, focus is trapped in the top one, and closing it returns focus to the control that opened it, which sits inside the dialog underneath.
AlertDialog
Focus starts on “Keep it”, never on “Delete”.
"use client";
import { Button } from "@azmr/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@azmr/ui/modal";
import { useState } from "react";
export default function AlertDialogBasic() {
const [deleted, setDeleted] = useState(false);
return (
<div className="flex items-center gap-4">
<AlertDialog>
<AlertDialogTrigger render={<Button variant="destructive">Delete project</Button>} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this project?</AlertDialogTitle>
<AlertDialogDescription>
Its files and history are removed for everyone. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep it</AlertDialogCancel>
<AlertDialogAction onClick={() => setDeleted(true)}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p className="text-sm text-muted-foreground" aria-live="polite">
{deleted ? "Project deleted." : "Focus starts on “Keep it”, never on “Delete”."}
</p>
</div>
);
}An alert dialog interrupts to ask for a decision. It differs from a Dialog in three ways, all deliberate:
- It is announced as an alert dialog.
- A click outside does not close it. The person has to choose.
- Focus starts on
AlertDialogCancel, the least destructive choice, even when the destructive button comes first in the page order. Pressing Enter by reflex cannot delete anything. If there is noAlertDialogCancel, focus starts on the first control.
AlertDialogAction is destructive by default; pass tone="default" for a plain confirmation. It runs its onClick and then closes the dialog. Escape cancels.
Drawer
import { Button } from "@azmr/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
type DrawerSide,
DrawerTitle,
DrawerTrigger,
} from "@azmr/ui/modal";
const sides: DrawerSide[] = ["right", "left", "top", "bottom"];
export default function DrawerBasic() {
return (
<div className="flex flex-wrap gap-2">
{sides.map((side) => (
<Drawer key={side} side={side}>
<DrawerTrigger render={<Button variant="outline">From the {side}</Button>} />
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>
On a touch screen, swipe toward the {side} to dismiss.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button>Apply</Button>} />
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
);
}A drawer slides in from side (right by default, left, top or bottom) and is a full modal. On touch it can be dismissed by swiping back toward the edge it came from; the panel follows the finger exactly and the scrim fades with the swipe. Gesture handling comes from Base UI's drawer, so there is no extra module to install.
Motion
The scrim fades and a dialog fades and scales in from slightly smaller, using --motion-duration-base. A drawer slides on --motion-duration-slow. Under prefers-reduced-motion those tokens collapse to near zero, so everything appears and disappears instantly. While a finger is dragging a drawer there is no easing at all, so it tracks exactly.
Accessibility
| Key | Action |
|---|---|
| TaborShift+Tab | Moves through the controls inside the surface and wraps at the ends. |
| Escape | Closes the top surface. Focus returns to the control that opened it. |
| EnterorSpace | Opens from a trigger, and activates the focused button inside. |
- The surface is named by its title and described by its description. Always include a title.
- The corner close button is labelled "Close"; its icon is decorative.
- The scrim colour is a token, so it follows light and dark mode and scoped themes.
Props
Prop
Type
Prop
Type
The rest are Base UI's props, passed through (open, defaultOpen, onOpenChange, modal, initialFocus, finalFocus, and so on).