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.
1 import { Dialog } from "@/components/ui/dialog" 2 import { Button } from "@/components/ui/button" 3 4 export 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.
1 import { useState } from "react" 2 import { Dialog } from "@/components/ui/dialog" 3 import { Button } from "@/components/ui/button" 4 5 export 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 }
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.
1 import { useState } from "react" 2 import { Dialog } from "@/components/ui/dialog" 3 import { Button } from "@/components/ui/button" 4 import { Input } from "@/components/ui/input" 5 6 export 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.
1 import { useState } from "react" 2 import { Dialog } from "@/components/ui/dialog" 3 import { Button } from "@/components/ui/button" 4 5 export 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 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
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.