Dialog
A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
Examples
Basic
Prop | Default | Type | Description |
---|---|---|---|
title | - | string | The title of the dialog. |
description | - | string | The description of the dialog. |
showClose | true | boolean | Show the close button. |
defaultOpen | false | boolean | The open state of the dialog when it is initially rendered. Use when you do not need to control its open state. |
modal | true | boolean | The modality of the dialog When set to true, interaction with outside elements will be disabled and only dialog content will be visible to screen readers. |
open | - | boolean | The controlled open state of the dialog. Can be binded as v-model:open . |
Read more in Radix Dialog Root API
Preview
Code
<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
Prop | Default | Type | Description |
---|---|---|---|
scrollable | false | boolean | If true, the dialog will have a scrollable body. |
Preview
Code
<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
Prop | Default | Type | Description |
---|---|---|---|
preventClose | - | boolean | If true, the dialog will not close on overlay click or escape key press. |
Preview
Code
<script setup lang="ts">
</script>
<template>
<NDialog
title="Unclosable Dialog"
:_dialog-close="{
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
Name | Props | Description |
---|---|---|
default | - | The trigger slot. |
content | - | The content slot. |
trigger | open | The trigger button used to open the dialog. |
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
Preview
Code
<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
Preview
Code
<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.
Preview
Code
<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.
Preview
Code
<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>
Presets
shortcuts/dialog.ts
type KbdPrefix = 'dialog'
export const staticDialog: Record<`${KbdPrefix}-${string}` | KbdPrefix, string> = {
// base
'dialog': '',
// sub-components
'dialog-overlay': 'fixed inset-0 z-50 bg-black/80',
'dialog-content': 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-base bg-base p-6 shadow-lg duration-200 sm:rounded-lg',
'dialog-scroll-overlay': 'fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80',
'dialog-scroll-content': 'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-base bg-base p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
'dialog-header': 'flex flex-col gap-y-1.5 text-center sm:text-left',
'dialog-title': 'text-lg font-semibold leading-none tracking-tight',
'dialog-description': 'text-sm text-muted',
'dialog-close': 'absolute right-4 top-4',
'dialog-footer': 'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
}
export const dynamicDialog: [RegExp, (params: RegExpExecArray) => string][] = [
// dynamic preset
]
export const dialog = [
...dynamicDialog,
staticDialog,
]
Props
types/dialog.ts
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']
}
Components
Dialog.vue
DialogTitle.vue
DialogDescription.vue
DialogHeader.vue
DialogFooter.vue
DialogClose.vue
DialogOverlay.vue
DialogContent.vue
DialogScrollContent.vue
<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-bind="_dialogHeader"
:class="!props.title && !props.description && !$slots.title && !$slots.description && !$slots.header ? 'sr-only' : undefined"
:una
>
<slot name="header">
<DialogTitle
v-bind="_dialogTitle"
:class="!props.title && !$slots.title ? 'sr-only' : undefined"
:una
>
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription
v-if="props.description || $slots.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', 'una')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="cn(
'dialog-title',
props.una?.dialogTitle,
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', 'una')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn(
'dialog-description data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-48%',
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 slots = defineSlots<any>()
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(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
!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', 'una', '_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:any) => {
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>