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>.

A modal dialog is the right surface for confirmations, focused forms, and any task that needs the user's attention before they continue. The Dialog component in Drivn wraps the native HTML <dialog> element, so the browser handles modal stacking, focus trap, and Escape-to-close — the component file ships zero runtime UI dependencies and weighs around a hundred lines you own after install. Use it whenever the rest of the page should sit behind a backdrop while one task takes over.

This page collects the patterns that come up in shipped UIs. Each example is copy-paste ready and uses the same @/components/ui/dialog import path the Drivn CLI installs under, so the snippets compile the moment you run npx drivn add dialog. Drivn's Dialog uses dot notation — Dialog.Trigger opens, Dialog.Content renders the panel — and a title prop replaces the DialogHeader plus DialogTitle scaffold you might be used to.

If you want a side-anchored variant for mobile-friendly task flows, reach for Drawer. For an anchored, non-modal floating panel, use Popover. The Dialog examples below stay focused on the modal pattern: a basic confirmation, a controlled-state dialog, a chromeless dialog without the built-in close button, a form dialog that submits on action, and a nested dialog stacked on top of 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

The native <dialog> element manages a top-layer stack on the browser side, so opening one Dialog from inside another stacks them in z-order without manual portal mounting. Each Dialog.Content listens to its own context provider, and setOpen(false) on the inner one closes only the inner dialog. The browser returns focus to the outer dialog's previously focused element automatically.

Use nested dialogs sparingly — most of the time a single dialog with a multi-step body works better than two stacked ones. The right case is a confirmation that interrupts a longer task: an "Are you sure you want to discard?" prompt opened from inside an edit dialog, where confirming the inner dialog dismisses both. The example below shows the structure: an outer Dialog wraps the editor, an inner Dialog handles the confirmation.

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.