Dialog

A flexible overlay for showing messages, forms, or actions. Keeps focus on content while allowing clear, user-friendly interactions.

Share

A typical real-world dialog — rich #default body, an action-row #actions slot that mixes a left-side status with a right-side CTA, and Dropdowns nested inside the body for inline role changes.

vue
<script setup lang="ts">
// Share dialog — invite teammates by email and adjust per-row access.
// Rich `#default` body + a simple `#actions` slot CTA.
import { ref } from 'vue'
import { Avatar, Button, Dialog, Select, TextInput } from 'frappe-ui'

const open = ref(false)

type Role = 'view' | 'comment' | 'edit'
type Member = {
  name: string
  email: string
  initials: string
  image: string
  role: Role | 'owner'
  you?: boolean
}

const inviteEmail = ref('')

const avatarFor = (seed: string) => `https://i.pravatar.cc/80?u=${seed}`

const members = ref<Member[]>([
  {
    name: 'Faris Ansari',
    email: 'faris@example.com',
    initials: 'FA',
    image: avatarFor('faris@example.com'),
    role: 'owner',
    you: true,
  },
  {
    name: 'Ankush Menat',
    email: 'ankush@example.com',
    initials: 'AM',
    image: avatarFor('ankush@example.com'),
    role: 'edit',
  },
  {
    name: 'Shariq Ansari',
    email: 'shariq@example.com',
    initials: 'SA',
    image: avatarFor('shariq@example.com'),
    role: 'comment',
  },
])

const roleOptions = [
  { label: 'Can view', value: 'view' },
  { label: 'Can comment', value: 'comment' },
  { label: 'Can edit', value: 'edit' },
]

function invite() {
  const email = inviteEmail.value.trim()
  if (!email) return
  const local = email.split('@')[0]
  members.value.push({
    name: local.replace(/[._]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
    email,
    initials: local.slice(0, 2).toUpperCase(),
    image: avatarFor(email),
    role: 'edit',
  })
  inviteEmail.value = ''
}
</script>

<template>
  <Button icon-left="lucide-share-2" @click="open = true">Share…</Button>

  <Dialog v-model:open="open" size="lg" title="Share &quot;Q4 Roadmap&quot;">
    <div class="flex flex-col gap-5">
      <div class="flex gap-2">
        <TextInput
          v-model="inviteEmail"
          type="email"
          placeholder="Add people by email…"
          class="flex-1"
          @keydown.enter="invite"
        />
        <Button variant="solid" :disabled="!inviteEmail" @click="invite">
          Invite
        </Button>
      </div>

      <div class="flex flex-col gap-1">
        <p class="text-p-sm text-ink-gray-5">People with access</p>
        <div class="flex flex-col">
          <div
            v-for="member in members"
            :key="member.email"
            class="flex items-center gap-3 py-1.5"
          >
            <Avatar :image="member.image" :label="member.initials" size="md" />
            <div class="min-w-0 flex-1 leading-tight">
              <div class="text-base text-ink-gray-9">
                {{ member.name }}
                <span
                  v-if="member.you"
                  class="ml-0.5 text-p-xs text-ink-gray-5"
                >
                  (you)
                </span>
              </div>
              <div class="truncate text-p-sm text-ink-gray-5">
                {{ member.email }}
              </div>
            </div>

            <span
              v-if="member.role === 'owner'"
              class="text-p-sm text-ink-gray-5"
            >
              Owner
            </span>
            <Select
              v-else
              v-model="member.role"
              :options="roleOptions"
              class="w-36 shrink-0"
            />
          </div>
        </div>
      </div>
    </div>

    <template #actions="{ close }">
      <div class="flex justify-end">
        <Button variant="solid" @click="close">Done</Button>
      </div>
    </template>
  </Dialog>
</template>

Multi-step wizard

One <Dialog> instance, four steps. The body content swaps with internal state while the dialog only animates in once; the title, dismissible flag, and primary CTA all react to the current step.

vue
<script setup lang="ts">
// Multi-step wizard inside one `<Dialog>` instance. The body content,
// `title`, primary CTA, and `dismissible` flag all swap with the
// current step — the dialog itself only animates in once.
import { computed, reactive, ref } from 'vue'
import { Button, Dialog, FormControl, TextInput } from 'frappe-ui'

const open = ref(false)
const step = ref(0)
const steps = ['Project', 'Team', 'Done'] as const

const form = reactive({
  template: 'roadmap',
  name: '',
  emails: '',
})

const templateOptions = [
  { label: 'Roadmap', value: 'roadmap' },
  { label: 'Sprint board', value: 'sprint' },
  { label: 'Wiki', value: 'wiki' },
]

const title = computed(() => {
  if (step.value === 0) return 'Set up your workspace'
  if (step.value === 1) return 'Invite your team'
  return 'You\'re all set'
})

const inviteCount = computed(
  () => form.emails.split(/[\s,;]+/).filter(Boolean).length,
)

function reset() {
  step.value = 0
  form.template = 'roadmap'
  form.name = ''
  form.emails = ''
}
</script>

<template>
  <Button variant="solid" @click="(reset(), (open = true))">Get started</Button>

  <Dialog
    v-model:open="open"
    size="lg"
    :title="title"
    :dismissible="step === steps.length - 1"
  >
    <div class="mb-5 flex items-center gap-1.5">
      <span
        v-for="(_, i) in steps"
        :key="i"
        class="h-1 flex-1 rounded-full transition-colors"
        :class="i <= step ? 'bg-surface-gray-7' : 'bg-surface-gray-3'"
      />
    </div>

    <div v-if="step === 0" class="flex flex-col gap-4">
      <FormControl
        label="Project name"
        v-model="form.name"
        placeholder="e.g. Q4 Roadmap"
        autofocus
      />
      <FormControl
        type="select"
        label="Template"
        v-model="form.template"
        :options="templateOptions"
      />
    </div>

    <div v-else-if="step === 1" class="flex flex-col gap-3">
      <p class="text-base text-ink-gray-7">
        Add teammates so they can jump in too. Paste a comma- or
        space-separated list of emails.
      </p>
      <TextInput
        v-model="form.emails"
        placeholder="jane@example.com, marco@example.com"
      />
      <p class="text-p-sm text-ink-gray-5">
        {{ inviteCount }}
        {{ inviteCount === 1 ? 'invite' : 'invites' }} ready.
      </p>
    </div>

    <div v-else class="flex flex-col gap-2">
      <p class="text-base text-ink-gray-8">
        {{ form.name || 'Your workspace' }} is ready to go.
      </p>
      <p class="text-p-sm text-ink-gray-5">
        We'll set up the
        {{ templateOptions.find((t) => t.value === form.template)?.label }}
        template{{
          inviteCount > 0
            ? ` and send ${inviteCount} ${
                inviteCount === 1 ? 'invite' : 'invites'
              }.`
            : '.'
        }}
      </p>
    </div>

    <template #actions="{ close }">
      <div class="flex w-full justify-between gap-2">
        <Button v-if="step > 0 && step < steps.length - 1" @click="step -= 1">
          Back
        </Button>
        <span v-else />

        <div class="flex gap-2">
          <Button v-if="step < steps.length - 1" @click="close">Skip</Button>
          <Button
            v-if="step < steps.length - 1"
            variant="solid"
            :disabled="step === 0 && !form.name.trim()"
            @click="step += 1"
          >
            {{ step === steps.length - 2 ? 'Finish' : 'Continue' }}
          </Button>
          <Button v-else variant="solid" @click="close">Done</Button>
        </div>
      </div>
    </template>
  </Dialog>
</template>

Full canvas (bare)

bare: true strips all auto-chrome — no padded card, no auto-header, no auto-actions. Pair it with Dialog.Title for an accessible heading when you don't want visual chrome. A command palette is the canonical reason to reach for it.

vue
<script setup lang="ts">
// Command palette — the canonical reason to reach for `bare: true`.
// The visual is a floating search panel with input + list + hint footer,
// not a card with padding and a header, so we strip the auto-chrome and
// lay out the body directly. `Dialog.Title` keeps the heading accessible
// without rendering one.
import { computed, ref, watch } from 'vue'
import { Button, Dialog, KeyboardShortcut, TextInput } from 'frappe-ui'

const open = ref(false)
const query = ref('')
const activeIndex = ref(0)

type Command = {
  label: string
  hint: string
  icon: string
  combo?: string
}

const commands: Command[] = [
  {
    label: 'New project',
    hint: 'Create',
    icon: 'lucide-folder-plus',
    combo: 'Mod+N',
  },
  { label: 'New task', hint: 'Create', icon: 'lucide-plus', combo: 'Mod+T' },
  { label: 'Invite teammate', hint: 'People', icon: 'lucide-user-plus' },
  {
    label: 'Search documents',
    hint: 'Find',
    icon: 'lucide-file-search',
    combo: 'Mod+/',
  },
  {
    label: 'Open settings',
    hint: 'Workspace',
    icon: 'lucide-settings',
    combo: 'Mod+,',
  },
  { label: 'Switch theme', hint: 'Preferences', icon: 'lucide-moon-star' },
  { label: 'Log out', hint: 'Account', icon: 'lucide-log-out' },
]

const filtered = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return commands
  return commands.filter((c) => c.label.toLowerCase().includes(q))
})

watch([query, open], () => {
  activeIndex.value = 0
})

function run(cmd: Command) {
  // eslint-disable-next-line no-console
  console.log('run:', cmd.label)
  open.value = false
}

function move(delta: number) {
  if (!filtered.value.length) return
  activeIndex.value =
    (activeIndex.value + delta + filtered.value.length) % filtered.value.length
}

function onEnter() {
  const cmd = filtered.value[activeIndex.value]
  if (cmd) run(cmd)
}
</script>

<template>
  <Button @click="open = true">
    <template #prefix>
      <span class="lucide-search size-4" />
    </template>
    Open command palette
  </Button>

  <Dialog v-model:open="open" size="lg" bare>
    <Dialog.Title as-child>
      <h2 class="sr-only">Command palette</h2>
    </Dialog.Title>

    <div class="flex flex-col">
      <ul class="max-h-80 overflow-y-auto p-2">
        <TextInput
          class="mb-2"
          v-model="query"
          size="md"
          placeholder="Type a command…"
          autofocus
          @keydown.down.prevent="move(1)"
          @keydown.up.prevent="move(-1)"
          @keydown.enter.prevent="onEnter"
          @keydown.esc="open = false"
        >
          <template #prefix>
            <span class="lucide-search size-4 text-ink-gray-5" />
          </template>
        </TextInput>

        <li
          v-if="!filtered.length"
          class="px-3 py-8 text-center text-p-sm text-ink-gray-5"
        >
          No commands match "{{ query }}"
        </li>
        <li
          v-for="(cmd, i) in filtered"
          :key="cmd.label"
          class="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-base"
          :class="
            i === activeIndex
              ? 'bg-surface-gray-2 text-ink-gray-9'
              : 'text-ink-gray-7 hover:bg-surface-gray-2'
          "
          @mouseenter="activeIndex = i"
          @click="run(cmd)"
        >
          <span
            :class="[cmd.icon, 'size-4 text-ink-gray-6']"
            aria-hidden="true"
          />
          <span class="flex-1 truncate">{{ cmd.label }}</span>
          <span class="text-p-xs text-ink-gray-5">{{ cmd.hint }}</span>
          <KeyboardShortcut v-if="cmd.combo" :combo="cmd.combo" bg />
        </li>
      </ul>

      <div
        class="flex items-center gap-4 border-t border-outline-gray-modals px-4 py-2 text-p-xs text-ink-gray-5"
      >
        <span class="flex items-center gap-1.5">
          <KeyboardShortcut combo="ArrowUp" bg />
          <KeyboardShortcut combo="ArrowDown" bg />
          Navigate
        </span>
        <span class="flex items-center gap-1.5">
          <KeyboardShortcut combo="Enter" bg />
          Select
        </span>
        <span class="ml-auto flex items-center gap-1.5">
          <KeyboardShortcut combo="Escape" bg />
          Close
        </span>
      </div>
    </div>
  </Dialog>
</template>

Imperative API

The dialog.* helpers cover the confirm-family surface — confirm and the danger preset — without mounting a <Dialog> yourself. In real apps <Dialogs /> is mounted by FrappeUIProvider, the same component that hosts the toast viewport, so no extra setup is needed.

Pass an actions array to dialog.confirm (or dialog.danger) when a flow needs more than the default confirm + cancel pair. Each action accepts full Button props and its own awaited onClick; the clicked button shows a loading spinner while its handler is pending and every other button is disabled until it settles. Throwing from onClick surfaces inline via the shared error region.

dialog.danger is a one-line preset for irreversible actions. It forces theme: 'red', defaults the icon to a warning triangle, and defaults confirmLabel to 'Delete'. Everything confirm accepts (including actions[]) is forwarded through.

vue
<script setup lang="ts">
// Imperative `dialog.*` API — the full confirm-family surface in one place.
//
// Covers:
// - `dialog.confirm`     — basic + destructive (`theme: 'red'`) + async `onConfirm`.
// - `dialog.confirm({ actions })` — flows with more than the default
//   confirm + cancel pair (paste/replace/cancel, save/discard/cancel,
//   inline error from a throwing handler).
// - `dialog.danger`      — preset for irreversible actions; forces
//   `theme: 'red'` + warning icon, defaults `confirmLabel` to 'Delete',
//   and composes with `actions[]` for "delete with options" flows.
//
// In real apps `<Dialogs />` is mounted by `FrappeUIProvider`, the same
// component that hosts the toast viewport. No extra setup needed.
import { Button, dialog, Dialogs } from 'frappe-ui'

function destructiveConfirm() {
  dialog.confirm({
    title: 'Delete space',
    message:
      'This will permanently delete the space along with 12 discussions and 4 tasks.',
    confirmLabel: 'Delete',
    theme: 'red',
    onConfirm: async () => {
      await new Promise((r) => setTimeout(r, 900))
    },
  })
}

function minimalConfirm() {
  dialog.confirm({
    title: 'Import workbook',
    message: 'Are you sure you want to import this workbook?',
    onConfirm: async () => {
      await new Promise((r) => setTimeout(r, 500))
    },
  })
}

// Each action's button props (theme, variant, icon, …) flow through to the
// underlying `Button`. Each action's `onClick` is awaited independently —
// the clicked button shows a loading spinner while its handler is pending,
// and every other button is disabled until it settles.
function pastePage() {
  dialog.confirm({
    title: 'Paste page',
    message:
      'A page with this name already exists. Create a new copy or replace the current page?',
    actions: [
      { label: 'Cancel', variant: 'outline' },
      {
        label: 'Create copy',
        onClick: async () => {
          await new Promise((r) => setTimeout(r, 600))
        },
      },
      {
        label: 'Replace',
        theme: 'red',
        variant: 'solid',
        onClick: async () => {
          await new Promise((r) => setTimeout(r, 900))
        },
      },
    ],
  })
}

function navigateAway() {
  dialog.confirm({
    title: 'Unsaved changes',
    message: 'You have unsaved changes. Save before leaving?',
    actions: [
      { label: 'Cancel', variant: 'outline' },
      {
        label: 'Discard',
        theme: 'red',
        variant: 'subtle',
        onClick: () => {
          // eslint-disable-next-line no-console
          console.log('discarded')
        },
      },
      {
        label: 'Save and leave',
        variant: 'solid',
        onClick: async () => {
          await new Promise((r) => setTimeout(r, 700))
        },
      },
    ],
  })
}

// Throwing inside `onClick` is caught and rendered as an inline error,
// and every button re-enables so the user can retry or cancel.
function actionThatFails() {
  dialog.confirm({
    title: 'Send invitation',
    message: 'Send an invite to jane@example.com?',
    actions: [
      { label: 'Cancel', variant: 'outline' },
      {
        label: 'Send',
        variant: 'solid',
        onClick: async () => {
          await new Promise((r) => setTimeout(r, 500))
          throw new Error('Server rejected the invitation: rate limit hit.')
        },
      },
    ],
  })
}

function dangerDelete() {
  dialog.danger({
    title: 'Delete comment',
    message: 'Are you sure you want to delete this comment?',
    onConfirm: async () => {
      await new Promise((r) => setTimeout(r, 500))
    },
  })
}

function dangerCustomLabel() {
  // `confirmLabel` overrides the default 'Delete' when the action isn't a
  // delete in the literal sense.
  dialog.danger({
    title: 'Revoke invitation',
    message: 'This will revoke jane@example.com\'s pending invitation.',
    confirmLabel: 'Revoke',
    onConfirm: async () => {
      await new Promise((r) => setTimeout(r, 500))
    },
  })
}

function dangerWithActions() {
  // `actions[]` composes with `danger` — useful for "delete with options"
  // (e.g., delete only this occurrence vs the whole series).
  dialog.danger({
    title: 'Delete recurring task',
    message: 'How would you like to delete this task?',
    actions: [
      { label: 'Cancel', variant: 'outline' },
      {
        label: 'This occurrence',
        variant: 'subtle',
        theme: 'red',
        onClick: async () => {
          await new Promise((r) => setTimeout(r, 400))
        },
      },
      {
        label: 'Entire series',
        variant: 'solid',
        theme: 'red',
        onClick: async () => {
          await new Promise((r) => setTimeout(r, 400))
        },
      },
    ],
  })
}

// Close-behavior matrix for `onConfirm`:
//   • resolves        → auto-closes (close() runs after the await)
//   • throws / rejects → stays open, thrown message rendered inline
//   • calls close()    → closes immediately (the trailing auto-close is a no-op)
//
// Note: calling `ctx.setError(msg)` from inside `onConfirm` without throwing
// does NOT keep the dialog open — the auto-close still fires after the
// handler resolves. Use `throw` to stay open with an inline error.
function optimisticClose() {
  // Closes the dialog right away, then finishes the work in the background —
  // useful when the user has already committed and the UI shouldn't block.
  dialog.confirm({
    title: 'Send report',
    message: 'Send the weekly report to your team? The dialog will close immediately.',
    confirmLabel: 'Send',
    onConfirm: async ({ close }) => {
      close()
      await new Promise((r) => setTimeout(r, 1500))
      // eslint-disable-next-line no-console
      console.log('report sent in background')
    },
  })
}

// Stay open after an async call by throwing. The thrown message flows through
// `extractErrorMessage` and is rendered inline; the confirm button re-enables
// so the user can retry, edit, or cancel. This is the canonical pattern for
// server-side validation failures (e.g., username taken, quota exceeded).
function keepOpenAfterAsync() {
  dialog.confirm({
    title: 'Claim username',
    message: 'Claim the username "frappe-fan"? This pretends to call the server.',
    confirmLabel: 'Claim',
    onConfirm: async () => {
      await new Promise((r) => setTimeout(r, 700))
      // Server says no — throw to keep the dialog open with the reason.
      throw new Error('That username is already taken. Try a different one.')
    },
  })
}
</script>

<template>
  <div class="w-full flex flex-col gap-3">
    <div class="flex flex-wrap gap-2">
      <Button theme="red" variant="subtle" @click="destructiveConfirm">
        dialog.confirm (destructive)
      </Button>
      <Button @click="minimalConfirm">dialog.confirm (minimal)</Button>
    </div>

    <div class="flex flex-wrap gap-2">
      <Button @click="pastePage">3 actions (paste page)</Button>
      <Button @click="navigateAway">3 actions (save/discard/cancel)</Button>
      <Button @click="actionThatFails">Action that throws</Button>
    </div>

    <div class="flex flex-wrap gap-2">
      <Button theme="red" variant="subtle" @click="dangerDelete">
        dialog.danger (delete)
      </Button>
      <Button theme="red" variant="subtle" @click="dangerCustomLabel">
        dialog.danger (custom label)
      </Button>
      <Button theme="red" variant="subtle" @click="dangerWithActions">
        dialog.danger + actions[]
      </Button>
    </div>

    <div class="flex flex-wrap gap-2">
      <Button @click="optimisticClose">close() before await</Button>
      <Button @click="keepOpenAfterAsync">stay open after async (throw)</Button>
    </div>
  </div>
  <!-- In real apps <FrappeUIProvider> auto-mounts <Dialogs />. -->
  <Dialogs />
</template>

Prompt

dialog.prompt collects structured input through a fields[] array. Each field renders through FormControl, so any FormControl type works — including text, select, checkbox, and combobox. For combobox fields, allowCreate passes the typed query through as the value when nothing in the list matches — handy for category-style fields where users can add new entries inline.

Each field can also declare a validate function. It runs after the built-in required check, in parallel across all fields, and returns a non-empty string to mark the field invalid (shown inline below it) or null for valid. The second argument is a snapshot of every field's current value, so validators can reference siblings. The submit button keeps its loading state while async validators settle.

vue
<script setup lang="ts">
// `dialog.prompt(...)` — the full prompt surface in one place.
//
// Covers:
// - Basic prompt with `text` + `select` fields (and a `defaultValue`).
// - `type: 'combobox'` for searchable pickers, plus grouped options and
//   `allowCreate: true` (mirrors Combobox's `allowCustomValue` so the typed
//   query becomes the value when no option matches — useful for
//   category-style fields where users can add new entries inline).
// - Per-field `validate` — sync, async, and cross-field. Validators run
//   after the built-in `required` check, in parallel across all fields.
//   Returning a non-empty string marks the field invalid and renders that
//   string inline below it; returning `null`/`undefined`/`''` means valid.
//   The submit button keeps its loading state while async validators
//   settle, and errors clear the instant the user edits the field.
import { Button, dialog, Dialogs } from 'frappe-ui'

function newFolder() {
  dialog.prompt({
    title: 'New folder',
    fields: [
      {
        name: 'name',
        label: 'Folder name',
        type: 'text',
        required: true,
        placeholder: 'e.g. Q4 Plans',
      },
      {
        name: 'visibility',
        label: 'Visibility',
        type: 'select',
        defaultValue: 'private',
        options: [
          { label: 'Private', value: 'private' },
          { label: 'Team', value: 'team' },
          { label: 'Public', value: 'public' },
        ],
      },
    ],
    confirmLabel: 'Create',
    onConfirm: async ({ values }) => {
      await new Promise((r) => setTimeout(r, 500))
      // eslint-disable-next-line no-console
      console.log('created folder', values)
    },
  })
}

const spaces = [
  { label: 'Engineering', value: 'engineering' },
  { label: 'Design', value: 'design' },
  { label: 'Marketing', value: 'marketing' },
  { label: 'Product', value: 'product' },
  { label: 'Operations', value: 'operations' },
  { label: 'Sales', value: 'sales' },
]

function moveDiscussion() {
  dialog.prompt({
    title: 'Move discussion',
    message: 'Pick a space to move this discussion to.',
    fields: [
      {
        name: 'space',
        label: 'Space',
        type: 'combobox',
        options: spaces,
        placeholder: 'Search spaces…',
        required: true,
      },
    ],
    confirmLabel: 'Move',
    onConfirm: async ({ values }) => {
      await new Promise((r) => setTimeout(r, 500))
      // eslint-disable-next-line no-console
      console.log('moved to', values.space)
    },
  })
}

function newSpaceWithCategory() {
  // Grouped + `allowCreate` — typing a brand new category name accepts
  // the query as the value instead of forcing the user to use the list.
  dialog.prompt({
    title: 'New space',
    fields: [
      {
        name: 'name',
        label: 'Space name',
        type: 'text',
        required: true,
        placeholder: 'e.g. Q4 Roadmap',
      },
      {
        name: 'category',
        label: 'Category',
        type: 'combobox',
        allowCreate: true,
        placeholder: 'Pick or type a new category',
        options: [
          {
            group: 'Existing',
            options: [
              { label: 'Engineering', value: 'engineering' },
              { label: 'Design', value: 'design' },
              { label: 'Product', value: 'product' },
            ],
          },
        ],
      },
    ],
    confirmLabel: 'Create',
    onConfirm: async ({ values }) => {
      await new Promise((r) => setTimeout(r, 500))
      // eslint-disable-next-line no-console
      console.log('created', values)
    },
  })
}

const reservedUsernames = new Set(['admin', 'root', 'support', 'me'])

function createAccount() {
  dialog.prompt({
    title: 'Create account',
    fields: [
      {
        name: 'username',
        label: 'Username',
        required: true,
        placeholder: 'jane.doe',
        // Async validator — pretend we're hitting the server to check
        // uniqueness. The submit button keeps spinning until this resolves.
        validate: async (value: string) => {
          await new Promise((r) => setTimeout(r, 600))
          if (reservedUsernames.has(value.toLowerCase())) {
            return `"${value}" is reserved. Pick another.`
          }
          if (value.length < 3) return 'Use at least 3 characters.'
          return null
        },
      },
      {
        name: 'email',
        label: 'Email',
        required: true,
        placeholder: 'jane@example.com',
        // Sync validator — runs in the same parallel pass as `username`.
        validate: (value: string) => {
          if (!value.includes('@')) return 'That doesn\'t look like an email.'
          return null
        },
      },
      {
        name: 'password',
        label: 'Password',
        type: 'text',
        required: true,
        // Cross-field validation via the second argument.
        validate: (value: string, all) => {
          if (value.length < 8) return 'Use at least 8 characters.'
          if (typeof all.username === 'string' && value.includes(all.username)) {
            return 'Password must not contain your username.'
          }
          return null
        },
      },
    ],
    confirmLabel: 'Create account',
    onConfirm: async ({ values }) => {
      await new Promise((r) => setTimeout(r, 500))
      // eslint-disable-next-line no-console
      console.log('created', values)
    },
  })
}
</script>

<template>
  <div class="w-full flex flex-wrap gap-2">
    <Button @click="newFolder">Basic (text + select)</Button>
    <Button @click="moveDiscussion">Combobox (single field)</Button>
    <Button @click="newSpaceWithCategory">
      Combobox + grouped + allowCreate
    </Button>
    <Button @click="createAccount">
      Per-field validate (sync + async + cross-field)
    </Button>
  </div>
  <Dialogs />
</template>

API Reference

Show types
typescript
import type { ButtonProps } from '../Button'

export type DialogSize =
  | 'xs'
  | 'sm'
  | 'md'
  | 'lg'
  | 'xl'
  | '2xl'
  | '3xl'
  | '4xl'
  | '5xl'
  | '6xl'
  | '7xl'

export type DialogTheme = 'yellow' | 'blue' | 'red' | 'green'

export type DialogPosition = 'center' | 'top'

/** Legacy icon appearance — replaced by `theme`. */
export type DialogIconAppearance = 'warning' | 'info' | 'danger' | 'success'

export type DialogIcon = {
  name: string
  /** Color tone. Replaces deprecated `appearance`. */
  theme?: DialogTheme
  /** @deprecated Use `theme` instead. */
  appearance?: DialogIconAppearance
}

export type DialogActionContext = {
  close: () => void
}

export type DialogAction = ButtonProps & {
  onClick?: (context: DialogActionContext) => void | Promise<void>
}

/** A `DialogAction` augmented with a reactive `loading` flag, as surfaced to the `#actions` slot. */
export type DialogReactiveAction = DialogAction & { loading: boolean }

/** Legacy blob — accepted for back-compat, warns once. */
export type DialogOptions = {
  title?: string
  message?: string
  size?: DialogSize
  icon?: string | DialogIcon
  actions?: DialogAction[]
  position?: DialogPosition
  paddingTop?: string | number
}

export interface DialogProps {
  // Visibility — both supported, `open` is canonical.
  /** Controls whether the dialog is open (v-model:open). Canonical. */
  open?: boolean

  /** Controls whether the dialog is open (v-model). Also supported. */
  modelValue?: boolean

  // Content.
  /** Dialog title. Renders the auto-header. */
  title?: string

  /** Description text rendered below the title. */
  message?: string

  /** Icon shown next to the title in the auto-header. */
  icon?: string | DialogIcon

  // Layout.
  /** Max-width size of the dialog. Default `'lg'`. */
  size?: DialogSize

  /** Vertical placement. Default `'center'`. */
  position?: DialogPosition

  /** Overrides the position-based top padding (escape hatch). */
  paddingTop?: string | number

  // Actions.
  /** Footer action buttons. */
  actions?: DialogAction[]

  // Behavior.
  /** Allow outside-click and Escape to close. Default `true`. */
  dismissible?: boolean

  /** Show the top-right close button. Default `true`. */
  showCloseButton?: boolean

  /** Drop the chrome: no padded card, no auto-header, no auto-actions. Default `false`. */
  bare?: boolean

  // Deprecated.
  /** @deprecated Use `dismissible` (inverted) instead. */
  disableOutsideClickToClose?: boolean

  /** @deprecated Use flat top-level props instead. */
  options?: DialogOptions
}

export interface DialogEmits {
  /** Fired when the dialog open state changes via `v-model:open`. */
  'update:open': [value: boolean]

  /** Fired when the dialog open state changes via `v-model`. */
  'update:modelValue': [value: boolean]

  /** Fired when the dialog transitions to closed. */
  close: []

  /** Fired after the close animation finishes. */
  'after-leave': []
}

/** Scoped payload exposed to every Dialog slot. */
export interface DialogSlotProps {
  /** Closes the dialog. */
  close: () => void
}

/** Scoped payload for the `#actions` slot. */
export interface DialogActionsSlotProps extends DialogSlotProps {
  /** Reactive list of resolved actions (with `loading` state) for re-laying-out auto-rendered buttons. */
  actions: DialogReactiveAction[]
}

export interface DialogExposed {
  /** Closes the dialog. */
  close: () => void
}

export interface DialogSlots {
  /** Main content rendered inside the padded card. Exposes `{ close }`. */
  default?: (props: DialogSlotProps) => any

  /** Title area; accepts arbitrary content (extra buttons next to title, etc.). Exposes `{ close }`. */
  title?: (props: DialogSlotProps) => any

  /** Footer override; exposes `{ close, actions }`. */
  actions?: (props: DialogActionsSlotProps) => any

  /** @deprecated Use `#default` + `bare` prop. */
  body?: () => any

  /** @deprecated Use `#default`. */
  'body-main'?: () => any

  /** @deprecated Use `#title` for extras (no direct replacement). */
  'body-header'?: () => any

  /** @deprecated Use `#title`. */
  'body-title'?: () => any

  /** @deprecated Use `#default`. */
  'body-content'?: () => any
}
open
= undefined
boolean

Controls whether the dialog is open (v-model:open). Canonical.

modelValue
= undefined
boolean

Controls whether the dialog is open (v-model). Also supported.

title
string

Dialog title. Renders the auto-header.

message
string

Description text rendered below the title.

icon
string | DialogIcon

Icon shown next to the title in the auto-header.

size
= undefined
DialogSize

Max-width size of the dialog. Default `'lg'`.

position
= undefined
DialogPosition

Vertical placement. Default `'center'`.

paddingTop
string | number

Overrides the position-based top padding (escape hatch).

actions
DialogAction[]

Footer action buttons.

dismissible
= true
boolean

Allow outside-click and Escape to close. Default `true`.

showCloseButton
= true
boolean

Show the top-right close button. Default `true`.

bare
= false
boolean

Drop the chrome: no padded card, no auto-header, no auto-actions. Default `false`.

disableOutsideClickToClose
= undefined

Deprecated — Use `dismissible` (inverted) instead.

options

Deprecated — Use flat top-level props instead.

default
DialogSlotProps

Main content rendered inside the padded card. Exposes `{ close }`.

title
DialogSlotProps

Title area; accepts arbitrary content (extra buttons next to title, etc.). Exposes `{ close }`.

actions
DialogActionsSlotProps

Footer override; exposes `{ close, actions }`.

body

Deprecated — Use `#default` + `bare` prop.

body-main

Deprecated — Use `#default`.

body-header

Deprecated — Use `#title` for extras (no direct replacement).

body-title

Deprecated — Use `#title`.

body-content

Deprecated — Use `#default`.

update:modelValue
[value: boolean]

Fired when the model value changes.

update:open
[value: boolean]

Fired when the open state changes.

close
[]

Fired when the component closes.

after-leave
[]