Propel

Dialog

A modal overlay for focused tasks and multi-step flows.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogInfo,
  DialogInfoIcon,
  DialogMain,
  DialogProgression,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { ChevronLeftOutline, InfoOutline, CloseOutline } from "@makeplane/propel/icons";

/**
 * Default composition: full Modal anatomy — progression, heading, body slot, info helper, and
 * action row.
 */
export default function BasicDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Create cycle"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogProgression>
              <IconButton
                variant="ghost"
                size="xs"
                aria-label="Back"
                icon={<Icon icon={ChevronLeftOutline} />}
              />
              Step 1 of 3
            </DialogProgression>
            <DialogHeading>
              <DialogTitle>Create cycle</DialogTitle>
              <DialogDescription>
                Name the cycle and set its date range. You can add work items in the next step.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
          <DialogBody>
            <div className="min-h-32 rounded-lg bg-surface-2" />
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <DialogInfo>
            <DialogInfoIcon>
              <Icon icon={InfoOutline} />
            </DialogInfoIcon>
            Drafts save automatically
          </DialogInfo>
          <Button
            stretch="auto"
            variant="ghost"
            size="md"
            render={<DialogClose />}
            label="Cancel"
          />
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Save draft"
          />
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Continue"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Installation

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogCloseGroup,
  DialogToolbar,
  DialogPanes,
  DialogAside,
  DialogMain,
  DialogHeader,
  DialogProgression,
  DialogHeading,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogActions,
  DialogInfo,
  DialogInfoIcon,
  DialogClose,
} from "@makeplane/propel/components/dialog";

Usage

Compose DialogCloseGroup (corner dismiss), optional DialogToolbar (top chrome with a bottom border), DialogMain (progression / heading / optional body slot), and DialogActions (optional DialogInfo + buttons) inside DialogContent. Put DialogDescription in DialogHeading under the title — not in DialogBody. When DialogBody content can overflow, pass tabIndex={0} so the scroll region is keyboard-reachable.

import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogInfo,
  DialogInfoIcon,
  DialogMain,
  DialogProgression,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { ChevronLeftOutline, InfoOutline, CloseOutline } from "@makeplane/propel/icons";

/**
 * Default composition: full Modal anatomy — progression, heading, body slot, info helper, and
 * action row.
 */
export default function BasicDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Create cycle"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogProgression>
              <IconButton
                variant="ghost"
                size="xs"
                aria-label="Back"
                icon={<Icon icon={ChevronLeftOutline} />}
              />
              Step 1 of 3
            </DialogProgression>
            <DialogHeading>
              <DialogTitle>Create cycle</DialogTitle>
              <DialogDescription>
                Name the cycle and set its date range. You can add work items in the next step.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
          <DialogBody>
            <div className="min-h-32 rounded-lg bg-surface-2" />
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <DialogInfo>
            <DialogInfoIcon>
              <Icon icon={InfoOutline} />
            </DialogInfoIcon>
            Drafts save automatically
          </DialogInfo>
          <Button
            stretch="auto"
            variant="ghost"
            size="md"
            render={<DialogClose />}
            label="Cancel"
          />
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Save draft"
          />
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Continue"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Anatomy

Part Role
Dialog Root context (open state, modality)
DialogTrigger / DialogClose Behavior grafts onto Button / IconButton via render
DialogContent Portal + backdrop + viewport + popup (size defaults to md)
DialogCloseGroup Absolute top-end slot for the dismiss IconButton
DialogToolbar Optional top chrome; bottom border flush to the popup
DialogPanes / DialogAside Optional two-pane row: 280 px start rail beside DialogMain
DialogMain Padded top section; shrinks so the footer stays visible
DialogProgression Optional back + step label row
DialogHeader / DialogHeading Progression + title / description stack (6 px gap)
DialogTitle / DialogDescription Accessible name + supporting copy — keep both in Heading
DialogBody Scrollable slot only (forms, lists) — not description
DialogActions Footer with top border, pinned while the body scrolls
DialogInfo / DialogInfoIcon Optional footer helper

Examples

Sizes

DialogContent’s size sets the dialog width: xs 480 px, sm 576 px, md 672 px (default), lg 800 px, xl 1000 px, or full, which fills the viewport minus an 80 px margin on every edge and pins the height. Every dialog hangs 80 px from the top of the viewport, so a short one opens where a tall one does, and grows with its content until it is 80 px from the bottom; past that the body scrolls. The gutter of DialogMain, DialogActions, and DialogAside follows the size (16 px, 48 px at full). DialogToolbar matches on the start edge; its end edge is 40 px so content clears the corner dismiss (48 px at full). density="compact" keeps 16 px at every size, including full. height="fixed" pins 700 px so split chrome does not jump when the active pane changes.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  type DialogPopupSize,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

const SIZES: { size: DialogPopupSize; label: string; width: string }[] = [
  { size: "xs", label: "Extra small", width: "480 px — a short confirmation with one field." },
  { size: "sm", label: "Small", width: "576 px — a single-column form." },
  { size: "md", label: "Medium", width: "672 px — the default; most create and edit forms." },
  { size: "lg", label: "Large", width: "800 px — two-column forms and previews." },
  { size: "xl", label: "Extra large", width: "1000 px — dense tables and editors." },
  { size: "full", label: "Full", width: "Viewport minus 80 px on every edge; height pinned." },
];

export default function SizesDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      {SIZES.map(({ size, label, width }) => (
        <Dialog key={size}>
          <Button
            stretch="auto"
            variant="secondary"
            size="lg"
            render={<DialogTrigger />}
            label={label}
          />
          <DialogContent size={size}>
            <DialogCloseGroup>
              <IconButton
                variant="ghost"
                size="xs"
                aria-label="Close"
                render={<DialogClose />}
                icon={<Icon icon={CloseOutline} />}
              />
            </DialogCloseGroup>
            <DialogMain>
              <DialogHeader>
                <DialogHeading>
                  <DialogTitle>{label} dialog</DialogTitle>
                  <DialogDescription>{width}</DialogDescription>
                </DialogHeading>
              </DialogHeader>
              <DialogBody>
                <div className="min-h-24 rounded-lg bg-surface-2" />
              </DialogBody>
            </DialogMain>
            <DialogActions>
              <Button
                stretch="auto"
                variant="primary"
                size="md"
                render={<DialogClose />}
                label="Done"
              />
            </DialogActions>
          </DialogContent>
        </Dialog>
      ))}
    </div>
  );
}

Scrollable body

Tall content scrolls inside DialogBody so the header and footer stay pinned. Pass tabIndex={0} on the body when it can overflow.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

const RELEASE_NOTES = Array.from({ length: 12 }, (_, index) => ({
  version: `2.${12 - index}.0`,
  notes:
    "Improved cycle burndown accuracy, faster work-item search indexing, and new keyboard shortcuts for triaging intake items.",
}));

export default function ScrollableBodyDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="What's new"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>What&apos;s new</DialogTitle>
              <DialogDescription>
                Everything that shipped in the last twelve releases.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
          {/* A scrollable region must be keyboard-reachable (axe scrollable-region-focusable). */}
          <DialogBody tabIndex={0}>
            <div className="flex flex-col gap-4">
              {RELEASE_NOTES.map((release) => (
                <div key={release.version} className="flex flex-col gap-1">
                  <h3 className="text-body-sm-medium text-primary">Version {release.version}</h3>
                  <p className="text-body-sm-regular text-secondary">{release.notes}</p>
                </div>
              ))}
            </div>
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Got it"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Fullscreen wizard

size="full" plus DialogPanes: a DialogAside step rail on the start side, the content pane in DialogMain, and a DialogInfo note in the footer beside Back / Next. The panes work at any size; the step list is your own content.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogAside,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogInfo,
  DialogInfoIcon,
  DialogMain,
  DialogPanes,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { InfoOutline, CloseOutline } from "@makeplane/propel/icons";
import * as React from "react";

const STEPS = ["States", "Work item types", "Workflows", "Labels", "Templates"];

export default function FullscreenWizardDemo() {
  const [step, setStep] = React.useState(0);
  const last = step === STEPS.length - 1;
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Review migration"
      />
      <DialogContent size="full">
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogPanes>
          <DialogAside>
            <nav aria-label="Migration steps" className="flex flex-col items-start gap-3">
              {STEPS.map((label, index) => (
                <button
                  key={label}
                  type="button"
                  aria-current={index === step ? "step" : undefined}
                  onClick={() => setStep(index)}
                  className="text-start text-body-sm-medium text-tertiary hover:text-primary aria-[current]:text-primary"
                >
                  {label}
                </button>
              ))}
            </nav>
          </DialogAside>
          <DialogMain>
            <DialogHeader>
              <DialogHeading>
                <DialogTitle>{STEPS[step]}</DialogTitle>
                <DialogDescription>
                  Everything below moves from the project level to the workspace level.
                </DialogDescription>
              </DialogHeading>
            </DialogHeader>
            <DialogBody tabIndex={0}>
              <div className="min-h-96 rounded-lg bg-surface-2" />
            </DialogBody>
          </DialogMain>
        </DialogPanes>
        <DialogActions>
          <DialogInfo>
            <DialogInfoIcon>
              <Icon icon={InfoOutline} />
            </DialogInfoIcon>
            This is review only. No changes are made during migration.
          </DialogInfo>
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            disabled={step === 0}
            onClick={() => setStep((current) => current - 1)}
            label="Back"
          />
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={last ? <DialogClose /> : undefined}
            onClick={last ? undefined : () => setStep((current) => current + 1)}
            label={last ? "Review and enable" : "Next"}
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Discard guard

Pass discard on the root with when true while the form has unsaved edits. Escape, an outside press, and the corner close then raise an alert asking to discard; “Keep editing” returns to the draft, “Discard” closes and calls onDiscard. Closing from your own handler (Post, Save) is never intercepted.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { TextAreaField } from "@makeplane/propel/components/text-area-field";
import { CloseOutline } from "@makeplane/propel/icons";
import * as React from "react";

export default function DiscardGuardDemo() {
  const [open, setOpen] = React.useState(false);
  const [comment, setComment] = React.useState("");
  return (
    <Dialog
      open={open}
      onOpenChange={setOpen}
      discard={{
        when: comment.trim() !== "",
        onDiscard: () => setComment(""),
        title: "Discard comment?",
        description: "Your comment hasn't been posted yet. Discarding it can't be undone.",
      }}
    >
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Add comment"
      />
      <DialogContent size="md">
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Add comment</DialogTitle>
            </DialogHeading>
          </DialogHeader>
          <DialogBody>
            <TextAreaField
              size="lg"
              resize="none"
              label="Comment"
              placeholder="Leave a comment…"
              value={comment}
              onChange={(event) => setComment(event.target.value)}
            />
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            onClick={() => {
              // Closing from a handler is not a dismissal, so the guard does not intercept it.
              setOpen(false);
              setComment("");
            }}
            label="Post comment"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Confirmation

Title, description, and cancel / confirm actions — a short task without a body slot.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

export default function ConfirmationDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Delete project"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Delete project</DialogTitle>
              <DialogDescription>
                This permanently removes the project and all of its work items. This action
                can&apos;t be undone.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Cancel"
          />
          <Button
            stretch="auto"
            variant="danger"
            size="md"
            render={<DialogClose />}
            label="Delete"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Non-dismissable

With disablePointerDismissal on the root, a backdrop click no longer closes the dialog — it dismisses only via an explicit DialogClose or Escape.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

export default function NonDismissableDemo() {
  return (
    <Dialog disablePointerDismissal>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Open locked dialog"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Unsaved changes</DialogTitle>
              <DialogDescription>
                Choose an action — clicking outside won&apos;t dismiss.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Discard"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Nested dialogs

A DialogTrigger inside a dialog stacks a second dialog above it. The child paints its own backdrop over the parent. Escape unwinds one level at a time.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

export default function NestedDialogsDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Invite teammates"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Invite teammates</DialogTitle>
              <DialogDescription>
                Invited members join with the Member role. Need something more specific? Create a
                custom role first.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
        </DialogMain>
        <DialogActions>
          <Dialog>
            <Button
              stretch="auto"
              variant="secondary"
              size="md"
              render={<DialogTrigger />}
              label="Create custom role"
            />
            <DialogContent>
              <DialogCloseGroup>
                <IconButton
                  variant="ghost"
                  size="xs"
                  aria-label="Close"
                  render={<DialogClose />}
                  icon={<Icon icon={CloseOutline} />}
                />
              </DialogCloseGroup>
              <DialogMain>
                <DialogHeader>
                  <DialogHeading>
                    <DialogTitle>Create custom role</DialogTitle>
                    <DialogDescription>
                      Custom roles scope what invited members can see and edit.
                    </DialogDescription>
                  </DialogHeading>
                </DialogHeader>
              </DialogMain>
              <DialogActions>
                <Button
                  stretch="auto"
                  variant="secondary"
                  size="md"
                  render={<DialogClose />}
                  label="Back"
                />
              </DialogActions>
            </DialogContent>
          </Dialog>
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Send invites"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}

Open from a menu

Choosing a menu item closes the menu, so the dialog lives outside it and is opened imperatively via controlled open / onOpenChange.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import { LinkOutline, EditOutline, DeleteOutline, CloseOutline } from "@makeplane/propel/icons";
import * as React from "react";

export default function OpenFromMenuDemo() {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Menu>
        <Button
          stretch="auto"
          variant="secondary"
          size="lg"
          render={<MenuTrigger />}
          label="Project options"
        />
        <MenuContent>
          <MenuItem icon={<Icon icon={EditOutline} tint="secondary" />} label="Edit" />
          <MenuItem icon={<Icon icon={LinkOutline} tint="secondary" />} label="Copy link" />
          <MenuSeparator />
          <MenuItem
            variant="danger"
            icon={<Icon icon={DeleteOutline} />}
            onClick={() => setOpen(true)}
            label="Delete project…"
          />
        </MenuContent>
      </Menu>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogCloseGroup>
            <IconButton
              variant="ghost"
              size="xs"
              aria-label="Close"
              render={<DialogClose />}
              icon={<Icon icon={CloseOutline} />}
            />
          </DialogCloseGroup>
          <DialogMain>
            <DialogHeader>
              <DialogHeading>
                <DialogTitle>Delete project</DialogTitle>
                <DialogDescription>
                  This permanently removes the project and all of its work items. This action
                  can&apos;t be undone.
                </DialogDescription>
              </DialogHeading>
            </DialogHeader>
          </DialogMain>
          <DialogActions>
            <Button
              stretch="auto"
              variant="secondary"
              size="md"
              render={<DialogClose />}
              label="Cancel"
            />
            <Button
              stretch="auto"
              variant="danger"
              size="md"
              render={<DialogClose />}
              label="Delete"
            />
          </DialogActions>
        </DialogContent>
      </Dialog>
    </>
  );
}

Detached triggers

createDialogHandle() links triggers to a Dialog defined elsewhere in the tree, so several launch points share one dialog without lifting state.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  createDialogHandle,
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

// A handle links triggers that live far from the dialog they open, so several
// launch points can share one dialog without hoisting controlled state.
const shortcutsDialog = createDialogHandle();

export default function DetachedTriggersDemo() {
  return (
    <div className="flex items-center gap-2">
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger handle={shortcutsDialog} />}
        label="Keyboard shortcuts"
      />
      <Button
        stretch="auto"
        variant="ghost"
        size="lg"
        render={<DialogTrigger handle={shortcutsDialog} />}
        label="Help"
      />
      <Dialog handle={shortcutsDialog}>
        <DialogContent>
          <DialogCloseGroup>
            <IconButton
              variant="ghost"
              size="xs"
              aria-label="Close"
              render={<DialogClose />}
              icon={<Icon icon={CloseOutline} />}
            />
          </DialogCloseGroup>
          <DialogMain>
            <DialogHeader>
              <DialogHeading>
                <DialogTitle>Keyboard shortcuts</DialogTitle>
                <DialogDescription>
                  Press <kbd>C</kbd> to create a work item, <kbd>/</kbd> to search, and <kbd>?</kbd>{" "}
                  to reopen this list from anywhere.
                </DialogDescription>
              </DialogHeading>
            </DialogHeader>
          </DialogMain>
          <DialogActions>
            <Button
              stretch="auto"
              variant="primary"
              size="md"
              render={<DialogClose />}
              label="Done"
            />
          </DialogActions>
        </DialogContent>
      </Dialog>
    </div>
  );
}

API Reference

Dialog

The dialog Root — Base UI's context/state provider (renders no element of its own). Modal by default; pass disablePointerDismissal to keep it open on outside clicks. A behavior-only role, so it lives in components (rules 1a, 2); the styled parts live in elements/dialog and are grafted onto Base UI behavior here.

PropTypeDefaultDescription
childrenReactNode | PayloadChildRenderFunction<unknown>—The content of the dialog. This can be a regular React node or a render function that receives the payload of the active trigger.
discardDialogDiscard—Ask before a dismissal while when is true: Escape, an outside press, and any DialogClose raise an AlertDialog instead of closing. "Discard" then closes the dialog and calls onDiscard; "Keep editing" returns to it. Closing from your own handler (Save, Post) is not a dismissal and is never intercepted: set controlled open to false, or call actionsRef.current.close(). Either of those while the alert is showing closes both.

DialogTrigger

The behavior that opens the dialog. Use as the render target of a Button so the styled primitive's look wins via render-composition: ``tsx <Button variant="secondary" size="lg" label="Open dialog" render={<DialogTrigger />} />; Base UI manages aria-haspopup/aria-expanded and focus restoration when the dialog closes. Maps 1:1 to Dialog.Trigger`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogTriggerState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogContent

Convenience that composes the dialog overlay boilerplate — portal, shared Backdrop, the top-anchored DialogViewport, and the DialogPopup — so a consumer only writes the trigger and the popup body. Each Base UI behavior part grafts onto its styled element via render. Nested dialogs keep their own dim: Base UI omits a nested Backdrop unless forceRender is set, which would leave a child modal sitting on the parent with no extra overlay. Closed force-rendered backdrops set pointer-events: none so they cannot steal clicks from the parent while unmounting. DialogPanesContext is reset here so a nested dialog does not inherit pane fill from a parent DialogPanes through the portal.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl" | "full"mdDialog width: xs 480 px, sm 576 px, md 672 px, lg 800 px, xl 1000 px, or full, which fills the viewport minus an 80 px margin on every edge and pins the height as well. The ready-made DialogMain, DialogToolbar, DialogActions, and DialogAside read it for their gutter (16 px, 48 px at full).
height"auto" | "fixed"autoDialog height. auto (default) sizes to content up to the viewport ceiling. fixed pins 700 px so split chrome does not resize when the active pane changes. Prefer size="full" when the popup should fill the viewport.
density"compact" | "comfortable"comfortableCompactness of Main / Toolbar / Actions / Aside gutters. size already picks the popup width and the default gutter; density is the second axis when that coupling is wrong. comfortable (the default) is 16 px, 48 px at full. compact keeps 16 px at every size, including full.

DialogToolbar

Top chrome of the dialog popup, sibling of DialogMain. Its start gutter follows the size chosen on DialogContent (16 px, 48 px at full); the end gutter is 40 px so an end-aligned control clears the corner dismiss. density="compact" keeps 16 px start at full. Bottom border is flush to the popup, matching DialogActions.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogTitle

The accessible title for the dialog: Base UI's Dialog.Title behavior (links to the popup via aria-labelledby) grafted onto the dialog-owned title chrome (text-h5-medium).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogTitleState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogDescription

The supporting description for the dialog: Base UI's Dialog.Description behavior (links to the popup via aria-describedby) grafted onto the dialog-owned description chrome (text-body-xs tertiary).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogDescriptionState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogClose

The behavior that closes the dialog when activated. Use as the render target of a Button or IconButton so the styled primitive's look wins via render-composition. Corner dismiss belongs in DialogCloseGroup: ``tsx <DialogCloseGroup> <IconButton variant="ghost" size="xs" aria-label={closeLabel} icon={<Icon icon={X} />} render={<DialogClose />} /> </DialogCloseGroup>; Maps 1:1 to Dialog.Close`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogCloseState>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogPanes

The side-by-side row of a two-pane dialog: DialogAside on the start side, DialogMain filling the rest. Marks its descendants so the ready-made DialogMain fills the row (rule 7).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogAside

A 280 px start-side column inside DialogPanes, beside DialogMain. Stretches to the panes' height so its end seam is the full popup. Holds navigation for the dialog's content — a step list, a section list. Its gutter follows the size chosen on DialogContent (16 px, 48 px at full); density="compact" keeps 16 px at full. Pass tabIndex={0} when its content can overflow.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogMain

The padded top section of the dialog popup: optional DialogProgression, DialogHeader / DialogHeading, and the scrollable DialogBody. Its gutter follows the size chosen on DialogContent (16 px, 48 px at full); density="compact" keeps 16 px at full. Inside DialogPanes it fills the width beside DialogAside.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogCloseGroup

Positions the dismiss control at the popup's top inline-end corner. Hold an IconButton (size xs, variant ghost) with render={<DialogClose />} so the button chrome wins and Base UI supplies close behavior.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogProgression

Optional step row at the top of DialogHeader: compose a back IconButton (size xs, variant ghost) and the step label as children. Caption-md tertiary chrome is baked in. Laid out in normal flow (not Figma's absolute hang into the gutter) — the glyph sits on the Main padding edge; consumers supply the back handler.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogHeader

The top block inside DialogMain: stacks an optional DialogProgression over a DialogHeading (title + optional description). Dismiss lives in DialogCloseGroup on the popup, not here — this part carries extra end padding so progression/title clear the absolute close without widening Main/Body gutters. Full-width header controls (e.g. a command-palette search field) skip this part and sit directly in DialogMain so they keep equal side gutters.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogHeading

The heading block: stacks the DialogTitle over an optional DialogDescription with a tight gap. Sit inside DialogHeader (optionally under DialogProgression). Keep supporting copy here — not in DialogBody — so title/description use the 6 px heading rhythm.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogBody

The scrollable main slot inside DialogMain. Grows to fill available space and scrolls vertically when content exceeds the popup's max-height. overflow-x-clip on this node keeps trackpad deltas from panning sideways; an inner width clipper keeps focus scrolling from shifting the body under sticky header/footer. A 2 px padded bleed keeps field ring-2 focus chrome from being masked by the vertical clip. Pass form fields, lists, or other slot content — keep DialogDescription in DialogHeading. When the body can overflow, pass tabIndex={0} so the scroll region is keyboard-reachable (axe scrollable-region-focusable).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogActions

The footer of the dialog popup: an optional DialogInfo at the inline-start, then action buttons. Its gutter follows the size chosen on DialogContent (16 px, 48 × 18 px at full); density="compact" keeps 16 px at full. Stays visible while DialogBody scrolls.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogInfo

Optional footer helper at the inline-start of DialogActions. Compose a DialogInfoIcon and the helper label as children. Flexes to push action buttons to the inline-end.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

DialogInfoIcon

Decorative 14 px icon slot inside DialogInfo. Bakes aria-hidden and sizes its child via the shared node-slot. Pass a Propel glyph (or <Icon icon={…} />) as children.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.