Use Dialog for a focused task that interrupts the current page. Use Alert Dialog for consequential confirmation. DialogContent includes the portal and backdrop; do not add a second modal wrapper.
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function DialogDemo() {
const [open, setOpen] = useState(false);
const [savedName, setSavedName] = useState("");
return (
<div className="grid justify-items-center gap-4">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button />}>Edit profile</DialogTrigger>
<DialogContent>
<form
className="grid gap-5"
onSubmit={(event) => {
event.preventDefault();
setSavedName(String(new FormData(event.currentTarget).get("name") ?? ""));
setOpen(false);
}}
>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
This demo only updates local state. No data is sent.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="dialog-name">Name</Label>
<Input id="dialog-name" name="name" defaultValue={savedName || "Alex Doe"} required />
</div>
<DialogFooter>
<DialogClose render={<Button variant="neutral" />}>Cancel</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<output className="min-h-5 text-sm">
{savedName ? `Saved: ${savedName}` : "Open the dialog, try Tab, then Escape."}
</output>
</div>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/dialog.jsonnpm install @base-ui/react@^1.8.0Also install Button, then create components/ui/dialog.tsx:
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type DialogContentProps = DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
};
function Dialog(props: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root {...props} />;
}
function DialogTrigger(props: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal(props: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose(props: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
{...props}
className={(state) =>
cn(
"fixed inset-0 isolate z-50 bg-overlay duration-200 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogContentProps) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
{...props}
className={(state) =>
cn(
"fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-base border-2 border-border bg-background p-6 text-foreground shadow-shadow duration-200 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
{showCloseButton && (
<DialogClose
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogClose>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-3 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && <DialogClose render={<Button variant="outline" />}>Close</DialogClose>}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
{...props}
className={(state) =>
cn(
"font-heading text-lg leading-none tracking-tight",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
{...props}
className={(state) =>
cn(
"text-sm font-base text-foreground",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
The live profile form additionally uses Input and Label. Install them before copying the Code. Its local save does not send a request. In an application, close only after an asynchronous operation succeeds and keep the dialog open on failure.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/input.json https://neobrutal-ui.andongmin.com/r/label.json#Usage
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
export default function WorkspaceDialog() {
return (
<Dialog>
<DialogTrigger render={<Button />}>Workspace settings</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Workspace settings</DialogTitle>
<DialogDescription>Update preferences shared with your team.</DialogDescription>
</DialogHeader>
<p>Place the task controls here.</p>
</DialogContent>
</Dialog>
);
}#API reference
| Prop / part | Purpose | Default |
|---|---|---|
Dialog.open / onOpenChange | Controlled visibility | Uncontrolled |
Dialog.defaultOpen | Initial visibility | false |
DialogContent.showCloseButton | Built-in close control | true |
DialogFooter.showCloseButton | Add a close button to the footer | false |
DialogContent.initialFocus / finalFocus | Focus destinations | Base UI behavior |
DialogTrigger.render / DialogClose.render | Compose with a button | Primitive element |
#Accessibility
Keep a meaningful title and description. Tab and Shift+Tab stay in the modal; Escape closes a dismissible dialog. Verify that focus returns to its trigger. Do not remove the close control without providing another clear dismissal action. For longer tasks, keep actions reachable while the content scrolls.
DialogContent limits its height to the dynamic viewport minus 2rem and scrolls overflowing content by default.
For a fixed header or footer, use the flex-column examples below: keep those sections shrink-0 and put min-h-0 overflow-y-auto on the body.
#Examples
#Scrollable content
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const reviewNotes = [
"Review the summary, owner, and due date before sharing this workspace with the team.",
"Confirm that keyboard navigation, empty states, and narrow-screen layouts are in the release checklist.",
"Record unresolved decisions next to the component they affect so the next review starts with context.",
];
export default function DialogWithScrollableContent() {
return (
<Dialog>
<DialogTrigger render={<Button />}>Scrollable Content</DialogTrigger>
<DialogContent className="flex flex-col overflow-hidden sm:max-w-[425px]">
<DialogHeader className="shrink-0">
<DialogTitle>Project review notes</DialogTitle>
<DialogDescription>
Check how long-form content scrolls inside the modal surface.
</DialogDescription>
</DialogHeader>
<div className="-mx-6 min-h-0 max-h-[500px] overflow-y-auto px-6 text-sm">
{Array.from({ length: 9 }, (_, index) => (
<p key={index} className="mb-4 leading-normal">
{reviewNotes[index % reviewNotes.length]}
</p>
))}
</div>
</DialogContent>
</Dialog>
);
}
#Sticky footer
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const reviewNotes = [
"Review the summary, owner, and due date before sharing this workspace with the team.",
"Confirm that keyboard navigation, empty states, and narrow-screen layouts are in the release checklist.",
"Record unresolved decisions next to the component they affect so the next review starts with context.",
];
export default function DialogWithStickyFooter() {
return (
<Dialog>
<DialogTrigger render={<Button />}>Sticky Footer</DialogTrigger>
<DialogContent className="flex flex-col overflow-hidden sm:max-w-lg">
<DialogHeader className="shrink-0">
<DialogTitle>Review checklist</DialogTitle>
<DialogDescription>
Scroll through the notes while the close action remains available.
</DialogDescription>
</DialogHeader>
<div className="-mx-6 min-h-0 max-h-[500px] overflow-y-auto px-6 text-sm">
<h4 className="mb-4 text-lg leading-none font-medium">Project review notes</h4>
{Array.from({ length: 9 }, (_, index) => (
<p key={index} className="mb-4 leading-normal">
{reviewNotes[index % reviewNotes.length]}
</p>
))}
</div>
<DialogFooter className="shrink-0">
<DialogClose render={<Button />}>Close review</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}