Drivn vs shadcn/ui — Dialog Component Compared
Side-by-side comparison of Drivn Dialog vs shadcn/ui — native <dialog> element, zero runtime deps, dot notation, vs the Radix primitive wrapper.
Drivn and shadcn/ui ship modal dialogs from different foundations. shadcn/ui re-exports Radix UI's Dialog primitive — every subcomponent is a thin wrapper over @radix-ui/react-dialog, and the package pulls in a runtime dependency for portal management, focus trapping, and animation orchestration via tailwindcss-animate. Drivn's Dialog is built on the native HTML <dialog> element. The browser handles modal stacking, focus trap, and Escape-to-close through showModal(); the component file is around a hundred lines and ships zero runtime UI dependencies.
The API surface also diverges. shadcn exposes nine or so named exports — Dialog, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogClose, DialogHeader, DialogFooter, DialogTitle, DialogDescription — and you import each by name. Drivn collapses the same surface into dot notation: Dialog, Dialog.Trigger, Dialog.Content. The title becomes a prop on Dialog.Content, the close button renders automatically via showClose, and the overlay is a single CSS class on the <dialog> element. Two imports versus nine, no Radix primitive in package.json.
This page lays out every difference: underlying primitive, dependency footprint, controlled state, animation technique, and how each handles focus and dismissal. The shadcn equivalent shown in each section matches the published dialog.tsx recipe, not a hypothetical wrapper. If you already use shadcn dialogs and find yourself repeating the DialogHeader/DialogTitle pattern at every call site, the Drivn API collapses that boilerplate into a `title` prop.
Side-by-side comparison
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Underlying primitive | Native <dialog> element | @radix-ui/react-dialog |
| Runtime UI dependencies | Zero | Radix Dialog + tailwindcss-animate |
| Subcomponents to import | 3 (Dialog, .Trigger, .Content) | 9+ flat exports |
| Title surface | title prop | <DialogTitle> component |
| Close button | Built-in via showClose | Built into DialogContent |
| Focus trap | Browser via showModal() | Radix focus-scope |
| Outside-click dismiss | Built-in | Built-in (Radix) |
| Escape dismiss | Native cancel event | Radix keydown handler |
| Animation | CSS transitions on opacity + scale | tailwindcss-animate keyframes |
| Controlled API | open + onOpenChange | open + onOpenChange |
| License | MIT | MIT |
| Copy-paste install |
API side-by-side
shadcn/ui's Dialog is a flat namespace of named exports — you import Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, and DialogDescription separately, and the title row is built by composing <DialogHeader> around <DialogTitle> and <DialogDescription>. Every dialog repeats that scaffold. Drivn collapses the surface into dot notation. Dialog.Trigger opens, Dialog.Content accepts a title string, and the heading row is rendered for you when the prop is set. There is no header subcomponent and no description subcomponent — write your body content as plain JSX inside Dialog.Content.
The call site goes from six imports and three nested wrappers to two imports and a flat children block. For a confirmation dialog, that is the difference between fifteen lines and seven. The full Drivn surface, including the controlled-state hook into Dialog, stays one file you can edit after install.
1 // shadcn/ui — flat exports + composed header 2 import { 3 Dialog, 4 DialogContent, 5 DialogDescription, 6 DialogHeader, 7 DialogTitle, 8 DialogTrigger, 9 } from '@/components/ui/dialog' 10 import { Button } from '@/components/ui/button' 11 12 export function Confirm() { 13 return ( 14 <Dialog> 15 <DialogTrigger asChild> 16 <Button>Open Dialog</Button> 17 </DialogTrigger> 18 <DialogContent> 19 <DialogHeader> 20 <DialogTitle>Confirm Action</DialogTitle> 21 <DialogDescription> 22 Are you sure you want to continue? 23 </DialogDescription> 24 </DialogHeader> 25 <Button>Confirm</Button> 26 </DialogContent> 27 </Dialog> 28 ) 29 } 30 31 // Drivn — dot notation + title prop 32 import { Dialog } from '@/components/ui/dialog' 33 import { Button } from '@/components/ui/button' 34 35 export default function Page() { 36 return ( 37 <Dialog> 38 <Dialog.Trigger>Open Dialog</Dialog.Trigger> 39 <Dialog.Content title="Confirm Action"> 40 <p>Are you sure you want to continue?</p> 41 <Button>Confirm</Button> 42 </Dialog.Content> 43 </Dialog> 44 ) 45 }
Dependency footprint
shadcn's Dialog adds @radix-ui/react-dialog to your package.json plus tailwindcss-animate for the entry and exit keyframes. Radix in turn pulls a tree of focus-scope, dismissable-layer, portal, and presence packages. The component itself is a wrapper file — the actual modal logic lives inside the Radix primitive, which means upgrading Radix can change behavior under your feet.
Drivn ships zero runtime UI dependencies. The Dialog source imports React, lucide-react for the close icon, cn from your local utils, and Drivn's Button for the trigger — no Radix, no animate utility plugin. The native <dialog> element provides modal stacking, focus trap, and Escape via showModal(). The full component is one file you own, and bundle analysis shows the dialog adding under 2 KB of gzipped JS to your route.
1 // shadcn/ui — package.json after `npx shadcn add dialog` 2 { 3 "dependencies": { 4 "@radix-ui/react-dialog": "^1.1.x", 5 "tailwindcss-animate": "^1.0.x" 6 } 7 } 8 9 // Drivn — package.json after `npx drivn add dialog` 10 // (no new dependencies added)
Animation technique
shadcn animates with tailwindcss-animate utilities driven by Radix's data-[state=open] and data-[state=closed] attributes. The overlay fades, the content scales and fades on enter, and the closed-state utilities run on exit before Radix unmounts. You configure the timing through the plugin's utility classes.
Drivn uses plain CSS transitions on opacity and scale, swapped by a local visible boolean. When the dialog opens, el.showModal() runs, body overflow locks to hidden, then requestAnimationFrame(() => setVisible(true)) flips the classes from opacity-0 scale-95 to opacity-100 scale-100. Closing reverses the flag and a 150 ms timeout calls el.close(). The transition is the standard Tailwind transition-opacity duration-150 and transition-[scale] duration-150 — copy these lines into other components and the same animation feel applies.
1 // Drivn — animation lives in the styles object + visible flag 2 const styles = { 3 base: cn( 4 'fixed inset-0 m-0 p-0 border-none outline-none', 5 'max-w-none max-h-none w-screen h-dvh', 6 'flex items-center justify-center bg-overlay', 7 'backdrop-blur-sm transition-opacity duration-150', 8 'ease-out' 9 ), 10 content: cn( 11 'relative w-full max-w-md mx-4 p-6 shadow-xl', 12 'bg-card border border-border rounded-[20px]', 13 'transition-[scale] duration-150 ease-out' 14 ), 15 } 16 17 // applied conditionally on the <dialog> and inner <div> 18 className={cn(styles.base, visible ? 'opacity-100' : 'opacity-0 pointer-events-none')} 19 className={cn(styles.content, visible ? 'scale-100' : 'scale-95', className)}
Focus, Escape, and outside-click dismiss
shadcn delegates focus trap and Escape handling to Radix's focus-scope and dismissable-layer packages. They cover edge cases — return focus on close, layered dialogs, pointer-down outside detection — at the cost of a few hundred lines of imported runtime.
Drivn leans on the native <dialog> element for the same behaviors. el.showModal() enters the top-layer, traps focus inside, and dispatches a cancel event when Escape is pressed — the component listens for that event and calls setOpen(false) after preventDefault(). Outside-click is a single click handler on the <dialog> that closes when e.target === e.currentTarget. No portal, no synthetic keydown listener. Browser support is universal in evergreen targets; the Dialog component reference covers the full lifecycle.
1 React.useEffect(() => { 2 const el = ref.current 3 if (!el) return 4 const onCancel = (e: Event) => { 5 e.preventDefault() 6 setOpen(false) 7 } 8 el.addEventListener('cancel', onCancel) 9 if (open) { 10 if (!el.open) el.showModal() 11 document.body.style.overflow = 'hidden' 12 requestAnimationFrame(() => setVisible(true)) 13 return () => { 14 el.removeEventListener('cancel', onCancel) 15 document.body.style.overflow = '' 16 } 17 } 18 setVisible(false) 19 const id = setTimeout(() => { 20 if (el.open) el.close() 21 }, 150) 22 return () => { 23 el.removeEventListener('cancel', onCancel) 24 clearTimeout(id) 25 } 26 }, [open, setOpen]) 27 28 // outside-click on the <dialog> backdrop 29 onClick={(e) => { 30 if (e.target === e.currentTarget) setOpen(false) 31 }}
Controlled state
Both libraries expose the same controlled API: an open boolean and an onOpenChange callback. With shadcn you pass them to the Dialog root from @radix-ui/react-dialog. With Drivn you pass them to the same Dialog root, and the DialogRoot component reconciles controlled and uncontrolled modes — when open is undefined, internal useState drives state; when it is defined, the prop wins and onOpenChange fires alongside any internal updates.
The shape matches by design, so migrating an existing shadcn-driven dialog state machine to Drivn is a search-and-replace on the imports, not a rewrite. The Dialog props reference lists every prop and matches the Radix names where the semantics overlap.
1 import { useState } from 'react' 2 import { Dialog } from '@/components/ui/dialog' 3 4 export default function Page() { 5 const [open, setOpen] = useState(false) 6 7 return ( 8 <Dialog open={open} onOpenChange={setOpen}> 9 <Dialog.Trigger>Open</Dialog.Trigger> 10 <Dialog.Content title="Edit profile"> 11 <p>Form goes here.</p> 12 </Dialog.Content> 13 </Dialog> 14 ) 15 }
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
Native <dialog> covers the hard parts — top-layer stacking, focus trap, Escape via the cancel event — without a runtime dependency. Browser support is universal across evergreen targets, the modal API is stable, and the shipped component file stays around a hundred lines. Radix solves the same problems with a focus-scope and dismissable-layer module tree, which is excellent engineering but adds a runtime cost Drivn deliberately avoids.
Yes — 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, 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.
Most of it, yes. The controlled API matches — open plus onOpenChange on the root — so any state machine driving a shadcn dialog drives a Drivn dialog as-is. The structural changes are at the JSX layer: collapse <DialogHeader><DialogTitle>...</DialogTitle></DialogHeader> into a title prop on Dialog.Content, and drop the explicit DialogClose since Drivn renders the close button internally.
No — pass showClose={false} to Dialog.Content and the icon button disappears. The cancel listener still fires on Escape, the backdrop click still closes, and you can render your own close trigger inside the body if you want a different position or icon. Calling setOpen(false) from any custom close button works the same way the built-in one does.
The Dialog file starts with "use client" because it manages state with useState and listens to DOM events. You can import it from a Server Component, and Next.js draws the client boundary automatically based on the directive at the top of the file. Render the trigger and content inside a server-rendered page — the dialog hydrates on the client without extra configuration.

