Dialog
A modal window over the page. Use it for decisions that need an answer now, short forms, previews, and search. Focus stays inside until it closes, then returns to where it was.
Usage
import { Button } from "@tulipfarm/sprout/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@tulipfarm/sprout/dialog";
<Dialog open={open} onOpenChange={(next) => !pending && setOpen(next)}>
<DialogTrigger render={<Button variant="destructive" size="sm" />}>
Remove member
</DialogTrigger>
<DialogContent finalFocus={() => trigger.current ?? heading.current}>
<DialogHeader>
<DialogTitle>Remove [email protected]?</DialogTitle>
<DialogDescription>
They lose access to this workspace now.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" disabled={pending} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="destructive" disabled={pending} aria-busy={pending} onClick={remove}>
{pending ? "Saving change…" : "Confirm removal"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>Dialog works controlled with open and onOpenChange, or uncontrolled with defaultOpen. To block Escape and outside clicks while work is saving, ignore the change in onOpenChange. You can also mount the dialog only while it is open.
DialogContent portals an overlay and the popup, and adds a corner close button. Turn it off with showCloseButton={false}. DialogFooter showCloseButton adds an outline Close button after your actions.
Sizes
size is "default" (24rem, scrolls as a whole), "form" (36rem, the header stays put and DialogBody scrolls), "split" (64rem, no padding, for two panes), or "fullscreen" (covers the viewport). Below 40rem every size fills the width minus a 1rem gutter.
Sizes
Each size with the content it is used for. Default fits short forms and confirmations, form scrolls its middle, split holds two panes, and fullscreen covers the viewport.
Confirmations
Pass finalFocus a function so focus goes back to the trigger, or to a section heading with tabIndex={-1} if the row is gone. Errors stay inside the dialog as an Alert, so the person can try again.
Confirmations
Default-size dialogs opened from row actions. Escape and outside clicks are ignored while a change is saving, and focus returns to the trigger or to the section heading if the row disappears.
Members and teams
- Ana Ruiz[email protected] · Owner
- Kofi Mensah[email protected] · Member
- Priya Natarajan[email protected] · Admin
- Field operationsTeam · 6 members
States
Dialogs often open before their data arrives. Show a skeleton with role="status", an error with a retry, and an empty StatePanel when there is nothing to choose. Leave out the description until it has something to say.
Loading, empty, error, and blocked states
Pick a starting state, then open the dialog. The body scrolls between a fixed header and footer, and the footer offers the recovery that fits the state.
Flows
The footer can hold a hint as a direct <p>, a submit button tied to a form with form="…", or a back step. Without a footer, put actions in a right-aligned row at the end of the body. A second dialog can open on top; when it closes, move focus to the control the person needs next.
Form dialogs
Form-size dialogs with a scrolling body and a footer bar. Multi-step flows swap the body and footer in place; a second dialog can open on top and hand focus back.
Danger zone
Search
<DialogContent
data-slot="search-dialog"
placement="top"
showCloseButton={false}
className="flex max-w-2xl flex-col gap-0 p-0"
>
<DialogTitle className="sr-only">Search Meadow</DialogTitle>
…
</DialogContent>
<DialogContent
placement="top"
style={{ "--sprout-dialog-top": "33.333%" }}
className="gap-0 p-0"
showCloseButton={false}
>
<DialogHeader className="sr-only">…</DialogHeader>
…
</DialogContent>Search and command palettes
placement="top" pins the dialog near the top so results grow downward. The corner button is off; search closes from its input row and both titles are screen-reader only.
API
Dialog, DialogTrigger, DialogPortal, DialogClose, DialogOverlay, DialogTitle, and DialogDescription wrap the Base UI dialog parts and take their props, including render. DialogHeader, DialogBody, and DialogFooter render a div and accept render, so a body can be the form.
DialogContent takes size, placement("center" or "top"; top reads --sprout-dialog-top, default 12dvh), showCloseButton, closeLabel, container, and every Base UI popup prop such as initialFocus and finalFocus. It sets data-slot="dialog-content" (overridable), data-size, and data-placement.
Accessibility
The popup has role="dialog" and is named by DialogTitle. Every dialog needs a title, even if it is visually hidden with sr-only. Tab and Shift+Tab stay inside. Escape and outside clicks close it unless you ignore the change. Focus moves to the first focusable element, or to initialFocus.
Opening fades and scales in over 180ms; closing takes 120ms. Both turn off when reduced motion is requested, and fullscreen dialogs only fade.
Theme tokens
Dialogs use --sprout-overlay, --sprout-surface, --sprout-canvas, --sprout-line, --sprout-text, --sprout-muted, --sprout-shadow-overlay, and --sprout-radius-panel. They need no brand color, so they look the same with the neutral and TulipFarm themes in light and dark mode.