Skip to content
Drivn
5 min read

How to Add a Dialog to a React App

Step-by-step guide to adding a copy-and-own modal Dialog to any React project with the Drivn CLI — native <dialog> element, controlled state, no portal.

A modal dialog is a focused overlay that interrupts the page to ask for a decision — confirm a delete, edit a field, read a message — while trapping focus and blocking everything behind it. Building one well means promoting the panel above all other content, trapping the Tab key inside it, closing on Escape and outside clicks, and locking body scroll. Most teams reach for a portal library and a focus-trap package to get there.

Drivn writes the component into your repository instead. The CLI copies a dialog.tsx file built on the browser's native <dialog> element — calling showModal() handles the top layer, focus trap, and Escape for free, so there is no Radix primitive and no floating-ui in your package.json. After install the file is yours: read it end to end, restyle the styles object, and ship today. Because it tracks open state with React hooks and drives the native element from an effect, it is a client component marked 'use client', while the page around it can still render on the server.

This guide adds the Dialog to an existing React app in about ten minutes — install the CLI, render it uncontrolled, switch to controlled state, and customize the panel. It works the same in Vite + React or a Next.js App Router project. For the Next.js-specific walkthrough see the Next.js Dialog guide; for live patterns see the Dialog examples.

Prerequisites

Before installing the Dialog, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your CSS, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired. For a custom setup, check the compilerOptions.paths entry in tsconfig.json; the installation page lists the minimal config. The Dialog composes Drivn's own Button for its trigger and imports the X icon from lucide-react for the close control — the CLI resolves both and adds lucide-react to your package.json during install if it is missing.

Step 1 — Install Drivn via the CLI

Run the CLI from your project root to add the Dialog source file. The command prompts once for your install directory (defaulting to src/components/ui/), writes dialog.tsx, and resolves its component dependency by also writing button.tsx, since the Dialog imports Button directly for its trigger. It adds lucide-react to your package.json if it is not already present. No global config file is created — both files are TypeScript you edit like any other component. Confirm they landed in your editor, then commit them. If your project uses a monorepo layout or a non-standard path, the CLI docs cover the flags for targeting a custom location during install.

1# add the Dialog (the CLI also writes its Button dependency)
2npx drivn add dialog
3
4# verify both files were written
5ls src/components/ui/dialog.tsx src/components/ui/button.tsx

Step 2 — Render an uncontrolled dialog

Open the page where the Dialog should live and import it from your UI directory. The compound API is three parts — Dialog, Dialog.Trigger, and Dialog.Content. In the simplest uncontrolled form the trigger opens the modal and the built-in close button and Escape key close it, so you write no useState at all. Dialog.Content accepts a title string that renders the heading; anything you nest inside becomes the body. Because the Dialog holds open state with useState and drives the native <dialog> element from an effect, the source begins with 'use client' — in a Next.js App Router project keep this in a small client component and render it as an island. See the Dialog docs for the full prop table.

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

Step 3 — Switch to controlled state

The uncontrolled form is enough for a confirm button, but when the dialog's open state depends on app logic — opening it after a mutation resolves, or closing it from a keyboard shortcut — pass open and onOpenChange to the root. The source reads const open = controlled ?? uncontrolled, so supplying open switches the component into controlled mode and your useState value becomes the single source of truth. Update it inside onOpenChange to keep the trigger, backdrop, and Escape key in sync. This is the pattern to reach for when a parent owns several dialogs or a server action returns and you want to surface a success modal.

1'use client'
2import { useState } from 'react'
3import { Dialog } from '@/components/ui/dialog'
4
5export function EditProfile() {
6 const [open, setOpen] = useState(false)
7 return (
8 <Dialog open={open} onOpenChange={setOpen}>
9 <Dialog.Trigger>Edit profile</Dialog.Trigger>
10 <Dialog.Content title="Edit profile">
11 <p>Make changes, then save.</p>
12 </Dialog.Content>
13 </Dialog>
14 )
15}

Step 4 — Customize the panel and styles

The Content sub-component takes three presentation props. title renders an <h2> heading; omit it for a bare dialog. showClose defaults to true and renders the round close button in the top-right corner — pass showClose={false} for flows where dismissal should be deliberate, like a destructive confirmation. className merges onto the panel via cn, so you can widen it past the default max-w-md per instance. To restyle globally, open src/components/ui/dialog.tsx and edit the styles object near the top — content sets the panel (bg-card border border-border rounded-[20px]) and base sets the backdrop (bg-overlay backdrop-blur-sm). Every class reads from your Tailwind tokens, so editing the colors in globals re-themes every Dialog at once.

1const styles = {
2 base: cn(
3 'fixed inset-0 m-0 p-0 border-none outline-none',
4 'max-w-none max-h-none w-screen h-dvh',
5 'flex items-center justify-center bg-overlay',
6 'backdrop-blur-sm transition-opacity duration-150',
7 'ease-out'
8 ),
9 content: cn(
10 'relative w-full max-w-md mx-4 p-6 shadow-xl',
11 'bg-card border border-border rounded-[20px]',
12 'transition-[scale] duration-150 ease-out'
13 ),
14 // ...title, close
15}
Get started

Install Drivn in one command

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

Follow Drivn updates
New components, improvements, and guides every release.
Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add dialog. The Dialog has no dependency on Next.js or any router — it renders anywhere React and Tailwind reach the DOM. Vite + React is the most common non-Next setup and works without extra configuration; the CLI also writes the Button dependency the Dialog uses for its trigger.

The Dialog is a client component — it is marked 'use client' because it holds open state with useState and drives the native <dialog> element from an effect. Your surrounding page and layout still render on the server. The usual pattern is a small client component holding the Dialog and its trigger, rendered as an island inside an otherwise server-rendered route. Next.js inserts the client boundary at the import automatically.

No. The Dialog renders the browser's native <dialog> element and calls showModal(), which promotes it to the top layer above all other page content and traps focus inside it without any portal or focus-trap dependency. The entire behavior — open, close, Escape, backdrop click, scroll lock — lives in the one file the CLI writes to your repo, plus the lucide-react icon and Drivn's own Button for the trigger.

The Content source wires both. A backdrop click closes because the outer onClick checks e.target === e.currentTarget and calls setOpen(false). Escape is handled through the native cancel event — an effect calls e.preventDefault() then setOpen(false) so React stays the source of truth rather than letting the browser close the element behind React's back. Both are a few lines in the file you own.

Yes. Pass showClose={false} to Dialog.Content to hide the top-right close button — useful for destructive confirmations where dismissal should be deliberate. Pass a className to widen the panel past its default max-w-md or change padding per instance; it merges onto the panel through cn. For a global change, edit the styles.content line in the dialog.tsx file you own after install.