Developer Experience (DX)
Next, let's look at how developers use the Dialog. It has one API, with the same names as every other component, it's typed end to end, and it leaves when to open and close it to you.
import { useState } from "react";
import { Button } from "@/components/Button";
import { Dialog } from "@/components/Dialog";
import { Input } from "@/components/Input";
export function EditProfile() {
const [open, setOpen] = useState(false);
function save() {
// …save the profile, then close the dialog.
setOpen(false);
}
return (
<>
<Button onClick={() => setOpen(true)}>Edit profile</Button>
<Dialog
id="edit-profile"
title="Edit profile"
open={open}
onClose={() => setOpen(false)}
actions={
<>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="primary" onClick={save}>Save changes</Button>
</>
}
>
<label htmlFor="name">Full name</label>
<Input id="name" autoComplete="name" />
</Dialog>
</>
);
}
Vanilla and Astro have no state to bind open to, so their script opens the dialog from the button that controls it, and lets the browser close it. The close event's returnValue then says why.
Consistent
Once you've learned the Button's API, you already know most of the Dialog's. These names mean the same thing everywhere in Open Components:
| Name | Kind | What it means |
|---|---|---|
size | Prop | How big the component is, which for a dialog is how wide it gets |
open | Prop | Whether it's showing, for anything that opens on top of the page |
actions | Slot | Buttons that act on the component, after everything else |
close | Event | People asked to close it, and why |
We recommend size="md" and role="dialog" as the defaults. The Dialog also leaves a few things out, on purpose:
- There's no
variantorcolor. A dialog has no intent of its own, so its actions say what it's about, like a Delete project Button withcolor="error". - There's no
triggerslot. The button that opens a dialog is a Button of yours, with its own label and its own place on the page, and the Dialog gives focus back to it on its own. - There's no
modalprop. A dialog that leaves the page working, next to what opened it, is a Popover, which is on our Roadmap. - There's no
closableprop either. Every dialog closes onEscape, and an alert dialog leaves out the close button and ignores the backdrop on its own.
Type-safe
Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like size="xl", which isn't one of the Dialog's sizes, or a dialog without a title.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Shows the dialog. It never changes on its own |
id | string | Required. Its title's and description's IDs start with it | |
title | string | Required. The heading that names the dialog | |
description | string | A sentence or two that screen readers read along with the title | |
role | "dialog" | "alertdialog" | "dialog" | alertdialog for a confirmation that needs an answer |
size | "sm" | "md" | "lg" | "md" | How wide it gets, at most |
closeLabel | string | "Close" | The close button's label, for other languages |
| Event | Payload | Description |
|---|---|---|
close | "escape" | "close-button" | "backdrop" | "action" | "closed", string | Fires when people ask to close it, with why, and for action, the value of the button that sent the <form method="dialog">. Fires with closed when it's already closed |
Angular is the one framework where two of these names change, since its Dialog sits on the <dialog> itself. A title attribute stays on the element even when an input reads it, which would show the title as a tooltip over the whole panel, so the title is dialogTitle. close is already the <dialog>'s own event, and an output carries a single value, so the event is closeRequest, with { reason, value }.
| Slot | Description |
|---|---|
default | The content, like a message or a form, which scrolls when it doesn't fit |
actions | The Buttons that answer the dialog, like Cancel and Save, in the order people read them |
We also export DialogProps, which comes in handy for confirmations you set up from data:
import type { DialogProps } from "@/components/Dialog";
const confirmations: Record<string, Pick<DialogProps, "id" | "title" | "description">> = {
delete: { id: "delete-project", title: "Delete project?", description: "You can't undo it." },
archive: { id: "archive-project", title: "Archive project?", description: "Nobody can change it after." },
};
Composable
The Dialog renders the <dialog> as its root, so everything you pass lands on it, including classes, styles, data attributes and event listeners. Its title and description get their IDs from the dialog's own id, as in edit-profile-title, so you can point at them from elsewhere, like a test.
Forms
A <form method="dialog"> is how HTML answers a dialog. On its own, it closes the dialog and sets returnValue to the value of the button that sent it. The Dialog asks instead, firing close with the reason action and that value, so you decide what happens, the same way as for every other reason. That also means the answer works before your JavaScript loads, in Astro and Vanilla.
<Dialog
id="delete-project"
role="alertdialog"
title="Delete project?"
open={open}
onClose={(reason, value) => {
setOpen(false);
if (value === "delete") deleteProject();
}}
actions={
<form method="dialog">
<Button type="submit" value="keep" variant="outline">Keep project</Button>
<Button type="submit" value="delete" color="error">Delete project</Button>
</form>
}
/>
A form in the body works like any other, so a dialog that edits something can send it with a submit Button among its actions, through the button's form attribute, and close once it's saved.
With other components
- A Button opens the dialog, and gets focus back when it closes. The close button is a Button too, and so are the actions, with their names, focus rings and
type="button". - An Input in the body gets the focus when the dialog opens, if it's the first control there, and keeps its label and error message as it would anywhere else.
- A Spinner shows that an action is under way, inside its Button, with
loading, which keeps the focus on the Button while the dialog waits. - Menus, popovers and tooltips inside a dialog, which are on our Roadmap, open in the top layer too, above the dialog, and close on
Escapebefore it does.
Controllable
The Dialog doesn't keep any state you can't reach. Whether it's open is yours, and so is everything that happens when people ask to close it:
| State | How you control it |
|---|---|
| Open | open, which it never changes on its own |
| Closing | close, with why, after which you set open to false, or keep it open, unless it's closed |
| Initial focus | data-autofocus on the control to start on |
| Focus after | Back to the opener on its own, or anywhere you move it once open is false |
| Role | role, which also decides the close button and the backdrop |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
dialog/shared-vocabulary | Must | Component | It uses the shared names: open, size, actions and close (closeRequest in Angular) | Open Components | Review |
dialog/typed | Must | Component | Props, events and slots are typed, with unions rather than string, and id and title (dialogTitle in Angular) are required | Open Components | Type check |
dialog/controllable | Must | Component | It opens and closes with open, and asks rather than closing itself: Escape, the close button and the backdrop fire close, with why, and when a browser closes it anyway, it fires close with closed | Open Components | Unit test |
dialog/root-attributes | Must | Component | Attributes, classes and listeners land on the <dialog> | Open Components | Unit test |
dialog/form-actions | Should | Component | A <form method="dialog"> inside it fires close with the reason action and its button's value, rather than closing it | HTML | Unit test |
FAQ
close, with why, and you set open to false, which most of the time is all your handler does. The one close you can't turn down is the browser's own, after a close request it won't let the page refuse, and close then fires with closed, so set open to false for it, and the dialog and your code stay in step.closedby="any" asks the browser to close a dialog on a press outside it, on top of Escape, which is what our backdrop handling does. It closes the dialog itself, though, rather than asking you first, so the Dialog handles the backdrop on its own, the same way it handles every other reason.showModal(), so a dialog can't open before your JavaScript loads. Everything inside it is plain HTML, though, so once it's open, a <form method="dialog"> answers it, and the browser closes it on Escape, even in Astro and Vanilla, where no framework is running.