For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Dialog

An accessible dialog with a production-friendly panel API and smooth Motion transitions.

Playground

Live preview
Open the dialog to preview it. Try long content and a form to see stationary actions. Reset also clears demo state. Generated code reproduces configuration, not transient form edits.
Customize Dialog
Customize
dialog-demo.tsx
"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:

Dependencies4 required packages
  • @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.0

Usage

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.

dialog-demo.tsx
"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

Current name: Vandor UI
dialog-demo.tsx
"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

dialog-demo.tsx
"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.

dialog-demo.tsx
"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

dialog-demo.tsx
"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. contentProps inherits 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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
childrenReactNode | ((control: DialogControl) => ReactNode)

Dialog: plain content or optional render function. Content is not automatically wrapped in DialogBody.

Not set
closeButtonLabelstring

contentProps.closeButtonLabel: localized accessible name for the built-in X.

"Close dialog"
contentPropsDialogContentProps

Popup props, ref, className, render, initialFocus, finalFocus, size, and close/persistence options. No children.

Not set
controlDialogControl

Dialog: controller from useDialogControl. Exclusive with open, defaultOpen, and handle.

Not set
defaultOpenboolean

Dialog: initial uncontrolled state.

false
descriptionReactNode

Dialog: optional supporting description.

Not set
disablePointerDismissalboolean

Dialog: prevent outside-press dismissal; non-modal focus-out dismissal is also prevented. Escape still works.

false
exampleExample preset

Playground only: plain children, render function, external control, or native form.

Not applicable
finalFocusBase UI Popup finalFocus

contentProps.finalFocus: closing focus target. Set explicitly for external openers.

Base UI default
initialFocusBase UI Popup initialFocus

contentProps.initialFocus: opening focus target, boolean, ref, or interaction-aware function.

Base UI default
keepMountedboolean

contentProps.keepMounted: retain hidden content and form state after closing.

false
longContentboolean

Playground only: demonstrate a scrolling body with stationary actions.

Not applicable
modalboolean | "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) => void

Dialog: state request with Base UI reason and details.cancel() for unsaved-change guards.

Not set
onOpenChangeComplete(open: boolean) => void

Dialog: runs after native Motion animations complete. close() itself returns void.

Not set
openboolean

Dialog: controlled state; update in onOpenChange to accept requests.

Not set
renderHeader(elements: DialogHeaderElements) => ReactNode

Dialog: customize header contents while retaining the provided semantic title and description exactly once.

Not set
showCloseButtonboolean

contentProps.showCloseButton: show the built-in accessible X.

true
size"sm" | "md" | "lg" | "xl"

contentProps.size: responsive maximum panel width.

"md"
titleReactNode

Dialog: required meaningful accessible title.

Required
triggerReactElement

Dialog: 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,
};
 

Credits

  • shadcn/ui: Adapted Base Nova Dialog source and semantic composition (MIT). Vandor adds the panel API, controllers, scroll layout, and Motion integration.
  • Base UI: Accessible dialog primitive, focus management, dismissal, and nested-dialog behavior.