Skip to main content

Dialog (Modal)

  • Component overview: Dialog for confirming actions, displaying information, or collecting user input. Also exported as Modal.
  • Current status: Supports declarative trigger-as-children usage, config-style content slots, and imperative Dialog.open() API.
  • Implementation note: ui/dialog wraps Radix Dialog primitive; design layer provides config-style API and imperative overlay support.
  • Default interaction: In the default usage, children is the trigger element.

Basic Usage​

Config mode: pass title, content, and use cancelText / okText to render the default cancel / confirm buttons. They auto-close the dialog on click.

Result
Loading...
Live Editor
render(
  <div style={{ padding: '40px 0' }}>
    <Dialog
      title="Confirm Action"
      content="Are you sure you want to proceed? This action cannot be undone."
      cancelText="Cancel"
      okText="Confirm"
      onOk={() => console.log('confirmed')}
    >
      <Button>Open Dialog</Button>
    </Dialog>
  </div>,
)

Size​

Use size to switch the dialog width. default is 464px; emphasized is 708px, for content-heavy or more prominent dialogs. The two are visually identical apart from width.

Result
Loading...
Live Editor
render(
  <div style={{ display: 'flex', gap: 16, padding: '40px 0' }}>
    <Dialog
      title="Default (464px)"
      content="The standard dialog width, suitable for most confirmations."
      okText="OK"
    >
      <Button variant="secondary">Default</Button>
    </Dialog>
    <Dialog
      size="emphasized"
      title="Emphasized (708px)"
      content="A wider dialog for content-heavy or more prominent scenarios."
      okText="OK"
    >
      <Button>Emphasized</Button>
    </Dialog>
  </div>,
)

Hide Close Button​

Set showClose={false} to remove the close button.

Result
Loading...
Live Editor
render(
  <div style={{ padding: '40px 0' }}>
    <Dialog
      title="Important Notice"
      showClose={false}
      content="Please read carefully before proceeding."
      okText="I Understand"
    >
      <Button variant="secondary">No Close Button</Button>
    </Dialog>
  </div>,
)

Destructive Confirm Button​

Use okButtonProps to customize the confirm button. Pass variant="destructive" for destructive actions such as deleting data.

Result
Loading...
Live Editor
render(
  <div style={{ padding: '40px 0' }}>
    <Dialog
      title="Delete Record"
      content="This action cannot be undone. The record will be permanently deleted."
      cancelText="Cancel"
      okText="Delete"
      okButtonProps={{ variant: 'destructive' }}
    >
      <Button variant="destructive-outline">Delete</Button>
    </Dialog>
  </div>,
)

Content Only​

When only content is passed (no title, no buttons), the content fills the dialog area without any padding wrapper — giving you full layout control.

Result
Loading...
Live Editor
const Demo = () => {
  const [open, setOpen] = useState(false)
  return (
    <div style={{ padding: '40px 0' }}>
      <Button variant="secondary" onClick={() => setOpen(true)}>Open Custom Dialog</Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        content={
          <div style={{ padding: '32px 24px', textAlign: 'center' }}>
            <div style={{ fontSize: 40, marginBottom: 12 }}>🎉</div>
            <div style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>All done!</div>
            <p style={{ color: '#888', marginBottom: 24 }}>Your changes have been saved successfully.</p>
            <Button onClick={() => setOpen(false)}>Close</Button>
          </div>
        }
      />
    </div>
  )
}
render(<Demo />)

For more complex layouts, pass footer directly. cancelText / okText are then ignored, and closing logic is up to the caller.

Result
Loading...
Live Editor
const Demo = () => {
  const [open, setOpen] = useState(false)
  return (
    <div style={{ padding: '40px 0' }}>
      <Button onClick={() => setOpen(true)}>Open Dialog</Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title="Custom Footer"
        content="With a custom footer, the close action is the caller's responsibility."
        footer={
          <div style={{ display: 'flex', gap: 8, justifyContent: 'space-between', flex: 1 }}>
            <Button variant="link-color">View Details</Button>
            <Button onClick={() => setOpen(false)}>Got it</Button>
          </div>
        }
      />
    </div>
  )
}
render(<Demo />)

When content is long, only the content area scrolls — title and footer remain fixed. Scroll within the content area below to verify.

Result
Loading...
Live Editor
const Demo = () => {
  const [open, setOpen] = useState(false)
  return (
    <div style={{ padding: '40px 0' }}>
      <Button onClick={() => setOpen(true)}>Open Long Content Dialog</Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title="Long Scrollable Content"
        content={
          <div>
            {Array.from({ length: 25 }, (_, i) => (
              <p key={i} style={{ marginBottom: 10 }}>
                Line {i + 1} — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
              </p>
            ))}
          </div>
        }
        cancelText="Cancel"
        okText="Confirm"
      />
    </div>
  )
}
render(<Demo />)

Overlay Inside Dialog (Wheel Scroll)​

Popovers and Selects inside a Dialog are portaled to document.body. Wheel scrolling in the dropdown list should work normally — the dialog's scroll lock no longer blocks it.

Result
Loading...
Live Editor
const Demo = () => {
  const [open, setOpen] = useState(false)
  return (
    <div style={{ padding: '40px 0' }}>
      <Button onClick={() => setOpen(true)}>Open Dialog with Select</Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title="Overlay Inside Dialog"
        content={
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <p style={{ margin: 0 }}>The Select below portals its dropdown to document.body. Use the trackpad / wheel to scroll the option list — it should scroll normally.</p>
            <Select
              options={Array.from({ length: 40 }, (_, i) => ({
                value: String(i),
                label: `Option ${i + 1}`,
              }))}
              placeholder="Select an option"
            />
          </div>
        }
        cancelText="Cancel"
        okText="OK"
      />
    </div>
  )
}
render(<Demo />)

Controlled Mode​

Result
Loading...
Live Editor
const Demo = () => {
  const [open, setOpen] = useState(false)
  return (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '40px 0' }}>
      <Button onClick={() => setOpen(true)}>Open Controlled</Button>
      <span style={{ fontSize: 14, color: '#999' }}>open: {String(open)}</span>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title="Controlled Dialog"
        content="This dialog is controlled externally."
        okText="Close"
      />
    </div>
  )
}
render(<Demo />)

Imperative API​

Dialog.open() provides imperative access. Suitable for async callbacks, keyboard shortcuts, or table actions where a JSX trigger is not available. Requires DesignProvider or OverlayHost mounted at the application root.

import { Dialog } from '@plaud/design'

Dialog.open({
title: 'Delete item',
content: <div>This action cannot be undone. Please confirm.</div>,
})

Close from Inside​

content and footer support render functions. The function receives a controller with close, update, etc.

Dialog.open({
title: 'Confirm',
content: ({ close }) => (
<div>
<p>Are you sure?</p>
<Button onClick={close}>Close</Button>
</div>
),
})

Update an Open Dialog​

open() returns a controller to update configuration after opening:

const controller = Dialog.open({
title: 'Uploading...',
content: <div>Please wait.</div>,
})

setTimeout(() => {
controller.update({
title: 'Upload complete',
content: <div>The file is ready.</div>,
})
}, 1000)

Controller​

interface ImperativeOverlayController<TOptions> {
id: string
close: () => void
update: (updater: Partial<TOptions> | ((prev: TOptions) => TOptions)) => void
afterClosed: Promise<void>
}

Accessible Description​

Radix wires the dialog's aria-describedby to a description element. Wrap the body copy that should be read out on open with DialogDescription and place it inside content — no custom id or manual aria-describedby needed. Use asChild when the description spans several paragraphs (a <p> cannot nest <p>).

Result
Loading...
Live Editor
const Demo = () => {
  const [open, setOpen] = useState(false)
  return (
    <div style={{ padding: '40px 0' }}>
      <Button variant="secondary" onClick={() => setOpen(true)}>Open Described Dialog</Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title="Saving in the background"
        okText="Got it"
        content={
          <DialogDescription asChild>
            <div>
              <p>The task keeps running even if you leave this page.</p>
              <p>You will be notified when it finishes.</p>
            </div>
          </DialogDescription>
        }
      />
    </div>
  )
}
render(<Demo />)

DialogDescription​

Thin wrapper over Radix Dialog.Description (same style as the internal DialogTitle): renders a <p> with Body typography (--Font-Size-Body / --Line-Height-Body, Labels/Secondary) and registers itself as the dialog's aria-describedby target. Accepts all Radix Description props, including asChild and className.

Props​

Dialog (Config Mode)​

PropTypeDefaultDescription
titleReactNode—Header title
contentReactNode—Body content
size'default' | 'emphasized''default'Dialog width. default = 464px, emphasized = 708px
cancelTextReactNode—Cancel button label. Auto-closes dialog on click. Ignored when footer is provided
okTextReactNode—OK / confirm button label. Auto-closes dialog on click. Ignored when footer is provided
onCancel() => void—Cancel button callback (dialog closes automatically)
onOk() => void—OK button callback (dialog closes automatically)
cancelButtonPropsOmit<ButtonProps, 'children'>—Extra props for the cancel button, e.g. variant. Ignored when footer is provided
okButtonPropsOmit<ButtonProps, 'children'>—Extra props for the OK button, e.g. variant="destructive". Ignored when footer is provided
footerReactNode—Custom footer. When provided, cancelText / okText are ignored and closing is the caller's responsibility
childrenReactNode—Trigger element (non-controlled mode)
openboolean—Controlled open state
onOpenChange(open: boolean) => void—Open state change callback
showClosebooleantrueShow close button
closeOnOverlayClickbooleanfalseClose the dialog when the overlay is clicked. Only a pointerdown that lands on the overlay element itself triggers this — other elements floating above the dialog (e.g. a global widget) never do. Non-modal dialogs (modal={false}) render no overlay, so there this prop falls back to its legacy meaning — when enabled, any outside pointerdown closes the dialog
destroyOnClosebooleantrueUnmount content when closed
contentClassNamestring—Custom content area class
contentPropsDialogContentProps—Extended props passed to the dialog content layer, such as className, data attributes, and interaction callbacks

Usage Constraints​

  • Prefer config-style usage. In the default mode, children acts as the trigger and content is configured through props.
  • Imperative API requires DesignProvider or OverlayHost at the application root.