Modal dialog
A short, focused task over the page: create, edit, or one step that needs an answer. The page waits under a 10% black blanket until you finish or cancel.
Playground
Press Open dialog, then Cancel to come back to the controls. The code under the preview lists only the props you changed.
import { DialogContent } from '@/ui/dialog';<DialogContent />Default
384px over the page: 12px radius, the overlay shadow, the title at 16/20/600 and one line of description, 16px padding, and the footer on the sunken band with Cancel, then the one primary, right-aligned. Focus starts in the first field; Add job waits, disabled, until the title is filled.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/ui/dialog';
import { Field, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select';
const companies = { halcyon: 'Halcyon Bank', orbital: 'Orbital Freight', northbridge: 'Northbridge Talent' };
// Focus starts in the first field. Add job waits, disabled, until the title is filled.
export default function ModalDialogDefault() {
const [title, setTitle] = useState('');
return (
<Dialog onOpenChange={(open) => !open && setTitle('')}>
<DialogTrigger render={<Button variant="primary" />}>Add job</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add job</DialogTitle>
<DialogDescription>It opens in the Jobs list for your whole team.</DialogDescription>
</DialogHeader>
<DialogBody>
<Field>
<FieldLabel htmlFor="add-job-title">Job title</FieldLabel>
<Input id="add-job-title" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Senior Data Engineer" />
</Field>
<Field>
<FieldLabel id="add-job-company">Company</FieldLabel>
<Select items={companies} defaultValue="halcyon">
<SelectTrigger aria-labelledby="add-job-company">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(companies).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button />}>Cancel</DialogClose>
<DialogClose render={<Button variant="primary" disabled={!title.trim()} />}>Add job</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Sizes
Default 384px for one to three fields. Large 560px for a longer form or a list to pick from. The viewer is 1024px and the window's height less 64px, for a file or CV: no padding, no footer, and the header holds the actions. On a narrow screen every size is the window less 16px a side.
import { DownloadSimpleIcon } from '@phosphor-icons/react';
import { Button, IconButton } from '@/ui/button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/ui/dialog';
const people = ['Maya Chen · maya@northwind.io', 'Maya Chen · m.chen@gmail.com'];
// Pick the smallest size that fits without scrolling. Large holds a list to pick from; the viewer holds a file.
export default function ModalDialogSizes() {
return (
<div className="flex flex-wrap gap-100">
<Dialog>
<DialogTrigger render={<Button />}>Merge people</DialogTrigger>
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>Merge people</DialogTitle>
<DialogDescription>Pick the record to keep. Notes, files and applications move to it.</DialogDescription>
</DialogHeader>
<DialogBody>
<ul className="flex flex-col gap-050">
{people.map((person) => (
<li key={person} className="flex h-9 items-center rounded-large px-150 shadow-raised">
{person}
</li>
))}
</ul>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button />}>Cancel</DialogClose>
<DialogClose render={<Button variant="primary" />}>Merge</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger render={<Button />}>Preview CV</DialogTrigger>
<DialogContent size="viewer">
<DialogHeader>
<DialogTitle>Maya Chen CV.pdf</DialogTitle>
<IconButton label="Download Maya Chen CV.pdf" className="ms-auto">
<DownloadSimpleIcon />
</IconButton>
</DialogHeader>
<DialogBody>
<div className="h-120 w-85 max-w-full bg-surface shadow-raised" role="img" aria-label="First page of Maya Chen CV.pdf" />
</DialogBody>
</DialogContent>
</Dialog>
</div>
);
}Saving and a failed save
While it saves, the primary shows a spinner with the same label, and Escape, the blanket and Cancel wait. When the save fails, the dialog stays open with what was typed and the error above the footer. This one fails the first time.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/ui/dialog';
import { Field, FieldLabel } from '@/ui/field';
import { Input } from '@/ui/input';
// Saving: a spinner in the button, same label; Escape, the blanket and Cancel wait. The first save fails: the dialog
// stays open with what was typed and the error above the footer. The second works.
export default function ModalDialogFooterStates() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tries, setTries] = useState(0);
const save = () => {
if (saving) return;
setSaving(true);
setError(null);
setTimeout(() => {
setSaving(false);
setTries(tries + 1);
if (tries === 0) setError('Couldn’t add the job. Check your connection and try again.');
else setOpen(false);
}, 1200);
};
return (
<Dialog open={open} onOpenChange={(next) => !saving && setOpen(next)}>
<DialogTrigger render={<Button variant="primary" />}>Add job</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add job</DialogTitle>
</DialogHeader>
<DialogBody>
<Field>
<FieldLabel htmlFor="footer-job-title">Job title</FieldLabel>
<Input id="footer-job-title" defaultValue="Senior Data Engineer" disabled={saving} />
</Field>
</DialogBody>
<DialogFooter error={error}>
<DialogClose render={<Button disabled={saving} />}>Cancel</DialogClose>
<Button variant="primary" loading={saving} onClick={save}>
Add job
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}When things go wrong
A long title wraps. A long body scrolls between the header and the footer, which stay in view. A condition that blocks part of the task shows as a section message inside the dialog, never a toast.
import { Button } from '@/ui/button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/ui/dialog';
import { SectionMessage } from '@/ui/section-message';
const notes = Array.from({ length: 24 }, (_, i) => `Call ${i + 1} with Zoë O’Brien-Smith about the Senior Data Engineer role at Halcyon Bank`);
// A long title wraps. A long body scrolls between the header and the footer, which stay in view. A condition that
// blocks the task shows as a section message inside the dialog, never a toast.
export default function ModalDialogUnhappy() {
return (
<Dialog>
<DialogTrigger render={<Button />}>Log activity for 王小明</DialogTrigger>
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>Log activity for 王小明 and Nguyễn Thị Minh Khai at Northbridge Talent Partners International</DialogTitle>
<DialogDescription>24 earlier calls are listed below.</DialogDescription>
</DialogHeader>
<DialogBody>
<SectionMessage appearance="warning" title="Your mailbox is disconnected">
Calls still log, but emails won’t attach until you reconnect it in Settings.
</SectionMessage>
<ol className="flex flex-col gap-100 text-text-subtle">
{notes.map((note) => (
<li key={note}>{note}</li>
))}
</ol>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button />}>Cancel</DialogClose>
<DialogClose render={<Button variant="primary" />}>Log call</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Motion
The dialog uses the Dialog motion; the blanket fades with it. Press Replay to open it after a beat, then Cancel or Escape to see it close.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/ui/dialog';
// Replay opens the dialog after a beat, so the 200ms entry plays; Cancel or Escape plays the 150ms exit.
export default function ModalDialogMotion() {
const [open, setOpen] = useState(false);
const replay = () => {
setOpen(false);
setTimeout(() => setOpen(true), 400);
};
return (
<>
<Button variant="ghost" onClick={replay}>
Replay
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add job</DialogTitle>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button />}>Cancel</DialogClose>
<DialogClose render={<Button variant="primary" />}>Add job</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}Hover preview card
A record's summary when you rest on its name, so you can check who someone is without leaving Flo's answer or the page you're on.
Popup
A floating panel tied to the button that opened it: a short form, a picker, a filter. It closes on Escape or a click outside, and the page stays live around it.