Skip to content
Drivn
5 min read

React Dialog Component Examples

Drop-in React Dialog examples: confirmation prompt, controlled state, hidden close button, form dialog, nested dialogs. Built on the native <dialog>.

Some tasks need the page to stop. A destructive confirmation, a three-field form, a step the user has to acknowledge before anything else happens — those belong on a modal surface rather than inline. The Dialog component in Drivn builds that surface on the native HTML <dialog> element and opens it with showModal(), which means the browser supplies top-layer stacking, the focus trap, and Escape handling instead of a JavaScript focus-scope module. The file is around a hundred lines of TSX with no runtime UI dependencies, and it belongs to you the moment you run npx drivn add dialog through the Drivn CLI.

The API is dot notation. Dialog holds the open state, Dialog.Trigger renders an outline Button that opens it, and Dialog.Content renders the panel. A title string prop draws the heading, so there is no DialogHeader plus DialogTitle pair to import and no extra subcomponent for the close icon. Every snippet on this page uses the @/components/ui/dialog path the CLI installs to, so each one compiles as written.

Need a side-anchored panel for a mobile task flow instead? Use Drawer. Need something anchored to a trigger and non-modal? Use Popover. The five examples below stay on the modal path: a basic confirmation, controlled open state, a dialog with the built-in close button hidden, a form dialog that submits and then closes, and one dialog nested inside another.

Basic confirmation dialog

The default Dialog renders a Trigger button that opens a centered panel with a heading and your body content. Pass the title prop on Dialog.Content and the component renders an <h2> styled with text-lg font-semibold at the top of the panel. The close button (an X icon in the top-right corner) is rendered automatically — no extra subcomponent to import.

Because the Dialog source lives in your repo after install, swapping the trigger styling, panel max-width, or the close icon is a local edit. The minimum viable integration is a Trigger plus a Content with a title prop, body content as plain JSX, and an action button. Outside-click closes the dialog because the component listens for clicks on the <dialog> backdrop element.

1import { Dialog } from "@/components/ui/dialog"
2import { Button } from "@/components/ui/button"
3
4export default function Page() {
5 return (
6 <Dialog>
7 <Dialog.Trigger>Open Dialog</Dialog.Trigger>
8 <Dialog.Content title="Confirm Action">
9 <p>Are you sure you want to continue?</p>
10 <Button>Confirm</Button>
11 </Dialog.Content>
12 </Dialog>
13 )
14}

Controlled open state

For dialogs that need to open from outside the trigger — a side menu, a keyboard shortcut, or a route parameter — pass open and onOpenChange to the Dialog root. The component reconciles controlled and uncontrolled modes inside DialogRoot: when open is undefined, internal useState runs; when defined, the prop wins and onOpenChange fires alongside any internal updates.

This pattern is also the right one for confirmation flows where the dialog must close only after an async action resolves. Keep the dialog open while the request is in flight, then call setOpen(false) from the action handler. The Dialog props reference lists every prop and the controlled API matches the names you may already use for shadcn or Radix dialogs.

1import { useState } from "react"
2import { Dialog } from "@/components/ui/dialog"
3import { Button } from "@/components/ui/button"
4
5export default function Page() {
6 const [open, setOpen] = useState(false)
7
8 return (
9 <>
10 <Button onClick={() => setOpen(true)}>Edit profile</Button>
11 <Dialog open={open} onOpenChange={setOpen}>
12 <Dialog.Content title="Edit profile">
13 <p>Form goes here.</p>
14 <Button onClick={() => setOpen(false)}>Save</Button>
15 </Dialog.Content>
16 </Dialog>
17 </>
18 )
19}

Hide the built-in close button

For dialogs that should close only via an explicit action — a one-step confirmation, a tutorial step, a destructive prompt — pass showClose={false} on Dialog.Content and the X button in the corner disappears. Outside-click and Escape still dismiss the dialog by default, so you may also want to gate those by switching to controlled state and ignoring onOpenChange calls until the user picks an action.

When the close button is hidden, render your own action buttons inside the body. Calling setOpen(false) from any of them closes the dialog the same way the built-in icon does. This pattern fits a "Are you sure?" prompt where the only valid exits are Confirm or Cancel.

1import { useState } from "react"
2import { Dialog } from "@/components/ui/dialog"
3import { Button } from "@/components/ui/button"
4
5export default function Page() {
6 const [open, setOpen] = useState(false)
7
8 return (
9 <Dialog open={open} onOpenChange={setOpen}>
10 <Dialog.Trigger>Delete account</Dialog.Trigger>
11 <Dialog.Content title="Delete this account?" showClose={false}>
12 <p>This action cannot be undone.</p>
13 <div className="flex gap-2 mt-4">
14 <Button variant="outline" onClick={() => setOpen(false)}>
15 Cancel
16 </Button>
17 <Button variant="destructive">Delete</Button>
18 </div>
19 </Dialog.Content>
20 </Dialog>
21 )
22}

Form dialog with submit

Dialogs are the natural surface for short, focused forms — invite a teammate, rename a project, edit a single field. Render an Input inside Dialog.Content, hold the value in useState, and close the dialog from your submit handler after the request resolves. Because the dialog uses the native <dialog> element, focus enters the panel automatically and Escape cancels the form.

Keep the form vertical, leave breathing room above the action row, and place the primary action on the right. The Drivn Button variants — default for the primary action, outline for cancel — give the form the styling rhythm you see in the rest of the app. For multi-step forms or wizards, a Stepper inside the dialog body is a good fit.

1import { useState } from "react"
2import { Dialog } from "@/components/ui/dialog"
3import { Button } from "@/components/ui/button"
4import { Input } from "@/components/ui/input"
5
6export default function Page() {
7 const [open, setOpen] = useState(false)
8 const [name, setName] = useState("")
9
10 const handleSubmit = async () => {
11 await fetch("/api/rename", {
12 method: "POST",
13 body: JSON.stringify({ name }),
14 })
15 setOpen(false)
16 }
17
18 return (
19 <Dialog open={open} onOpenChange={setOpen}>
20 <Dialog.Trigger>Rename project</Dialog.Trigger>
21 <Dialog.Content title="Rename project">
22 <Input
23 value={name}
24 onChange={(e) => setName(e.target.value)}
25 placeholder="New name"
26 />
27 <div className="flex gap-2 mt-4 justify-end">
28 <Button variant="outline" onClick={() => setOpen(false)}>
29 Cancel
30 </Button>
31 <Button onClick={handleSubmit}>Save</Button>
32 </div>
33 </Dialog.Content>
34 </Dialog>
35 )
36}

Nested dialogs

Because each Dialog.Content mounts its own <dialog> element and opens it with showModal(), the browser's top-layer stack decides the ordering for you — no portal target to mount, no z-index arithmetic, no stacking-context workaround. Each Dialog root creates a fresh context provider, so useDialog inside the inner content resolves to the inner setOpen; calling it closes only the inner panel and leaves the outer one mounted and open. Both panels render through the same styles.content shell, so a nested dialog looks identical to a standalone one — just painted above its parent.

Reach for nesting sparingly. A single dialog with a multi-step body usually reads better than two stacked panels. The case that earns it is an interruption inside a longer task: a discard confirmation raised from an edit dialog, where confirming should dismiss both at once. The example below shows that shape — the outer Dialog wraps the editor, the inner one handles the confirmation, and its destructive action calls both setters in sequence.

1import { useState } from "react"
2import { Dialog } from "@/components/ui/dialog"
3import { Button } from "@/components/ui/button"
4
5export default function Page() {
6 const [outer, setOuter] = useState(false)
7 const [inner, setInner] = useState(false)
8
9 return (
10 <Dialog open={outer} onOpenChange={setOuter}>
11 <Dialog.Trigger>Edit</Dialog.Trigger>
12 <Dialog.Content title="Edit document">
13 <p>Unsaved changes will be lost.</p>
14 <Button onClick={() => setInner(true)}>Discard</Button>
15 <Dialog open={inner} onOpenChange={setInner}>
16 <Dialog.Content title="Discard changes?">
17 <p>This cannot be undone.</p>
18 <Button
19 variant="destructive"
20 onClick={() => {
21 setInner(false)
22 setOuter(false)
23 }}
24 >
25 Discard
26 </Button>
27 </Dialog.Content>
28 </Dialog>
29 </Dialog.Content>
30 </Dialog>
31 )
32}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Dialog renders as a centered modal panel anchored in the middle of the viewport. Drawer slides in from the side and is a better fit for mobile-friendly task flows or settings panels. Both trap focus and dismiss on Escape, but Dialog uses the native <dialog> element while Drawer is a positioned <aside>. Reach for Drawer on mobile-first surfaces; reach for Dialog when the task should clearly own the screen.

Switch to controlled mode by passing open and onOpenChange to the Dialog root, then call setOpen(false) from any handler — a Save button, a form submit, an async resolver. The pattern matches what you would do with a shadcn or Radix dialog. The built-in close button does the same thing internally, so a custom close button is just an additional caller of the same setter.

Yes — the native <dialog> element handles focus trap, Escape-to-close (via the cancel event), and top-layer stacking when the component calls el.showModal(). There is no Radix focus-scope or dismissable-layer module behind it. When the dialog closes, focus returns to the element that opened it, the same way a native modal would.

Switch to controlled mode and ignore the setOpen(false) calls that originate from the backdrop. The component fires onOpenChange(false) for outside-click, Escape, and the close button — gate which ones close the dialog inside your handler. For a one-step destructive confirmation, also pass showClose={false} so the corner icon disappears and only your action buttons can close it.

The Dialog file starts with "use client" because it manages state with useState and listens for events. You can import it from a Server Component, but it renders on the client. Next.js draws the client boundary correctly based on the use client directive at the top of the file — no manual configuration needed in your layout or page.