Next.js Dialog for the App Router
Add a Next.js Dialog with focus trapping, Escape-to-close, and scroll lock. Drivn builds on the native <dialog> element — no portal, no focus-trap deps.
Modal dialogs are deceptively hard in the Next.js App Router. A modal has to trap focus, close on Escape, render above everything else, and lock body scroll — and it has to do all that from a client island inside a tree that mostly renders on the server. The usual answer stacks a portal library on a focus-trap package on the glue between them.
Drivn's Dialog skips the portal entirely. After install it lives in src/components/ui/dialog.tsx, marks itself 'use client', and builds on the browser's native <dialog> element. Calling showModal() promotes the dialog to the top layer above all other content, traps focus, and handles Escape for free — no React portal, no focus-trap dependency, no z-index war. It imports React, the X icon from lucide-react, the cn utility, and Drivn's own Button, and nothing else.
This guide installs Drivn in a Next.js 16 project, renders the Dialog as a client island, wires controlled open state, and customizes the panel. Every snippet comes from the component's real API. For the full reference see the Dialog docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Dialog.
Install in a Next.js 16 project
Drivn installs through a small CLI that writes the component source directly into your repository — there is no runtime npm package to version-lock. From the root of your Next.js 16 project run npx drivn add dialog. Because the Dialog imports Drivn's Button for its trigger, the CLI pulls that file in too, and adds lucide-react to your package.json if it is missing — that is the dialog's only third-party dependency, used for the close icon. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own both files; future Drivn releases will not overwrite them. Commit the change for a clean baseline before customizing.
1 # from the root of your Next.js 16 project 2 npx drivn add dialog
Render as a client island
An App Router page is a server component by default, so it cannot hold the dialog's open state. The Dialog is marked 'use client' at the top of its source because it tracks open with React state and toggles the native element with hooks. The clean pattern is to keep the Dialog and its trigger inside a small client component, then render that island in an otherwise server-rendered page. The compound API is three parts — Dialog, Dialog.Trigger, and Dialog.Content — and in the simplest uncontrolled form the trigger manages open state for you, so you write no useState at all. No dynamic() import and no SSR-disable flag are needed — Next.js inserts the client boundary at the import. See the installation guide for project setup.
1 'use client' 2 import { Dialog } from '@/components/ui/dialog' 3 import { Button } from '@/components/ui/button' 4 5 export 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 }
How open and close work
Unlike most React modals, the Drivn Dialog does not portal into document.body. The Content sub-component renders a real <dialog> element and drives it through one effect: when open becomes true it calls el.showModal(), sets document.body.style.overflow to hidden for scroll lock, and flips a visible flag inside requestAnimationFrame so the panel transitions in. Escape is wired through the native cancel event — the effect calls e.preventDefault() and setOpen(false) so React stays the source of truth. A backdrop click closes too, because the outer onClick checks e.target === e.currentTarget. The relevant styles slice below is copied verbatim from dialog.ts — edit these classes to restyle the overlay or panel.
1 const 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 }
Controlled open 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 state becomes the single source of truth. This is the pattern to reach for in App Router projects where a server action returns and you want to surface a success dialog, or where a parent island owns several dialogs. Bind open to your useState value and update it inside onOpenChange.
1 'use client' 2 import { useState } from 'react' 3 import { Dialog } from '@/components/ui/dialog' 4 5 export function ControlledDialog() { 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 }
Customize the panel
The Content sub-component takes three presentation props. title renders an <h2> heading at the top of the panel; 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 or change its padding per instance without editing the source. For a global restyle, edit the styles.content line in dialog.tsx — every class reads from your Tailwind tokens, so changing the colors in globals re-themes every Dialog at once. See Drivn vs shadcn/ui Dialog for how this compares.
1 <Dialog> 2 <Dialog.Trigger>Delete account</Dialog.Trigger> 3 <Dialog.Content 4 title="Delete account" 5 showClose={false} 6 className="max-w-lg" 7 > 8 <p>This action cannot be undone.</p> 9 </Dialog.Content> 10 </Dialog>
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
The Dialog itself is a client component — it is marked 'use client' because it tracks open state with React hooks and drives the native <dialog> element from an effect. Your surrounding page and layout can still render on the server. The standard 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.
Switch it to controlled mode by passing open and onOpenChange to the root. The source uses controlled ?? uncontrolled, so once you supply open your state drives the dialog and you can set it to false from anywhere — after a server action resolves, on a keyboard shortcut, or from a parent island. In uncontrolled mode the trigger and the close button manage state for you with no extra wiring.
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.

