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>
}

Props

Dialog (Config Mode)

PropTypeDefaultDescription
titleReactNodeHeader title
contentReactNodeBody content
size'default' | 'emphasized''default'Dialog width. default = 464px, emphasized = 708px
cancelTextReactNodeCancel button label. Auto-closes dialog on click. Ignored when footer is provided
okTextReactNodeOK / confirm button label. Auto-closes dialog on click. Ignored when footer is provided
onCancel() => voidCancel button callback (dialog closes automatically)
onOk() => voidOK 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
footerReactNodeCustom footer. When provided, cancelText / okText are ignored and closing is the caller's responsibility
childrenReactNodeTrigger element (non-controlled mode)
openbooleanControlled open state
onOpenChange(open: boolean) => voidOpen state change callback
showClosebooleantrueShow close button
destroyOnClosebooleantrueUnmount content when closed
contentClassNamestringCustom content area class
contentPropsDialogContentPropsExtended 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.