---
title: Dialog
description: A native dialog driven by props: top layer, focus trap and Escape from the element, click-outside from here.
source: https://adamjurek.com/components/dialog
---

## Home


Dialog is a native `<dialog>` driven entirely by props: `open` shows it modal,
and `onClose` fires from Escape, the backdrop, or its own close button. Reach
for it to hold a reader until they answer something, like a confirmation -
not for a menu meant to be dismissed in passing.

<!-- ::start:showcase demo="dialog" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

`showModal` already supplies the top layer, focus trap, Escape and the
backdrop, so this component adds only what is missing: the frame, the title
the dialog is labelled by, and click-outside. Command Palette is this same
recipe with a filter bolted on, and the two stay separate components because
a dialog's job is to hold the page, and a palette's is to let you leave it
quickly.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/dialog.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/dialog.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Overlays |
| Files | `dialog.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | modal, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useState } from "react";
import { Button, Dialog } from "@sushindustries/ui";

export function Example() {
	const [open, setOpen] = useState(false);

	return (
		<>
			<Button variant="ghost" onClick={() => setOpen(true)}>
				Delete the draft
			</Button>
			<Dialog
				open={open}
				onClose={() => setOpen(false)}
				title="Delete the draft?"
				footer={
					<>
						<Button variant="ghost" onClick={() => setOpen(false)}>
							Keep it
						</Button>
						<Button onClick={() => setOpen(false)}>Delete</Button>
					</>
				}
			>
				It has been three weeks.
			</Dialog>
		</>
	);
}
```

## What you should see

Nothing until `open` is true, then a titled box in the browser's top layer,
the page behind it dimmed and inert - you cannot tab or click into it while
the dialog is up. Escape, a click outside the box, or the close button all
call `onClose` the same way.

## If nothing happens

`Dialog` renders a real `<dialog>` element and drives it with `showModal()`
and `close()` from an effect keyed on `open`. If it never appears, the most
likely cause is `open` never actually becoming `true` in whatever state is
passed in - the component has no way to open itself, it only reflects the
prop.


## Guides


## `open` and `onClose` have to agree

The element itself closes on Escape and on a backdrop click, and both of
those call `onClose` - but neither one flips `open` to `false` by itself.
If `onClose` does not lead to `open` becoming `false`, the effect sees the
prop still `true` and immediately reopens the dialog you just watched close,
which looks like a flicker rather than an error.

## When not to use it

A palette that filters as you type, or a menu meant to be left quickly, is
not this component - `Dialog`'s job is to hold the page hostage until you
answer it, which is correct for a confirmation and wrong for anything meant
to be dismissed in passing.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Calls `showModal`, so the page behind goes inert while it is true. |
| `onClose` | `() => void` | - | Escape, the backdrop and the close button all arrive here. Clear `open` or the two disagree. |
| `title` | `string` | - |  |
| `children` | `ReactNode` | - |  |
| `footer?` | `ReactNode` | - | Usually a Button row. |

<!-- /generated:api -->

## Notes

`onClose` is called, never assumed - clicking the backdrop, pressing Escape
and pressing the close button all route through it, and none of them touch
`open` directly. Keeping `open` in sync with `onClose` is the caller's job;
see Guides for what happens when it is not.

`footer` is optional and, when absent, leaves no empty row behind - there is
no placeholder bar rendered for a dialog with nothing to put in one.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="dialog" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useState } from "react";
import { Button, Dialog } from "@sushindustries/ui";

export function DeletePostButton({ onDelete }: { onDelete: () => void }) {
	const [confirming, setConfirming] = useState(false);

	return (
		<>
			<Button variant="ghost" onClick={() => setConfirming(true)}>
				Delete
			</Button>
			<Dialog
				open={confirming}
				onClose={() => setConfirming(false)}
				title="Delete this post?"
				footer={
					<>
						<Button variant="ghost" onClick={() => setConfirming(false)}>
							Cancel
						</Button>
						<Button
							onClick={() => {
								setConfirming(false);
								onDelete();
							}}
						>
							Delete
						</Button>
					</>
				}
			>
				This cannot be undone.
			</Dialog>
		</>
	);
}
```

## What this example is not

Not a form dialog. `children` here is a sentence, not inputs - a dialog that
collects data needs its own focus-management thinking about which field
gets focus on open, which this example never has to consider.
