shadcn/ui Dialog provides a fully accessible modal implementation built on Radix UI. These 4 examples cover the most common modal patterns in React applications.
Installation
npx shadcn@latest add dialog button input label
Basic Usage
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
</DialogHeader>
Content goes here
</DialogContent>
</Dialog>
Key Props
| Prop | Type | Description |
|---|---|---|
open | boolean | Controlled open state |
onOpenChange | (open: boolean) => void | Called when open state changes |
defaultOpen | boolean | Uncontrolled initial state |
modal | boolean | Whether dialog is modal (default true) |
1. Basic shadcn/ui Dialog
Simple dialog with title, description and close/action buttons.
tsx
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export default function BasicDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button style={{ background: "#fd4766" }}>Open Dialog</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>About BootstrapPlanet</DialogTitle>
<DialogDescription>
Free Bootstrap 5, React and Angular components with live previews.
</DialogDescription>
</DialogHeader>
<div className="py-3">
<p className="text-sm text-muted-foreground">
200+ components with copy-paste code. No signup required.
Built for developers who want to ship faster.
</p>
</div>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button style={{ background: "#fd4766" }}>
Browse Components →
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}2. React Form Modal
Dialog with a form inside. Includes controlled inputs and submit handler.
tsx
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Plus } from "lucide-react"
export default function FormDialog() {
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !email) return
console.log("Submitted:", { name, email })
setOpen(false)
setName("")
setEmail("")
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button style={{ background: "#fd4766" }}>
<Plus size={16} className="mr-2" />
Add User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Add New User</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Gagan Singh"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="gagan@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="role">Role</Label>
<select id="role" className="w-full border rounded-md px-3 py-2 text-sm">
<option>Admin</option>
<option>Editor</option>
<option>Viewer</option>
</select>
</div>
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" className="flex-1" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" className="flex-1" style={{ background: "#fd4766" }}>
Add User
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}3. Confirmation Dialog
Delete confirmation dialog with destructive button styling.
tsx
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Trash2, AlertTriangle } from "lucide-react"
interface ConfirmDialogProps {
title: string
description: string
onConfirm: () => void
trigger?: React.ReactNode
}
export function ConfirmDialog({ title, description, onConfirm, trigger }: ConfirmDialogProps) {
const [open, setOpen] = useState(false)
const handleConfirm = () => {
onConfirm()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<div onClick={() => setOpen(true)} className="cursor-pointer inline-block">
{trigger || <Button variant="destructive" size="sm"><Trash2 size={14} /></Button>}
</div>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="text-center sm:text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-3">
<AlertTriangle className="text-red-500" size={24} />
</div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-1">{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:flex-row-reverse">
<Button
variant="destructive"
onClick={handleConfirm}
className="flex-1 sm:flex-none"
>
Delete
</Button>
<Button
variant="outline"
onClick={() => setOpen(false)}
className="flex-1 sm:flex-none"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// Usage example
export default function ConfirmDialogExample() {
const [deleted, setDeleted] = useState(false)
return (
<div className="flex items-center gap-4">
<ConfirmDialog
title="Delete Template"
description="This will permanently delete Marvel Dashboard. This action cannot be undone."
onConfirm={() => setDeleted(true)}
trigger={
<Button variant="destructive" size="sm">
<Trash2 size={14} className="mr-2" /> Delete Template
</Button>
}
/>
{deleted && <p className="text-sm text-muted-foreground">Template deleted ✓</p>}
</div>
)
}4. React Image Preview Modal
Click an image to open a full-size preview in a dialog.
tsx
import { useState } from "react"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { X, ZoomIn } from "lucide-react"
const images = [
{ src: "https://picsum.photos/800/500?random=20", thumb: "https://picsum.photos/300/200?random=20", alt: "Marvel Dashboard" },
{ src: "https://picsum.photos/800/500?random=21", thumb: "https://picsum.photos/300/200?random=21", alt: "PORTO Template" },
{ src: "https://picsum.photos/800/500?random=22", thumb: "https://picsum.photos/300/200?random=22", alt: "Proctu Medical" },
]
export default function ImagePreviewModal() {
const [selected, setSelected] = useState<typeof images[0] | null>(null)
return (
<>
<div className="grid grid-cols-3 gap-3">
{images.map((img) => (
<div
key={img.src}
className="relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => setSelected(img)}
>
<img
src={img.thumb}
alt={img.alt}
className="w-full h-32 object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ZoomIn className="text-white" size={24} />
</div>
</div>
))}
</div>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-3xl p-2">
{selected && (
<>
<img
src={selected.src}
alt={selected.alt}
className="w-full rounded-lg"
/>
<p className="text-center text-sm text-muted-foreground mt-2 pb-1">
{selected.alt}
</p>
</>
)}
</DialogContent>
</Dialog>
</>
)
}Frequently Asked Questions
Use controlled mode with open and onOpenChange props: const [open, setOpen] = useState(false). Pass <Dialog open={open} onOpenChange={setOpen}>. Call setOpen(true) from anywhere to open programmatically. Call setOpen(false) to close.
Add onInteractOutside to DialogContent: <DialogContent onInteractOutside={(e) => e.preventDefault()}>. This stops the dialog closing when clicking outside. Users must use the close button or keyboard Escape.
Override the DialogContent size classes: <DialogContent className='max-w-full h-full m-0 rounded-none sm:rounded-none'>. Or use shadcn/ui Sheet component which slides in from an edge — better UX than a full-screen dialog on mobile.
Yes but use with caution. Each Dialog needs its own state. Focus trapping can get complex with nested dialogs. For complex flows consider a multi-step wizard inside a single dialog rather than nested dialogs.
Need a Full React + Next.js Dashboard Template?
Get a complete React + Next.js dashboard with 50+ components — built by the same team behind BootstrapPlanet.
Browse Templates →Use code FIRST30 for 30% off.