๐ŸŸข Dialog

  • Supports modal and non-modal modes.
  • Focus is automatically trapped when modal.
  • Can be controlled or uncontrolled.
  • Manages screen reader announcements with Title andDescription components.
  • Esc closes the component automatically.

Basic

NDialog is used to create dialog windows inside the web browser. It can be used for messages, prompts, logins, and much more.

PropTypeDefaultDescription
titleStringnullThe title of the dialog.
descriptionStringnullThe description of the dialog.
showCloseBooleantrueShow the close button.
All the props available in the Radix Vue Dialog are also available via its subcomponents' prop names, e.g., _dialog-content, _dialog-title, etc. refer to Dialog Props for more details.
<template>
  <NDialog
    title="Edit Profile"
    description="Edit your profile information"
  >
    <template #trigger>
      <NButton btn="solid-gray">
        Open Dialog
      </NButton>
    </template>

    <div class="grid gap-4 py-4">
      <div class="grid gap-2">
        <div class="grid grid-cols-3 items-center gap-4">
          <NLabel for="name">
            Name
          </NLabel>
          <NInput
            id="name"
            :una="{
              inputWrapper: 'col-span-2',
            }"
          />
        </div>
        <div class="grid grid-cols-3 items-center gap-4">
          <NLabel for="email">
            Email
          </NLabel>
          <NInput
            id="email"
            type="email"
            :una="{
              inputWrapper: 'col-span-2',
            }"
          />
        </div>
        <div class="grid grid-cols-3 items-center gap-4">
          <NLabel for="password">
            Current Password
          </NLabel>
          <NInput
            id="password"
            type="password"
            :una="{
              inputWrapper: 'col-span-2',
            }"
          />
        </div>
      </div>
    </div>

    <template #footer>
      <NButton
        btn="solid"
        label="Save Changes"
      />
    </template>
  </NDialog>
</template>

Scrollable Content

PropTypeDescription
scrollableBooleanIf true, the dialog will have a scrollable body.
<template>
  <NDialog
    title="Modal Title"
    description="Here is modal with overlay scroll"
    scrollable
  >
    <template #trigger>
      <NButton btn="solid-gray">
        Open Dialog
      </NButton>
    </template>

    <div class="grid h-300 gap-4 py-4">
      <p>
        This is some placeholder content to show the scrolling behavior for modals. Instead of repeating the text in the modal, we use an inline style to set a minimum height, thereby extending the length of the overall modal and demonstrating the overflow scrolling. When content becomes longer than the height of the viewport, scrolling will move the modal as needed.
      </p>
    </div>
  </NDialog>
</template>

Prevent Closing

PropTypeDescription
preventCloseBooleanIf true, the dialog will not close on overlay click or escape key press.
<script setup lang="ts">
</script>

<template>
  <NDialog
    title="Unclosable Dialog"
    :_dialogClose="{
      btn: 'solid-gray',
    }"
    prevent-close
  >
    <template #trigger>
      <NButton btn="solid-gray">
        Open Dialog
      </NButton>
    </template>

    <div>
      This dialog will stay open and not close until the close button is pressed.
    </div>
  </NDialog>
</template>

Slots

SlotPropertiesDescription
triggeropenThe trigger button used to open the dialog
default-The body of the dialog
content-The full content of the dialog. It covers the header, body, footer, and default slots.
header-Contains the title and description slots
footer-The footer.
title-The title displayed in the dialog.
description-The description displayed below the title.

Custom Close Button

<script setup lang="ts">
import { useClipboard } from '@vueuse/core'

const source = ref('https://unaui.com/getting-started/installation')
const { copy, copied } = useClipboard({ source })
</script>

<template>
  <NDialog
    title="Share Link"
    description="Anyone with this link will be able to view this project."
    :_dialog-footer="{
      class: 'sm:justify-start',
    }"
  >
    <template #trigger>
      <NButton btn="solid-gray">
        Open Dialog
      </NButton>
    </template>

    <form
      class="flex gap-2"
      @submit.prevent="copy(source)"
    >
      <NInput
        v-model="source"
        leading="i-radix-icons-link-2"
        :una="{
          inputWrapper: 'w-full',
        }"
        read-only
      />

      <NButton
        icon
        square
        type="submit"
        :btn="copied ? 'outline' : 'solid'"
        :label="!copied ? 'i-radix-icons-copy' : 'i-radix-icons-check'"
      />
    </form>

    <template #footer>
      <NDialogClose>
        <NButton
          btn="soft-gray"
        >
          Close
        </NButton>
      </NDialogClose>
    </template>
  </NDialog>
</template>

Scrollable Body

<template>
  <NDialog
    title="Edit profile"
    description="Make changes to your profile here. Click save when you're done."
    :_dialog-content="{
      class: 'sm:max-w-lg grid-rows-[auto_minmax(0,1fr)_auto] p-0 max-h-90dvh',
    }"
    :_dialog-footer="{
      class: 'p-6 pt-0',
    }"
    :_dialog-header="{
      class: 'p-6 pb-0',
    }"
  >
    <template #trigger>
      <NButton btn="solid-gray">
        Open Dialog
      </NButton>
    </template>

    <div class="grid gap-4 overflow-y-auto px-6 py-4">
      <div class="h-[300dvh] flex flex-col justify-between">
        <p>
          This is some placeholder content to show the scrolling behavior for modals. We use repeated line breaks to demonstrate how content can exceed minimum inner height, thereby showing inner scrolling. When content becomes longer than the predefined max-height of modal, content will be cropped and scrollable within the modal.
        </p>

        <p>This content should appear at the bottom after you scroll.</p>
      </div>
    </div>

    <template #footer>
      <NButton
        btn="solid"
        label="Save Changes"
      />
    </template>
  </NDialog>
</template>

Login Prompt

A login dialog with state which closes itself after a successful login.

<script setup lang="ts">
const username = ref('')
const password = ref('')

const loginStatus = ref<'inactive' | 'pending' | 'success' | 'failed'>('inactive')
const loginMessage = ref('')

const open = ref(false)

watch(open, () => {
  // Dialog refs stay loaded. They need to be reset when opened.
  username.value = ''
  password.value = ''
  loginStatus.value = 'inactive'
  loginMessage.value = ''
})

async function sleep(seconds: number) {
  await new Promise<void>((resolve) => {
    setTimeout(() => {
      resolve()
    }, seconds * 1000)
  })
}

async function submitLogin() {
  loginStatus.value = 'pending'
  loginMessage.value = ''
  await sleep(1)

  if (username.value === 'username' && password.value === 'password') {
    loginStatus.value = 'success'
    loginMessage.value = 'Login success!'
    await sleep(3)
    open.value = false
  }
  else {
    loginStatus.value = 'failed'
    loginMessage.value = 'Invalid Username/Password'
  }
}
</script>

<template>
  <NDialog
    v-model:open="open"
    title="Login"
    description="Please log in with your username and password"
  >
    <template #trigger>
      <NButton btn="solid-gray" label="Login" leading="i-mdi:login" />
    </template>
    <template #default>
      <form
        id="login-form" class="grid space-y-4"
        @submit.prevent="submitLogin()"
      >
        <NFormGroup label="Username">
          <NInput
            v-model="username"
            placeholder=""
            required
            :disabled="loginStatus === 'pending' || loginStatus === 'success'"
          />
        </NFormGroup>
        <NFormGroup label="Password">
          <NInput
            v-model="password"
            type="password"
            required
            :disabled="loginStatus === 'pending' || loginStatus === 'success'"
          />
          <div class="text-end">
            <NPopover>
              <template #trigger>
                <NButton btn="link-muted" class="text-xs text-muted">
                  Forgot password?
                </NButton>
              </template>
              <div>
                <p>Your username is <span class="font-mono">username</span></p>
                <p>Your password is <span class="font-mono">password</span></p>
              </div>
            </NPopover>
          </div>
        </NFormGroup>
      </form>
    </template>
    <template #footer>
      <span
        v-if="loginMessage"
        class="animate-in animate-duration-1s fade-in slide-in-from-right-4"
        :class="{
          'text-green': loginStatus === 'success',
          'text-red': loginStatus === 'failed',
        }"
      >
        {{ loginMessage }}
      </span>
      <NButton
        type="submit"
        form="login-form"
        btn="solid"
        label="Login"
        leading="i-mdi:login"
        :loading="loginStatus === 'pending'"
        :disabled="loginStatus === 'success'"
      />
    </template>
  </NDialog>
</template>

Blurred Background

A dialog whose overlay blurs the background content.

<template>
  <NDialog
    title="Blurring background"
    :una="{ dialogOverlay: 'backdrop-blur' }"
  >
    <template #trigger>
      <NButton btn="solid-gray" label="Open Dialog" />
    </template>

    <div>
      The background of this dialog is blurred. This can be used to protect sensitive information or hide certain types of images.
    </div>
  </NDialog>
</template>

Props

import type {
  DialogCloseProps,
  DialogContentProps,
  DialogDescriptionProps,
  DialogRootProps,
  DialogTitleProps,
} from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'

export interface NDialogProps extends DialogRootProps {
  title?: string
  description?: string
  scrollable?: boolean
  showClose?: boolean
  preventClose?: boolean

  // sub-components
  _dialogTitle?: NDialogTitleProps
  _dialogDescription?: NDialogDescriptionProps
  _dialogHeader?: NDialogHeaderProps
  _dialogFooter?: NDialogFooterProps
  _dialogOverlay?: NDialogOverlayProps
  _dialogContent?: NDialogContentProps
  _dialogClose?: NDialogCloseProps

  una?: NDialogUnaProps
}

interface BaseExtensions {
  class?: HTMLAttributes['class']
}

export interface NDialogTitleProps extends DialogTitleProps, BaseExtensions {
  una?: NDialogUnaProps['dialogTitle']
}

export interface NDialogDescriptionProps extends DialogDescriptionProps, BaseExtensions {
  una?: NDialogUnaProps['dialogDescription']
}

export interface NDialogContentProps extends DialogContentProps, BaseExtensions, Pick<NDialogProps, '_dialogOverlay' | '_dialogClose' | 'preventClose' | 'showClose'> {
  onCloseAutoFocus?: (event: any) => void
  onEscapeKeyDown?: (event: KeyboardEvent) => void
  onInteractOutside?: (event: any) => void
  onOpenAutoFocus?: (event: any) => void
  onPointerDownOutside?: (event: any) => void

  una?: NDialogUnaProps['dialogContent']
}

export interface NDialogOverlayProps extends BaseExtensions, Pick<NDialogProps, 'scrollable'> {
  una?: NDialogUnaProps['dialogOverlay']
}

export interface NDialogHeaderProps extends BaseExtensions {
  una?: NDialogUnaProps['dialogHeader']
}

export interface NDialogFooterProps extends BaseExtensions {
  una?: NDialogUnaProps['dialogFooter']
}

export interface NDialogCloseProps extends DialogCloseProps, NButtonProps {
}

export interface NDialogUnaProps {
  dialogTitle?: HTMLAttributes['class']
  dialogDescription?: HTMLAttributes['class']
  dialogHeader?: HTMLAttributes['class']
  dialogFooter?: HTMLAttributes['class']
  dialogOverlay?: HTMLAttributes['class']
  dialogContent?: HTMLAttributes['class']
}

Component

<script setup lang="ts">
import type { NDialogProps } from '../../../types'
import { reactivePick } from '@vueuse/core'
import { DialogRoot, type DialogRootEmits, DialogTrigger, useForwardPropsEmits } from 'radix-vue'
import DialogContent from './DialogContent.vue'
import DialogDescription from './DialogDescription.vue'
import DialogFooter from './DialogFooter.vue'
import DialogHeader from './DialogHeader.vue'
import DialogScrollContent from './DialogScrollContent.vue'
import DialogTitle from './DialogTitle.vue'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(defineProps<NDialogProps>(), {
  showClose: true,
})
const emits = defineEmits<DialogRootEmits>()

const rootProps = reactivePick(props, [
  'open',
  'defaultOpen',
  'modal',
])

const rootPropsEmits = useForwardPropsEmits(rootProps, emits)
</script>

<template>
  <DialogRoot v-slot="{ open }" v-bind="rootPropsEmits">
    <DialogTrigger as-child>
      <slot name="trigger" :open />
    </DialogTrigger>

    <component
      :is="!scrollable ? DialogContent : DialogScrollContent"
      v-bind="_dialogContent"
      :_dialog-overlay
      :_dialog-close
      :una
      :scrollable
      :show-close
      :prevent-close
      :aria-describedby="props.description ? 'dialog-description' : undefined"
    >
      <slot name="content">
        <DialogHeader
          v-if="props.title || props.description || $slots.header"
          v-bind="_dialogHeader"
          :una
        >
          <slot name="header">
            <DialogTitle
              v-if="props.title"
              v-bind="_dialogTitle"
              :una
            >
              <slot name="title">
                {{ title }}
              </slot>
            </DialogTitle>

            <DialogDescription
              v-if="props.description"
              v-bind="_dialogDescription"
              :una
            >
              <slot name="description">
                {{ description }}
              </slot>
            </DialogDescription>
          </slot>
        </DialogHeader>

        <!-- body -->
        <slot />

        <DialogFooter
          v-if="$slots.footer"
          v-bind="_dialogFooter"
          :una
        >
          <slot name="footer" />
        </DialogFooter>
      </slot>
    </component>
  </DialogRoot>
</template>
<script setup lang="ts">
import type { NDialogTitleProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'radix-vue'
import { cn } from '../../../utils'

const props = defineProps<NDialogTitleProps>()

const delegatedProps = reactiveOmit(props, 'class')

const forwardedProps = useForwardProps(delegatedProps)
</script>

<template>
  <DialogTitle
    v-bind="forwardedProps"
    :class="cn('dialog-title', props.class)"
  >
    <slot />
  </DialogTitle>
</template>
<script setup lang="ts">
import type { NDialogDescriptionProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'radix-vue'
import { cn } from '../../../utils'

const props = defineProps<NDialogDescriptionProps>()

const delegatedProps = reactiveOmit(props, 'class')

const forwardedProps = useForwardProps(delegatedProps)
</script>

<template>
  <DialogDescription
    v-bind="forwardedProps"
    :class="cn(
      'dialog-description',
      props.una?.dialogDescription,
      props.class,
    )"
  >
    <slot />
  </DialogDescription>
</template>
<script setup lang="ts">
import type { NDialogHeaderProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NDialogHeaderProps>()
</script>

<template>
  <div
    :class="cn(
      'dialog-header',
      props.una?.dialogHeader,
      props.class,
    )"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { NDialogFooterProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NDialogFooterProps>()
</script>

<template>
  <div
    :class="cn(
      'dialog-footer',
      props.una?.dialogFooter,
      props.class,
    )"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { NDialogCloseProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { DialogClose } from 'radix-vue'
import { cn } from '../../../utils'
import Button from '../Button.vue'

const props = withDefaults(defineProps<NDialogCloseProps>(), {
  btn: 'text-muted',
  square: '2em',
  icon: true,
  label: 'i-close',
})

const delegatedProps = reactiveOmit(props, 'class')
</script>

<template>
  <DialogClose
    as-child
  >
    <slot>
      <Button
        tabindex="-1"
        v-bind="delegatedProps"
        :class="cn('dialog-close', props.class)"
      >
        <template v-for="(_, name) in $slots" #[name]="slotData">
          <slot :name="name" v-bind="slotData" />
        </template>
      </Button>
    </slot>
  </DialogClose>
</template>
<script lang="ts" setup>
import type { NDialogOverlayProps } from '../../../types'
import { DialogOverlay } from 'radix-vue'
import { cn } from '../../../utils'

const props = defineProps<NDialogOverlayProps>()
</script>

<template>
  <DialogOverlay
    :class="cn(
      !props.scrollable ? 'dialog-overlay' : 'dialog-scroll-overlay',
      props.una?.dialogOverlay,
      props.class,
    )"
  >
    <slot />
  </DialogOverlay>
</template>
<script setup lang="ts">
import type { NDialogContentProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import {
  DialogContent,
  type DialogContentEmits,
  DialogPortal,
  useForwardPropsEmits,
} from 'radix-vue'
import { cn } from '../../../utils'
import DialogClose from './DialogClose.vue'
import DialogOverlay from './DialogOverlay.vue'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(defineProps<NDialogContentProps>(), {
  showClose: true,
})
const emits = defineEmits<DialogContentEmits>()

const delegatedProps = reactiveOmit(props, ['class', '_dialogOverlay', '_dialogClose'])

const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

<template>
  <DialogPortal>
    <DialogOverlay
      v-bind="_dialogOverlay"
      :una
    />

    <DialogContent
      v-bind="{ ...forwarded, ...$attrs }"
      :class="cn(
        'dialog-content',
        props.una?.dialogContent,
        props.class,
      )"
      @interact-outside="event => {
        if (preventClose) return event.preventDefault()
      }"
      @escape-key-down="event => {
        if (preventClose) return event.preventDefault()
      }"
    >
      <slot />

      <DialogClose
        v-if="showClose"
        v-bind="_dialogClose"
      />
    </DialogContent>
  </DialogPortal>
</template>
<script setup lang="ts">
import type { NDialogContentProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import {
  DialogContent,
  type DialogContentEmits,
  DialogPortal,
  useForwardPropsEmits,
} from 'radix-vue'
import { cn } from '../../../utils'
import DialogClose from './DialogClose.vue'
import DialogOverlay from './DialogOverlay.vue'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(defineProps<NDialogContentProps>(), {
  showClose: true,
})
const emits = defineEmits<DialogContentEmits>()

const delegatedProps = reactiveOmit(props, ['class', '_dialogOverlay', '_dialogClose'])

const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

<template>
  <DialogPortal>
    <DialogOverlay
      v-bind="_dialogOverlay"
      :una
      scrollable
    >
      <DialogContent
        v-bind="{ ...forwarded, ...$attrs }"
        :class="cn(
          'dialog-scroll-content',
          props.una?.dialogContent,
          props.class,
        )"
        @pointer-down-outside="(event) => {
          const originalEvent = event.detail.originalEvent;
          const target = originalEvent.target as HTMLElement;
          if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
            event.preventDefault();
          }
        }"
      >
        <slot />

        <DialogClose
          v-if="showClose"
          v-bind="_dialogClose"
        />
      </DialogContent>
    </DialogOverlay>
  </DialogPortal>
</template>