Skip to content

Modal

A dialog window that can be used to display a message or request user input.

Demo

This requires the following components to be installed:

This requires the following composables to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

Modal.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { ComponentConfig } from '@/ui/utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/ui/components/Button.vue'
import { usePortal } from '@/ui/composables/usePortal'
import { closeIcon } from '@/ui/icons'
import theme from '@/ui/theme/modal'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'

type Modal = ComponentConfig<typeof theme>

export interface ModalProps extends DialogRootProps {
  title?: string
  description?: string
  content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
  overlay?: boolean
  transition?: boolean
  fullscreen?: boolean
  portal?: boolean | string | HTMLElement
  close?: boolean | Partial<ButtonProps>
  closeIcon?: string
  dismissible?: boolean
  class?: any
  ui?: Modal['slots']
}

export interface ModalEmits extends DialogRootEmits {
  'after:leave': []
}

export interface ModalSlots {
  default: (props: { open: boolean }) => any
  content: (props?: object) => any
  header: (props?: object) => any
  title: (props?: object) => any
  description: (props?: object) => any
  close: (props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }) => any
  body: (props?: object) => any
  footer: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ModalProps>(), {
  close: true,
  portal: true,
  overlay: true,
  transition: true,
  modal: true,
  dismissible: true,
})
const emits = defineEmits<ModalEmits>()
const slots = defineSlots<ModalSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
  const events = {
    closeAutoFocus: (e: Event) => e.preventDefault(),
  }

  if (!props.dismissible) {
    return {
      pointerDownOutside: (e: Event) => e.preventDefault(),
      interactOutside: (e: Event) => e.preventDefault(),
      escapeKeyDown: (e: Event) => e.preventDefault(),
      ...events,
    }
  }

  return events
})

const ui = computed(() => tv(theme)({
  transition: props.transition,
  fullscreen: props.fullscreen,
}))
</script>

<!-- eslint-disable vue/custom-event-name-casing -->
<template>
  <DialogRoot v-slot="{ open }" v-bind="rootProps">
    <DialogTrigger v-if="!!slots.default" as-child :class="props.class">
      <slot :open="open" />
    </DialogTrigger>

    <DialogPortal v-bind="portalProps">
      <DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />

      <DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
        <VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
          <DialogTitle v-if="title || !!slots.title">
            <slot name="title">
              {{ title }}
            </slot>
          </DialogTitle>

          <DialogDescription v-if="description || !!slots.description">
            <slot name="description">
              {{ description }}
            </slot>
          </DialogDescription>
        </VisuallyHidden>

        <slot name="content">
          <div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
            <slot name="header">
              <div :class="ui.wrapper({ class: props.ui?.wrapper })">
                <DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
                  <slot name="title">
                    {{ title }}
                  </slot>
                </DialogTitle>

                <DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
                  <slot name="description">
                    {{ description }}
                  </slot>
                </DialogDescription>
              </div>

              <DialogClose v-if="close || !!slots.close" as-child>
                <slot name="close" :ui="ui">
                  <Button
                    v-if="close"
                    :icon="closeIcon"
                    size="md"
                    color="neutral"
                    variant="ghost"
                    aria-label="close"
                    v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
                    :class="ui.close({ class: props.ui?.close })"
                  />
                </slot>
              </DialogClose>
            </slot>
          </div>

          <div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
            <slot name="body" />
          </div>

          <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
            <slot name="footer" />
          </div>
        </slot>
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>
Modal.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/UI/Components/Button.vue'
import { usePortal } from '@/UI/Composables/usePortal'
import { closeIcon } from '@/UI/icons'
import theme from '@/UI/Theme/modal'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'

type Modal = ComponentConfig<typeof theme>

export interface ModalProps extends DialogRootProps {
  title?: string
  description?: string
  content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
  overlay?: boolean
  transition?: boolean
  fullscreen?: boolean
  portal?: boolean | string | HTMLElement
  close?: boolean | Partial<ButtonProps>
  closeIcon?: string
  dismissible?: boolean
  class?: any
  ui?: Modal['slots']
}

export interface ModalEmits extends DialogRootEmits {
  'after:leave': []
}

export interface ModalSlots {
  default: (props: { open: boolean }) => any
  content: (props?: object) => any
  header: (props?: object) => any
  title: (props?: object) => any
  description: (props?: object) => any
  close: (props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }) => any
  body: (props?: object) => any
  footer: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ModalProps>(), {
  close: true,
  portal: true,
  overlay: true,
  transition: true,
  modal: true,
  dismissible: true,
})
const emits = defineEmits<ModalEmits>()
const slots = defineSlots<ModalSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
  const events = {
    closeAutoFocus: (e: Event) => e.preventDefault(),
  }

  if (!props.dismissible) {
    return {
      pointerDownOutside: (e: Event) => e.preventDefault(),
      interactOutside: (e: Event) => e.preventDefault(),
      escapeKeyDown: (e: Event) => e.preventDefault(),
      ...events,
    }
  }

  return events
})

const ui = computed(() => tv(theme)({
  transition: props.transition,
  fullscreen: props.fullscreen,
}))
</script>

<!-- eslint-disable vue/custom-event-name-casing -->
<template>
  <DialogRoot v-slot="{ open }" v-bind="rootProps">
    <DialogTrigger v-if="!!slots.default" as-child :class="props.class">
      <slot :open="open" />
    </DialogTrigger>

    <DialogPortal v-bind="portalProps">
      <DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />

      <DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
        <VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
          <DialogTitle v-if="title || !!slots.title">
            <slot name="title">
              {{ title }}
            </slot>
          </DialogTitle>

          <DialogDescription v-if="description || !!slots.description">
            <slot name="description">
              {{ description }}
            </slot>
          </DialogDescription>
        </VisuallyHidden>

        <slot name="content">
          <div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
            <slot name="header">
              <div :class="ui.wrapper({ class: props.ui?.wrapper })">
                <DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
                  <slot name="title">
                    {{ title }}
                  </slot>
                </DialogTitle>

                <DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
                  <slot name="description">
                    {{ description }}
                  </slot>
                </DialogDescription>
              </div>

              <DialogClose v-if="close || !!slots.close" as-child>
                <slot name="close" :ui="ui">
                  <Button
                    v-if="close"
                    :icon="closeIcon"
                    size="md"
                    color="neutral"
                    variant="ghost"
                    aria-label="close"
                    v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
                    :class="ui.close({ class: props.ui?.close })"
                  />
                </slot>
              </DialogClose>
            </slot>
          </div>

          <div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
            <slot name="body" />
          </div>

          <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
            <slot name="footer" />
          </div>
        </slot>
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>

Theme

modal.ts
ts
export default {
  slots: {
    overlay: '',
    content: '',
    header: '',
    wrapper: '',
    body: '',
    footer: '',
    title: '',
    description: '',
    close: '',
  },
  variants: {
    transition: {
      true: {
        overlay: '',
        content: '',
      },
    },
    fullscreen: {
      true: {
        content: '',
      },
      false: {
        content: '',
      },
    },
  },
}
View Nuxt UI theme
modal.ts
ts
export default {
  slots: {
    overlay: 'fixed inset-0 bg-elevated/75',
    content: 'fixed bg-default divide-y divide-default flex flex-col focus:outline-none',
    header: 'flex items-center gap-1.5 p-4 sm:px-6 min-h-16',
    wrapper: '',
    body: 'flex-1 overflow-y-auto p-4 sm:p-6',
    footer: 'flex items-center gap-1.5 p-4 sm:px-6',
    title: 'text-highlighted font-semibold',
    description: 'mt-1 text-muted text-sm',
    close: 'absolute top-4 end-4',
  },
  variants: {
    transition: {
      true: {
        overlay: 'data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in]',
        content: 'data-[state=open]:animate-[scale-in_200ms_ease-out] data-[state=closed]:animate-[scale-out_200ms_ease-in]',
      },
    },
    fullscreen: {
      true: {
        content: 'inset-0',
      },
      false: {
        content: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[calc(100vw-2rem)] max-w-lg max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] rounded-lg shadow-lg ring ring-default',
      },
    },
  },
}

Test

To test this component, you can use the following test file:

Modal.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Modal from '@/ui/components/Modal.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('modal', () => {
  const props = { open: true, portal: false }

  it.each<[string, RenderOptions<typeof Modal>]>([
    // Props
    ['with open', { props }],
    ['with title', { props: { ...props, title: 'Title' } }],
    ['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
    ['with fullscreen', { props: { ...props, fullscreen: true, title: 'Title', description: 'Description' } }],
    ['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
    ['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
    ['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
    ['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with header slot', { props, slots: { header: () => 'Header slot' } }],
    ['with title slot', { props, slots: { title: () => 'Title slot' } }],
    ['with description slot', { props, slots: { description: () => 'Description slot' } }],
    ['with close slot', { props, slots: { close: () => 'Close slot' } }],
    ['with body slot', { props, slots: { body: () => 'Body slot' } }],
    ['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
  ])('renders %s correctly', async (name, options) => {
    const { html } = render(Modal, options)

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(html()).matchSnapshot()
  })
})
Modal.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Modal from '@/UI/Components/Modal.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('modal', () => {
  const props = { open: true, portal: false }

  it.each<[string, RenderOptions<typeof Modal>]>([
    // Props
    ['with open', { props }],
    ['with title', { props: { ...props, title: 'Title' } }],
    ['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
    ['with fullscreen', { props: { ...props, fullscreen: true, title: 'Title', description: 'Description' } }],
    ['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
    ['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
    ['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
    ['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with header slot', { props, slots: { header: () => 'Header slot' } }],
    ['with title slot', { props, slots: { title: () => 'Title slot' } }],
    ['with description slot', { props, slots: { description: () => 'Description slot' } }],
    ['with close slot', { props, slots: { close: () => 'Close slot' } }],
    ['with body slot', { props, slots: { body: () => 'Body slot' } }],
    ['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
  ])('renders %s correctly', async (name, options) => {
    const { html } = render(Modal, options)

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(html()).matchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

4ae5c - fix: modal on 3/28/2025
24284 - fix: modal description (#144) on 2/13/2025
62f35 - fix: replace UButton with Button in modal and toast components (#129) on 2/3/2025
c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
51f9b - feat: add slideover component (#47) on 12/18/2024
f0bfa - feat: modal (#4) on 12/18/2024