---
title: Dialog
description: A task done in a panel over the page, with its buttons in the band beneath it.
---
```tsx lineNumbers
'use client';
import { Button } from '@/components/panelui/button';
import { Dialog } from '@/components/panelui/dialog';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { useState } from 'react';
export default function DialogOverview() {
const [open, setOpen] = useState(false);
const [name, setName] = useState('Spring launch');
return (
);
}
```
Use it for a short task that is done away from the page and then put away: renaming something, inviting people, filling in a few fields.
The task sits in a panel, and `Dialog.Footer` moves out of the panel into the darker band underneath, where its buttons are equal pills. `variant="plain"` is a single surface with the footer ruled off at its foot, for a dense form. A press outside, Escape or the close button in the corner dismiss it.
The dialog is never taller than the screen: the header and footer stay put and `Dialog.Body` scrolls between them. On a screen under 640px wide it rests at the bottom, where a thumb reaches its buttons.
For a yes-or-no question, use [Alert Dialog](/docs/web/components/alert-dialog). For a longer task that keeps the page in view, use [Sheet](/docs/web/components/sheet).
## Installation
The source is copied into `components/panelui/`, where you can edit it.
Import from `panelui-web/dialog` instead of `@/components/panelui/dialog`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
Install the packages it uses:
Copy the source into `components/panelui/dialog.tsx`:
## Usage
```tsx
import { Dialog } from "@/components/panelui/dialog"
```
```tsx
```
## Composition
```tsx
```
- **`Dialog.Trigger`** — What opens the dialog. Pass your own button as `render`.
- **`Dialog.Content`** — The dialog, over a dimmed page. `variant` is `inset` (a panel in a band) or `plain` (one surface), `size` is `sm`, `md` or `lg`, `showClose` draws the round close button, and `blur` frosts the page behind it.
- **`Dialog.Header`** — The title and the line under it, given as `title` and `description` or as parts. It leaves room for the close button. Anything else in it, such as a search field, stays put while the body scrolls.
- **`Dialog.Title`** — The dialog's name. It is announced when the dialog opens.
- **`Dialog.Description`** — A sentence under the title, read out after it.
- **`Dialog.Body`** — The part that scrolls, with a thin drawn thumb. Its children are laid out in a column with a gap, so the fields of a form need no wrapper. A hairline appears under the header once its content has scrolled.
- **`Dialog.List`** — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one.
- **`Dialog.ListGroup`** — A run of items under a `label`, which stays pinned at the top while its items scroll under it.
- **`Dialog.ListItem`** — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up.
- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`.
- **`Dialog.Close`** — Closes the dialog. Pass your own button as `render`, such as Cancel.
## Examples
### Inviting people
A field, a role [Select](/docs/web/components/select) and an Add button in one row, over the list of people invited so far. Pressing Enter in the field adds the address.
```tsx lineNumbers
'use client';
import { Avatar } from '@/components/panelui/avatar';
import { Button } from '@/components/panelui/button';
import { Dialog } from '@/components/panelui/dialog';
import { Frame } from '@/components/panelui/frame';
import { Input } from '@/components/panelui/input';
import { Select } from '@/components/panelui/select';
import { useState } from 'react';
const ROLES = [
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
];
type Invite = { email: string; role: string };
export default function DialogInvite() {
const [invites, setInvites] = useState([
{ email: 'ines@quarry.dev', role: 'editor' },
{ email: 'tomas@quarry.dev', role: 'viewer' },
]);
const [email, setEmail] = useState('');
const [role, setRole] = useState('editor');
const add = () => {
const next = email.trim();
if (!next.includes('@') || invites.some((invite) => invite.email === next)) return;
setInvites([...invites, { email: next, role }]);
setEmail('');
};
return (
);
}
```
### A form
A name, an address with a fixed prefix in an `Input.Group`, a [Textarea](/docs/web/components/textarea) and a [Select](/docs/web/components/select). The form lives in the body and the submit button in the footer, joined by the form's `id`. An empty name marks its [Label](/docs/web/components/label) invalid; Create shows a spinner while it saves. `dismissible={false}` keeps a stray press outside from throwing the form away.
```tsx lineNumbers
'use client';
import { Button } from '@/components/panelui/button';
import { Dialog } from '@/components/panelui/dialog';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { Select } from '@/components/panelui/select';
import { Textarea } from '@/components/panelui/textarea';
import { useState } from 'react';
const VISIBILITY = [
{ value: 'team', label: 'Everyone in the team' },
{ value: 'private', label: 'Only people I invite' },
];
export default function DialogForm() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [missing, setMissing] = useState(false);
return (
);
}
```
### Choosing from a long list
`Dialog.List` holds the cities in `Dialog.ListGroup`s by region, whose headings stay pinned as the list scrolls, and each `Dialog.ListItem` shows the time there now and its offset as `meta`. The search field in the header stays put. Down moves from the field into the list, Enter in the field takes the first city found, and choosing one closes the dialog, so it draws no close button: `showClose={false}`.
```tsx lineNumbers
'use client';
import { SearchIcon } from 'lucide-react';
import { Button } from '@/components/panelui/button';
import { Dialog } from '@/components/panelui/dialog';
import { Input } from '@/components/panelui/input';
import { useEffect, useState, type KeyboardEvent } from 'react';
const REGIONS: { name: string; zones: [string, string][] }[] = [
{
name: 'Americas',
zones: [
['America/Anchorage', 'Anchorage'],
['America/Los_Angeles', 'Los Angeles'],
['America/Denver', 'Denver'],
['America/Mexico_City', 'Mexico City'],
['America/Chicago', 'Chicago'],
['America/Bogota', 'Bogotá'],
['America/New_York', 'New York'],
['America/Toronto', 'Toronto'],
['America/Halifax', 'Halifax'],
['America/Sao_Paulo', 'São Paulo'],
['America/Argentina/Buenos_Aires', 'Buenos Aires'],
],
},
{
name: 'Europe',
zones: [
['Europe/Lisbon', 'Lisbon'],
['Europe/London', 'London'],
['Europe/Madrid', 'Madrid'],
['Europe/Paris', 'Paris'],
['Europe/Berlin', 'Berlin'],
['Europe/Stockholm', 'Stockholm'],
['Europe/Athens', 'Athens'],
['Europe/Istanbul', 'Istanbul'],
['Europe/Moscow', 'Moscow'],
],
},
{
name: 'Africa',
zones: [
['Africa/Lagos', 'Lagos'],
['Africa/Cairo', 'Cairo'],
['Africa/Johannesburg', 'Johannesburg'],
['Africa/Nairobi', 'Nairobi'],
],
},
{
name: 'Asia',
zones: [
['Asia/Riyadh', 'Riyadh'],
['Asia/Dubai', 'Dubai'],
['Asia/Karachi', 'Karachi'],
['Asia/Kolkata', 'Kolkata'],
['Asia/Dhaka', 'Dhaka'],
['Asia/Bangkok', 'Bangkok'],
['Asia/Singapore', 'Singapore'],
['Asia/Shanghai', 'Shanghai'],
['Asia/Seoul', 'Seoul'],
['Asia/Tokyo', 'Tokyo'],
],
},
{
name: 'Oceania',
zones: [
['Pacific/Honolulu', 'Honolulu'],
['Australia/Perth', 'Perth'],
['Australia/Sydney', 'Sydney'],
['Pacific/Auckland', 'Auckland'],
],
},
];
const CITY = Object.fromEntries(REGIONS.flatMap((region) => region.zones));
/** The time there now, and its offset from UTC, as the browser knows them today. */
function clock(zone: string, now: Date) {
const time = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit' }).format(now);
const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'shortOffset' })
.formatToParts(now)
.find((part) => part.type === 'timeZoneName')?.value;
const offset = (name ?? 'GMT').replace('GMT', 'UTC').replace('-', '−');
return `${time} · ${offset}`;
}
export default function DialogScroll() {
const [open, setOpen] = useState(false);
const [zone, setZone] = useState('Europe/Istanbul');
const [query, setQuery] = useState('');
const [now, setNow] = useState(() => new Date());
// The times move on while the list is open.
useEffect(() => {
if (!open) return;
setNow(new Date());
const timer = window.setInterval(() => setNow(new Date()), 15000);
return () => window.clearInterval(timer);
}, [open]);
const needle = query.trim().toLowerCase();
const regions = REGIONS.map((region) => ({
...region,
zones: region.zones.filter(([, city]) => city.toLowerCase().includes(needle)),
})).filter((region) => region.zones.length > 0);
const choose = (next: string) => {
setZone(next);
setOpen(false);
};
// Down from the field goes into the list; Enter takes the first city found.
const fromSearch = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
document.querySelector('[data-slot=dialog-list-item]')?.focus();
}
if (event.key === 'Enter' && regions[0]) choose(regions[0].zones[0][0]);
};
return (
);
}
```
### Plain
`variant="plain"` is one surface, with the footer at its foot behind a rule and its buttons at the end in their usual sizes. `size="lg"` gives a two-column form the width it needs.
```tsx lineNumbers
'use client';
import { Button } from '@/components/panelui/button';
import { Dialog } from '@/components/panelui/dialog';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { Select } from '@/components/panelui/select';
const COUNTRIES = [
{ value: 'pt', label: 'Portugal' },
{ value: 'es', label: 'Spain' },
{ value: 'tr', label: 'Türkiye' },
{ value: 'gb', label: 'United Kingdom' },
];
export default function DialogPlain() {
return (
);
}
```
### A dialog from a dialog
A `Dialog` inside another's content opens over it. The one behind steps back, a little smaller and veiled, and the page is not darkened a second time. Closing the front one returns to the first.
```tsx lineNumbers
'use client';
import { Button } from '@/components/panelui/button';
import { Dialog } from '@/components/panelui/dialog';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { useState } from 'react';
export default function DialogNested() {
const [email, setEmail] = useState('mira@castell.studio');
const [changeOpen, setChangeOpen] = useState(false);
return (
);
}
```
## API Reference
### Dialog
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | — | Whether it is open, when the caller holds that. |
| `defaultOpen` | `boolean` | — | Whether it starts open, when the caller does not hold it. |
| `onOpenChange` | `(open: boolean) => void` | — | Called when it opens or closes. |
| `onOpenChangeComplete` | `(open: boolean) => void` | — | Called once it has finished opening or closing, after its transition has ended. `false` means the exit is over, so a dialog mounted from state can be unmounted. |
| `dismissible` | `boolean` | `true` | Whether a press outside it closes it. Turn it off while a form in it has changes that would be lost; Escape and the close button still work. Default `true`. |
### Dialog.Trigger
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement` | — | Your own element to be the trigger, such as a styled button. It is given the click handler and the attributes that tie it to the dialog. |
### Dialog.Content
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `'inset' \| 'plain'` | `'inset'` | `inset` sets the task in a panel inside a darker band that holds the footer. `plain` is one surface with the footer ruled off. Default `inset`. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | How wide it may grow: `sm` 24rem, `md` 28rem, `lg` 36rem. Default `md`. |
| `showClose` | `boolean` | `true` | Show the round close button in the top trailing corner. Default `true`. |
| `blur` | `boolean` | `false` | Frost the page behind it instead of only dimming it. |
| `inline` | `boolean` | `false` | Draw it in the page instead of over it: no backdrop, no trigger, always there. For previews; `open` is ignored. |
### Dialog.Header
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `ReactNode` | — | The heading. It is also what the dialog is announced as. |
| `description` | `ReactNode` | — | A line under the heading. |
### Dialog.Footer
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultTagName` | `'div',` | — | |
| `props` | `mergeProps(` | — | |
### Dialog.Close
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement` | — | Your own element to close the dialog with, such as a Cancel button. |
| `icon` | `boolean` | — | Draw the cross glyph. Set for you on the built-in close button. |
### Dialog.List
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultTagName` | `'div',` | — | |
| `props` | `mergeProps(` | — | |
| `ref` | `list,` | — | |
| `role` | `'listbox',` | — | |
| `className` | `dialogVariants().list({ className }),` | — | |
| `onKeyDown` | `move,` | — | |
### Dialog.ListGroup
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` | — | The heading over the group, which stays pinned while its items scroll under it. |
### Dialog.ListItem
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` | `false` | Whether it is the chosen one: it is tinted and checked. |
| `onSelect` | `() => void` | — | Called when it is pressed, or chosen with Enter or Space. |
| `meta` | `ReactNode` | — | Quieter text at its end, in figures that line up: a time, a count. |
| `disabled` | `boolean` | `false` | Shown, but cannot be chosen. |
Every part also takes the props of the element it renders, and a `className`.
## Notes
### Closing
A press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.
### Focus
When it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.
### Forms
Put the `