- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"use client";
import { Dialog, DialogBody, DialogFooter, useDialog } from "@/components/ui/dialog";
function DoneButton() {
const { close } = useDialog();
return <button type="button" onClick={close}>Done</button>;
}
export function DialogDemo() {
return (
<>
<Dialog
title={"Project settings"}
description={"Manage the details and preferences for this project."}
trigger={<button type="button">Open dialog</button>}
modal={true}
contentProps={{ size: "md", showCloseButton: true, keepMounted: false }}
>
<>
<DialogBody>
Your settings form
</DialogBody>
<DialogFooter>
<DoneButton />
</DialogFooter>
</>
</Dialog>
</>
);
}Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/dialog.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- @base-ui/react@^1.8.0
- cn
- lucide-react
- motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn lucide-react motion@^12.38.0Usage
import { Dialog, DialogBody } from "@/components/ui/dialog";
export function ProjectDetails() {
return (
<Dialog
title="Project details"
description="Manage your project preferences."
trigger={<button type="button">View project</button>}
>
<DialogBody>Your project details</DialogBody>
</Dialog>
);
}Plain children are the default. The children render function is optional, not required.
Dialog owns the associated trigger, portal, backdrop, header, panel, and built-in X.
Only Dialog, DialogBody, DialogFooter, useDialog, and useDialogControl are public components/hooks.
The upstream compound primitives remain internal.
"use client";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogFooter,
useDialogControl,
} from "@/components/ui/dialog";
export const DialogDemo = () => (
<Dialog
title="Project details"
description="A focused space for the task at hand."
trigger={<Button variant="outline">Open dialog</Button>}
>
<DialogBody>
<p className="text-muted-foreground">
The header stays visible while the body scrolls. Use the close button or
Escape when you are done.
</p>
</DialogBody>
</Dialog>
);
export const DialogFormDemo = () => {
const nameId = useId();
const [savedName, setSavedName] = useState("Vandor UI");
return (
<div className="flex flex-col items-center gap-3">
<Dialog
title="Edit project"
description="Submit to update the name in this local demo."
trigger={<Button variant="outline">Edit project</Button>}
>
{({ close }) => (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault();
setSavedName(
String(new FormData(event.currentTarget).get("name"))
);
close();
}}
>
<DialogBody className="flex flex-col gap-2">
<label htmlFor={nameId} className="font-medium">
Project name
</label>
<input
id={nameId}
name="name"
defaultValue={savedName}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</DialogBody>
<DialogFooter>
<Button type="button" variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit">Save name</Button>
</DialogFooter>
</form>
)}
</Dialog>
<output className="text-sm text-muted-foreground">
Current name: {savedName}
</output>
</div>
);
};
export const DialogExternalDemo = () => {
const control = useDialogControl();
const opener = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={opener} variant="outline" onClick={control.open}>
Open from outside
</Button>
<Dialog
control={control}
title="External control"
description="This opener lives outside the dialog composition."
contentProps={{ finalFocus: opener }}
>
<DialogBody>
<p className="text-muted-foreground">
The controller and child hooks share the accepted state. Focus
returns to the external opener after closing.
</p>
</DialogBody>
<DialogFooter>
<Button onClick={control.close}>Done</Button>
</DialogFooter>
</Dialog>
</>
);
};
export const DialogControlledDemo = () => {
const [open, setOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const [blocked, setBlocked] = useState(false);
return (
<Dialog
title="Unsaved changes"
description="Mark the draft as edited to try a dismissal guard."
trigger={<Button variant="outline">Try dismissal guard</Button>}
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && dirty) {
details.cancel();
setBlocked(true);
return;
}
setBlocked(false);
setOpen(nextOpen);
}}
>
<DialogBody className="flex flex-col gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={dirty}
onChange={(event) => setDirty(event.target.checked)}
/>
Draft has unsaved changes
</label>
{blocked && (
<p role="status" className="text-muted-foreground">
Dismissal was blocked. Uncheck the draft option or discard changes.
</p>
)}
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDirty(false);
setBlocked(false);
setOpen(false);
}}
>
Discard and close
</Button>
</DialogFooter>
</Dialog>
);
};
export const DialogNestedDemo = () => (
<Dialog
title="Project settings"
trigger={<Button variant="outline">Open settings</Button>}
>
<DialogBody>
<Dialog
title="Advanced settings"
description="Escape dismisses only the topmost dialog."
trigger={<Button variant="outline">Open advanced settings</Button>}
contentProps={{ size: "sm" }}
>
{({ close }) => (
<>
<DialogBody>Each dialog owns a separate controller.</DialogBody>
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
)}
</Dialog>
</DialogBody>
</Dialog>
);Consumer setup
Use React 19, Tailwind CSS 4, and an initialized shadcn theme. The registry uses Base UI 1.8+
and Motion 12.38+. The historical new-york source directory does not mean this is a Radix
component: its starting point is shadcn's Base Nova Dialog.
The single distributed dialog.tsx uses cn, lucide-react, @base-ui/react, and motion/react.
It does not install Button, Loading, website audio, or additional registry components.
For manual installation, install the packages listed under Dependencies and copy the complete
source into your configured UI directory. Keep the shadcn theme tokens for background,
foreground, border, muted-foreground, accent, accent-foreground, and ring.
Motion requires no animation stylesheet or provider. The module includes "use client"
and can be imported from a Next.js Server Component; interactive examples belong in client components.
Review the CLI overwrite prompt if your project already has components/ui/dialog.tsx.
The Vandor API is intentionally different from shadcn's compound API.
Forms and stationary actions
"use client";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogFooter,
useDialogControl,
} from "@/components/ui/dialog";
export const DialogDemo = () => (
<Dialog
title="Project details"
description="A focused space for the task at hand."
trigger={<Button variant="outline">Open dialog</Button>}
>
<DialogBody>
<p className="text-muted-foreground">
The header stays visible while the body scrolls. Use the close button or
Escape when you are done.
</p>
</DialogBody>
</Dialog>
);
export const DialogFormDemo = () => {
const nameId = useId();
const [savedName, setSavedName] = useState("Vandor UI");
return (
<div className="flex flex-col items-center gap-3">
<Dialog
title="Edit project"
description="Submit to update the name in this local demo."
trigger={<Button variant="outline">Edit project</Button>}
>
{({ close }) => (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault();
setSavedName(
String(new FormData(event.currentTarget).get("name"))
);
close();
}}
>
<DialogBody className="flex flex-col gap-2">
<label htmlFor={nameId} className="font-medium">
Project name
</label>
<input
id={nameId}
name="name"
defaultValue={savedName}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</DialogBody>
<DialogFooter>
<Button type="button" variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit">Save name</Button>
</DialogFooter>
</form>
)}
</Dialog>
<output className="text-sm text-muted-foreground">
Current name: {savedName}
</output>
</div>
);
};
export const DialogExternalDemo = () => {
const control = useDialogControl();
const opener = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={opener} variant="outline" onClick={control.open}>
Open from outside
</Button>
<Dialog
control={control}
title="External control"
description="This opener lives outside the dialog composition."
contentProps={{ finalFocus: opener }}
>
<DialogBody>
<p className="text-muted-foreground">
The controller and child hooks share the accepted state. Focus
returns to the external opener after closing.
</p>
</DialogBody>
<DialogFooter>
<Button onClick={control.close}>Done</Button>
</DialogFooter>
</Dialog>
</>
);
};
export const DialogControlledDemo = () => {
const [open, setOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const [blocked, setBlocked] = useState(false);
return (
<Dialog
title="Unsaved changes"
description="Mark the draft as edited to try a dismissal guard."
trigger={<Button variant="outline">Try dismissal guard</Button>}
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && dirty) {
details.cancel();
setBlocked(true);
return;
}
setBlocked(false);
setOpen(nextOpen);
}}
>
<DialogBody className="flex flex-col gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={dirty}
onChange={(event) => setDirty(event.target.checked)}
/>
Draft has unsaved changes
</label>
{blocked && (
<p role="status" className="text-muted-foreground">
Dismissal was blocked. Uncheck the draft option or discard changes.
</p>
)}
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDirty(false);
setBlocked(false);
setOpen(false);
}}
>
Discard and close
</Button>
</DialogFooter>
</Dialog>
);
};
export const DialogNestedDemo = () => (
<Dialog
title="Project settings"
trigger={<Button variant="outline">Open settings</Button>}
>
<DialogBody>
<Dialog
title="Advanced settings"
description="Escape dismisses only the topmost dialog."
trigger={<Button variant="outline">Open advanced settings</Button>}
contentProps={{ size: "sm" }}
>
{({ close }) => (
<>
<DialogBody>Each dialog owns a separate controller.</DialogBody>
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
)}
</Dialog>
</DialogBody>
</Dialog>
);Wrap both DialogBody and DialogFooter in a native form with
className="flex min-h-0 flex-1 flex-col". Do not put the footer inside the body or nest forms.
Body scrolling keeps the title, actions, and X visible. Any wrapper in the flex chain must retain
the constrained height (min-h-0, flex-1, and flex-col where applicable).
Content is deliberately not automatically wrapped in DialogBody.
<Dialog title="Edit project" trigger={<button type="button">Edit</button>}>
{({ close }) => (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={async (event) => {
event.preventDefault();
const values = new FormData(event.currentTarget);
// Application-owned: handle errors and pending state here.
await saveProject(values);
close();
}}
>
<DialogBody>{/* Your labeled fields */}</DialogBody>
<DialogFooter>
<button type="button" onClick={close}>
Cancel
</button>
<button type="submit">Save</button>
</DialogFooter>
</form>
)}
</Dialog>This fragment assumes your application supplies saveProject and labeled fields.
Submitting does not close the dialog automatically. Close only after saving succeeds;
display failures and disable repeated submissions in your application. Cancel and dismissal
do not save data. The runnable demo saves only local state.
Controllers and external openers
"use client";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogFooter,
useDialogControl,
} from "@/components/ui/dialog";
export const DialogDemo = () => (
<Dialog
title="Project details"
description="A focused space for the task at hand."
trigger={<Button variant="outline">Open dialog</Button>}
>
<DialogBody>
<p className="text-muted-foreground">
The header stays visible while the body scrolls. Use the close button or
Escape when you are done.
</p>
</DialogBody>
</Dialog>
);
export const DialogFormDemo = () => {
const nameId = useId();
const [savedName, setSavedName] = useState("Vandor UI");
return (
<div className="flex flex-col items-center gap-3">
<Dialog
title="Edit project"
description="Submit to update the name in this local demo."
trigger={<Button variant="outline">Edit project</Button>}
>
{({ close }) => (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault();
setSavedName(
String(new FormData(event.currentTarget).get("name"))
);
close();
}}
>
<DialogBody className="flex flex-col gap-2">
<label htmlFor={nameId} className="font-medium">
Project name
</label>
<input
id={nameId}
name="name"
defaultValue={savedName}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</DialogBody>
<DialogFooter>
<Button type="button" variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit">Save name</Button>
</DialogFooter>
</form>
)}
</Dialog>
<output className="text-sm text-muted-foreground">
Current name: {savedName}
</output>
</div>
);
};
export const DialogExternalDemo = () => {
const control = useDialogControl();
const opener = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={opener} variant="outline" onClick={control.open}>
Open from outside
</Button>
<Dialog
control={control}
title="External control"
description="This opener lives outside the dialog composition."
contentProps={{ finalFocus: opener }}
>
<DialogBody>
<p className="text-muted-foreground">
The controller and child hooks share the accepted state. Focus
returns to the external opener after closing.
</p>
</DialogBody>
<DialogFooter>
<Button onClick={control.close}>Done</Button>
</DialogFooter>
</Dialog>
</>
);
};
export const DialogControlledDemo = () => {
const [open, setOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const [blocked, setBlocked] = useState(false);
return (
<Dialog
title="Unsaved changes"
description="Mark the draft as edited to try a dismissal guard."
trigger={<Button variant="outline">Try dismissal guard</Button>}
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && dirty) {
details.cancel();
setBlocked(true);
return;
}
setBlocked(false);
setOpen(nextOpen);
}}
>
<DialogBody className="flex flex-col gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={dirty}
onChange={(event) => setDirty(event.target.checked)}
/>
Draft has unsaved changes
</label>
{blocked && (
<p role="status" className="text-muted-foreground">
Dismissal was blocked. Uncheck the draft option or discard changes.
</p>
)}
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDirty(false);
setBlocked(false);
setOpen(false);
}}
>
Discard and close
</Button>
</DialogFooter>
</Dialog>
);
};
export const DialogNestedDemo = () => (
<Dialog
title="Project settings"
trigger={<Button variant="outline">Open settings</Button>}
>
<DialogBody>
<Dialog
title="Advanced settings"
description="Escape dismisses only the topmost dialog."
trigger={<Button variant="outline">Open advanced settings</Button>}
contentProps={{ size: "sm" }}
>
{({ close }) => (
<>
<DialogBody>Each dialog owns a separate controller.</DialogBody>
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
)}
</Dialog>
</DialogBody>
</Dialog>
);useDialogControl() creates a stable controller. Bind it to one mounted Dialog with control.
Its read-only reactive isOpen reflects the accepted state; open() and close() request
changes through Base UI and return void, not promises. onOpenChangeComplete runs after animation
completion. Actions without a mounted Dialog are ignored rather than queued.
Do not combine control with open, defaultOpen, or handle; types and runtime guards reject this.
onOpenChange remains available to observe or cancel requests with details.cancel().
An external ordinary button is not an associated trigger. Set contentProps.finalFocus to
its ref so focus has an explicit return target. Custom associated trigger components must
forward their ref and native button props.
For child components, call useDialog() at the top level:
function DoneButton() {
const { close } = useDialog();
return (
<button type="button" onClick={close}>
Done
</button>
);
}This lets you keep ordinary children even when a deeply nested action needs the controller. Do not call hooks directly inside a children or header render callback; render a component instead.
Controlled state and dismissal guards
Use defaultOpen for uncontrolled initial state, or open and onOpenChange for application-owned
state. In controlled mode, close() requests closing; the dialog stays open until its parent
updates open. Direct parent updates animate too.
"use client";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogFooter,
useDialogControl,
} from "@/components/ui/dialog";
export const DialogDemo = () => (
<Dialog
title="Project details"
description="A focused space for the task at hand."
trigger={<Button variant="outline">Open dialog</Button>}
>
<DialogBody>
<p className="text-muted-foreground">
The header stays visible while the body scrolls. Use the close button or
Escape when you are done.
</p>
</DialogBody>
</Dialog>
);
export const DialogFormDemo = () => {
const nameId = useId();
const [savedName, setSavedName] = useState("Vandor UI");
return (
<div className="flex flex-col items-center gap-3">
<Dialog
title="Edit project"
description="Submit to update the name in this local demo."
trigger={<Button variant="outline">Edit project</Button>}
>
{({ close }) => (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault();
setSavedName(
String(new FormData(event.currentTarget).get("name"))
);
close();
}}
>
<DialogBody className="flex flex-col gap-2">
<label htmlFor={nameId} className="font-medium">
Project name
</label>
<input
id={nameId}
name="name"
defaultValue={savedName}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</DialogBody>
<DialogFooter>
<Button type="button" variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit">Save name</Button>
</DialogFooter>
</form>
)}
</Dialog>
<output className="text-sm text-muted-foreground">
Current name: {savedName}
</output>
</div>
);
};
export const DialogExternalDemo = () => {
const control = useDialogControl();
const opener = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={opener} variant="outline" onClick={control.open}>
Open from outside
</Button>
<Dialog
control={control}
title="External control"
description="This opener lives outside the dialog composition."
contentProps={{ finalFocus: opener }}
>
<DialogBody>
<p className="text-muted-foreground">
The controller and child hooks share the accepted state. Focus
returns to the external opener after closing.
</p>
</DialogBody>
<DialogFooter>
<Button onClick={control.close}>Done</Button>
</DialogFooter>
</Dialog>
</>
);
};
export const DialogControlledDemo = () => {
const [open, setOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const [blocked, setBlocked] = useState(false);
return (
<Dialog
title="Unsaved changes"
description="Mark the draft as edited to try a dismissal guard."
trigger={<Button variant="outline">Try dismissal guard</Button>}
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && dirty) {
details.cancel();
setBlocked(true);
return;
}
setBlocked(false);
setOpen(nextOpen);
}}
>
<DialogBody className="flex flex-col gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={dirty}
onChange={(event) => setDirty(event.target.checked)}
/>
Draft has unsaved changes
</label>
{blocked && (
<p role="status" className="text-muted-foreground">
Dismissal was blocked. Uncheck the draft option or discard changes.
</p>
)}
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDirty(false);
setBlocked(false);
setOpen(false);
}}
>
Discard and close
</Button>
</DialogFooter>
</Dialog>
);
};
export const DialogNestedDemo = () => (
<Dialog
title="Project settings"
trigger={<Button variant="outline">Open settings</Button>}
>
<DialogBody>
<Dialog
title="Advanced settings"
description="Escape dismisses only the topmost dialog."
trigger={<Button variant="outline">Open advanced settings</Button>}
contentProps={{ size: "sm" }}
>
{({ close }) => (
<>
<DialogBody>Each dialog owns a separate controller.</DialogBody>
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
)}
</Dialog>
</DialogBody>
</Dialog>
);onOpenChange(nextOpen, details) includes the Base UI reason, such as escape-key,
outside-press, close-press, or imperative-action. Use details.cancel() to reject
opening or closing without desynchronizing controllers. Set disablePointerDismissal
to prevent outside presses; Escape remains available.
Sizes, persistence, and custom headers
contentProps.size defaults to "md": sm caps width at 24rem, md at 32rem,
lg at 42rem, and xl at 56rem. Every size respects the available viewport.
Use contentProps.className for application-specific layout overrides.
contentProps.showCloseButton defaults to true. Localize the X with
contentProps.closeButtonLabel (default "Close dialog"). When hiding it, provide another
visible, keyboard-accessible close action. initialFocus and finalFocus retain the Base UI Popup API.
Content normally unmounts after exit. contentProps.keepMounted preserves hidden content and form
state across closure; it does not reset drafts. Reset values explicitly when changing the edited record.
<Dialog
title="Project settings"
description="Update project details"
renderHeader={({ title, description }) => (
<>
{description}
<div className="flex items-center gap-2">
<span aria-hidden="true">✎</span>
{title}
</div>
</>
)}
>
<DialogBody>Your settings form</DialogBody>
</Dialog>renderHeader receives ready-to-render semantic elements, not raw props. Include the provided
title exactly once and description once when present (null when absent). The stationary
header wrapper and X spacing remain owned by Dialog.
Nested dialogs
"use client";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogFooter,
useDialogControl,
} from "@/components/ui/dialog";
export const DialogDemo = () => (
<Dialog
title="Project details"
description="A focused space for the task at hand."
trigger={<Button variant="outline">Open dialog</Button>}
>
<DialogBody>
<p className="text-muted-foreground">
The header stays visible while the body scrolls. Use the close button or
Escape when you are done.
</p>
</DialogBody>
</Dialog>
);
export const DialogFormDemo = () => {
const nameId = useId();
const [savedName, setSavedName] = useState("Vandor UI");
return (
<div className="flex flex-col items-center gap-3">
<Dialog
title="Edit project"
description="Submit to update the name in this local demo."
trigger={<Button variant="outline">Edit project</Button>}
>
{({ close }) => (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault();
setSavedName(
String(new FormData(event.currentTarget).get("name"))
);
close();
}}
>
<DialogBody className="flex flex-col gap-2">
<label htmlFor={nameId} className="font-medium">
Project name
</label>
<input
id={nameId}
name="name"
defaultValue={savedName}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</DialogBody>
<DialogFooter>
<Button type="button" variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit">Save name</Button>
</DialogFooter>
</form>
)}
</Dialog>
<output className="text-sm text-muted-foreground">
Current name: {savedName}
</output>
</div>
);
};
export const DialogExternalDemo = () => {
const control = useDialogControl();
const opener = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={opener} variant="outline" onClick={control.open}>
Open from outside
</Button>
<Dialog
control={control}
title="External control"
description="This opener lives outside the dialog composition."
contentProps={{ finalFocus: opener }}
>
<DialogBody>
<p className="text-muted-foreground">
The controller and child hooks share the accepted state. Focus
returns to the external opener after closing.
</p>
</DialogBody>
<DialogFooter>
<Button onClick={control.close}>Done</Button>
</DialogFooter>
</Dialog>
</>
);
};
export const DialogControlledDemo = () => {
const [open, setOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const [blocked, setBlocked] = useState(false);
return (
<Dialog
title="Unsaved changes"
description="Mark the draft as edited to try a dismissal guard."
trigger={<Button variant="outline">Try dismissal guard</Button>}
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && dirty) {
details.cancel();
setBlocked(true);
return;
}
setBlocked(false);
setOpen(nextOpen);
}}
>
<DialogBody className="flex flex-col gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={dirty}
onChange={(event) => setDirty(event.target.checked)}
/>
Draft has unsaved changes
</label>
{blocked && (
<p role="status" className="text-muted-foreground">
Dismissal was blocked. Uncheck the draft option or discard changes.
</p>
)}
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDirty(false);
setBlocked(false);
setOpen(false);
}}
>
Discard and close
</Button>
</DialogFooter>
</Dialog>
);
};
export const DialogNestedDemo = () => (
<Dialog
title="Project settings"
trigger={<Button variant="outline">Open settings</Button>}
>
<DialogBody>
<Dialog
title="Advanced settings"
description="Escape dismisses only the topmost dialog."
trigger={<Button variant="outline">Open advanced settings</Button>}
contentProps={{ size: "sm" }}
>
{({ close }) => (
<>
<DialogBody>Each dialog owns a separate controller.</DialogBody>
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
)}
</Dialog>
</DialogBody>
</Dialog>
);Each Dialog has its own state. useDialog() chooses the nearest Dialog through portals;
useDialog(outerControl) explicitly selects another. Escape dismisses the topmost dialog.
The parent subtly scales back while a child is open; nested backdrops follow Base UI's behavior.
Nested Dialog and Drawer portals share the outermost panel's theme scope outside its animated panels, so parent
scaling and body overflow do not clip or reposition a child dialog.
Behavior & Accessibility
- A meaningful title names the dialog; the optional description is associated automatically.
- Modal dialogs trap focus, lock page scrolling, and render a backdrop by default.
modal={false}permits outside interaction and omits the backdrop.modal="trap-focus"traps focus without locking page scroll or blocking outside pointer interaction; it also omits the backdrop.- Keyboard, Escape, focus targeting/return, dismissal cancellation, and nesting remain Base UI-owned.
- Opening fades, scales from 94%, and lifts the panel by 12px over 320ms; closing reverses the movement over 220ms. The backdrop fades over 200ms. Motion's native Web Animations integrate with Base UI's animation lifecycle, including rapid reopening and controlled updates.
- Reduced-motion preferences remove scaling, movement, and animated fades. Without Web Animations, state changes are immediate.
- Root props inherit Base UI Dialog Root,
except children and state ownership follow the Vandor contract above.
contentPropsinherits Popup props except children; Body and Footer forward native div props and refs.
Portable Storybook stories
Installing Dialog does not install stories or Storybook packages. Install Dialog first, then optionally:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/dialog-stories.json
This separate item installs only dialog.stories.tsx beside the component using your configured UI
alias/layout. It does not reinstall or overwrite your customized Dialog, or modify .storybook/.
Use an existing React Storybook with @storybook/react; keep all Storybook package versions aligned.
The stories are typechecked and composed against Storybook 10.6.1. No addons are required for rendering.
Use your existing Controls setup to edit meaningful Playground args.
Include **/*.stories.tsx in your story discovery pattern and import your Tailwind/shadcn global
stylesheet in the preview. Theme decorators belong to the consumer. No Motion provider is required.
Storybook UI is not verified here because this repository has no Storybook server.
Props
| Prop | Type / Description | Default |
|---|---|---|
| children | ReactNode | ((control: DialogControl) => ReactNode)Dialog: plain content or optional render function. Content is not automatically wrapped in DialogBody. | Not set |
| closeButtonLabel | stringcontentProps.closeButtonLabel: localized accessible name for the built-in X. | "Close dialog" |
| contentProps | DialogContentPropsPopup props, ref, className, render, initialFocus, finalFocus, size, and close/persistence options. No children. | Not set |
| control | DialogControlDialog: controller from useDialogControl. Exclusive with open, defaultOpen, and handle. | Not set |
| defaultOpen | booleanDialog: initial uncontrolled state. | false |
| description | ReactNodeDialog: optional supporting description. | Not set |
| disablePointerDismissal | booleanDialog: prevent outside-press dismissal; non-modal focus-out dismissal is also prevented. Escape still works. | false |
| example | Example presetPlayground only: plain children, render function, external control, or native form. | Not applicable |
| finalFocus | Base UI Popup finalFocuscontentProps.finalFocus: closing focus target. Set explicitly for external openers. | Base UI default |
| initialFocus | Base UI Popup initialFocuscontentProps.initialFocus: opening focus target, boolean, ref, or interaction-aware function. | Base UI default |
| keepMounted | booleancontentProps.keepMounted: retain hidden content and form state after closing. | false |
| longContent | booleanPlayground only: demonstrate a scrolling body with stationary actions. | Not applicable |
| modal | boolean | "trap-focus"Dialog: modal focus and scroll lock. Only true renders a backdrop; trap-focus traps focus without blocking outside pointer interaction. | true |
| onOpenChange | (open, details) => voidDialog: state request with Base UI reason and details.cancel() for unsaved-change guards. | Not set |
| onOpenChangeComplete | (open: boolean) => voidDialog: runs after native Motion animations complete. close() itself returns void. | Not set |
| open | booleanDialog: controlled state; update in onOpenChange to accept requests. | Not set |
| renderHeader | (elements: DialogHeaderElements) => ReactNodeDialog: customize header contents while retaining the provided semantic title and description exactly once. | Not set |
| showCloseButton | booleancontentProps.showCloseButton: show the built-in accessible X. | true |
| size | "sm" | "md" | "lg" | "xl"contentProps.size: responsive maximum panel width. | "md" |
| title | ReactNodeDialog: required meaningful accessible title. | Required |
| trigger | ReactElementDialog: associated button element. Custom components must forward refs and native props. | Not set |
The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.
Source
"use client";
// Adapted from shadcn/ui's Base Nova Dialog (MIT).
// https://ui.shadcn.com/docs/components/base/dialog
/*
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { Dialog as Primitive } from "@base-ui/react/dialog";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { XIcon } from "lucide-react";
import { useAnimateMini, useReducedMotion } from "motion/react";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentProps, ReactElement, ReactNode } from "react";
const controlKey = Symbol("dialog-control");
type DialogHandle = ReturnType<typeof Primitive.createHandle>;
interface DialogControl {
readonly isOpen: boolean;
open: () => void;
close: () => void;
readonly [controlKey]: {
handle: DialogHandle;
subscribe: (listener: () => void) => () => void;
notify: () => void;
};
}
const createControl = (handle: DialogHandle): DialogControl => {
const listeners = new Set<() => void>();
return {
[controlKey]: {
handle,
notify: () => {
for (const listener of listeners) {
listener();
}
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
},
close: () => handle.close(),
get isOpen() {
return handle.isOpen;
},
open: () => handle.open(null),
};
};
const getServerOpen = () => false;
const useSubscription = (control: DialogControl) => {
useSyncExternalStore(
control[controlKey].subscribe,
() => control.isOpen,
getServerOpen
);
return control;
};
const useDialogControl = (): DialogControl => {
const [control] = useState(() => createControl(Primitive.createHandle()));
return useSubscription(control);
};
const DialogContext = createContext<DialogControl | null>(null);
const useDialog = (control?: DialogControl): DialogControl => {
const context = useContext(DialogContext);
const selected = control ?? context;
if (!selected) {
throw new Error(
"useDialog must be used inside Dialog or receive a control"
);
}
return useSubscription(selected);
};
type DialogContentProps = Omit<Primitive.Popup.Props, "children"> & {
size?: "sm" | "md" | "lg" | "xl";
showCloseButton?: boolean;
closeButtonLabel?: string;
keepMounted?: boolean;
};
interface DialogHeaderElements {
title: ReactElement;
description: ReactElement | null;
}
type DialogStateProps = Omit<
Primitive.Root.Props,
"children" | "handle" | "open" | "defaultOpen"
> &
(
| {
control: DialogControl;
handle?: never;
open?: never;
defaultOpen?: never;
}
| {
control?: never;
handle?: Primitive.Root.Props["handle"];
open?: boolean;
defaultOpen?: boolean;
}
);
type DialogProps = DialogStateProps & {
title: ReactNode;
description?: ReactNode;
trigger?: ReactElement;
children?: ReactNode | ((control: DialogControl) => ReactNode);
contentProps?: DialogContentProps;
renderHeader?: (elements: DialogHeaderElements) => ReactNode;
};
// Native WAAPI animations remain visible to Base UI's public animation lifecycle.
// This covers controlled prop changes as well as trigger/imperative requests,
// without retaining invisible, focus-trapped panels or overriding completion events.
const AnimatedElement = ({
nativeProps,
state,
render,
backdrop = false,
}: {
nativeProps: ComponentProps<"div">;
state: Primitive.Popup.State | Primitive.Backdrop.State;
render?: Primitive.Popup.Props["render"];
backdrop?: boolean;
}) => {
const [scope, animate] = useAnimateMini<HTMLDivElement>();
const reduceMotion = useReducedMotion();
const nestedOpen = "nestedDialogOpen" in state && state.nestedDialogOpen;
const openScale = nestedOpen ? 0.96 : 1;
const targetScale = state.open ? openScale : 0.94;
useLayoutEffect(() => {
const element = scope.current;
if (!element) {
return;
}
const opacity = state.open ? 1 : 0;
const scale = backdrop || reduceMotion ? 1 : targetScale;
const translate = state.open || reduceMotion ? "0 0px" : "0 12px";
if (
nativeProps.hidden ||
reduceMotion ||
typeof element.animate !== "function"
) {
element.style.opacity = String(opacity);
if (!backdrop) {
element.style.scale = String(scale);
element.style.translate = translate;
}
return;
}
const panelDuration = state.open ? 0.32 : 0.22;
const animation = animate(
[element],
backdrop ? { opacity } : { opacity, scale, translate },
{
duration: backdrop ? 0.2 : panelDuration,
ease: state.open ? [0.22, 1, 0.36, 1] : [0.4, 0, 1, 1],
}
);
// WAAPI rejects finished when interrupted; rapid reopen/unmount is expected.
for (const nativeAnimation of element.getAnimations()) {
// Intentional interruption is not an application error.
// eslint-disable-next-line promise/prefer-await-to-then
nativeAnimation.finished.catch(() => {
// Interrupted animations are intentionally canceled during cleanup.
});
}
return () => animation.stop();
}, [
animate,
backdrop,
nativeProps.hidden,
reduceMotion,
scope,
state.open,
targetScale,
]);
return useRender({
props: { ...nativeProps },
ref: [nativeProps.ref ?? null, scope],
render,
state: { nested: false, nestedDialogOpen: false, ...state },
});
};
const DialogChildren = ({
children,
}: {
children: DialogProps["children"];
}) => {
const control = useDialog();
return <>{typeof children === "function" ? children(control) : children}</>;
};
const DialogToastHost = () => {
const control = useDialog();
return (
<span
data-vandor-modal-toast-host=""
data-toast-host-active={control.isOpen ? "true" : "false"}
ref={(node) => {
if (!node) return;
if (control.isOpen && !node.dataset.toastHostOrder)
node.dataset.toastHostOrder = String(performance.now());
if (!control.isOpen) delete node.dataset.toastHostOrder;
}}
style={{ display: "contents" }}
/>
);
};
const owners = new WeakMap<DialogControl, object>();
const Dialog = ({
control,
handle,
title,
description,
trigger,
children,
renderHeader,
contentProps = {},
onOpenChange,
modal = true,
...props
}: DialogProps) => {
const [localHandle] = useState(() => Primitive.createHandle());
const localControl = useMemo(
() => createControl(handle ?? localHandle),
[handle, localHandle]
);
const selected = control ?? localControl;
const [owner] = useState(() => ({}));
useEffect(() => {
if (owners.has(selected)) {
throw new Error(
"A DialogControl can only be bound to one Dialog at a time"
);
}
owners.set(selected, owner);
selected[controlKey].notify();
return () => {
if (owners.get(selected) === owner) {
owners.delete(selected);
}
queueMicrotask(selected[controlKey].notify);
};
}, [owner, selected]);
useEffect(() => {
selected[controlKey].notify();
}, [selected, props.open]);
if (
control &&
(props.open !== undefined ||
props.defaultOpen !== undefined ||
handle !== undefined)
) {
throw new Error(
"Dialog control cannot be combined with open, defaultOpen, or handle"
);
}
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
null
);
const setContainer = useCallback((node: HTMLSpanElement | null) => {
// Dialog and Drawer share the outermost theme host without an install-time
// dependency on each other. Never portal into a transformed parent panel.
let host: HTMLElement | null = node;
let ancestor = node?.parentElement?.closest<HTMLElement>(
"[data-vandor-overlay-host]"
);
while (ancestor) {
host = ancestor;
ancestor = ancestor.parentElement?.closest<HTMLElement>(
"[data-vandor-overlay-host]"
);
}
setPortalContainer(host);
}, []);
const {
size = "md",
showCloseButton = true,
closeButtonLabel = "Close dialog",
keepMounted = false,
className,
render,
...popupProps
} = contentProps;
const elements: DialogHeaderElements = {
description:
description === undefined || description === null ? null : (
<Primitive.Description
data-slot="dialog-description"
className="text-sm leading-relaxed text-muted-foreground"
>
{description}
</Primitive.Description>
),
title: (
<Primitive.Title
data-slot="dialog-title"
className="text-base font-medium leading-snug text-foreground"
>
{title}
</Primitive.Title>
),
};
return (
<DialogContext.Provider value={selected}>
<span
ref={setContainer}
data-vandor-overlay-host=""
style={{ display: "contents" }}
>
<Primitive.Root
{...props}
handle={selected[controlKey].handle}
modal={modal}
onOpenChange={(nextOpen, details) => {
try {
onOpenChange?.(nextOpen, details);
} finally {
queueMicrotask(selected[controlKey].notify);
}
}}
>
{trigger && (
<Primitive.Trigger
data-slot="dialog-trigger"
className="cursor-pointer disabled:cursor-not-allowed data-disabled:cursor-not-allowed"
render={trigger}
/>
)}
<Primitive.Portal
container={portalContainer}
keepMounted={keepMounted}
// The theme host uses display: contents. A static portal wrapper
// would become an extra flex/grid item and introduce caller gaps.
style={{ position: "absolute" }}
>
{modal === true && (
<Primitive.Backdrop
data-slot="dialog-overlay"
className="fixed inset-0 z-50 min-h-dvh bg-black/55 opacity-0 data-ending-style:pointer-events-none supports-[-webkit-touch-callout:none]:absolute"
render={(nativeProps, state) => (
<AnimatedElement
nativeProps={nativeProps}
state={state}
backdrop
/>
)}
/>
)}
<Primitive.Viewport
data-slot="dialog-viewport"
className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
>
<Primitive.Popup
data-slot="dialog-popup"
data-size={size}
data-close-button={showCloseButton ? "" : undefined}
className={cn(
"group/dialog-popup pointer-events-auto relative flex max-h-[calc(100dvh-2rem)] w-full min-h-0 translate-y-3 scale-[0.94] flex-col overflow-hidden rounded-xl border border-border bg-background text-sm text-foreground opacity-0 shadow-xl outline-none sm:max-h-[calc(100dvh-3rem)] data-nested-dialog-open:brightness-95",
{
"max-w-2xl": size === "lg",
"max-w-4xl": size === "xl",
"max-w-lg": size === "md",
"max-w-sm": size === "sm",
},
className
)}
{...popupProps}
render={(nativeProps, state) => (
<AnimatedElement
nativeProps={nativeProps}
state={state}
render={render}
/>
)}
>
<div
data-slot="dialog-header"
className="flex shrink-0 flex-col gap-2 p-4 pb-0 group-data-close-button/dialog-popup:pr-14"
>
{renderHeader ? (
renderHeader(elements)
) : (
<>
{elements.title}
{elements.description}
</>
)}
</div>
<DialogChildren>{children}</DialogChildren>
{showCloseButton && (
<Primitive.Close
data-slot="dialog-close-button"
aria-label={closeButtonLabel}
className="absolute top-3 right-3 inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"
>
<XIcon aria-hidden="true" className="size-4" />
</Primitive.Close>
)}
</Primitive.Popup>
</Primitive.Viewport>
{modal === true && <DialogToastHost />}
</Primitive.Portal>
</Primitive.Root>
</span>
</DialogContext.Provider>
);
};
const DialogBody = ({ className, ...props }: ComponentProps<"div">) => (
<div
data-slot="dialog-body"
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain p-4",
className
)}
{...props}
/>
);
const DialogFooter = ({ className, ...props }: ComponentProps<"div">) => (
<div
data-slot="dialog-footer"
className={cn(
"mt-auto flex shrink-0 flex-col-reverse gap-2 p-4 pt-0 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
export { Dialog, DialogBody, DialogFooter, useDialog, useDialogControl };
export type {
DialogControl,
DialogHeaderElements,
DialogProps,
DialogContentProps,
};