Vue componentFree

Modal Dialog

Accessible Vue 3 modal dialog with v-model:open, focus trap, Escape and backdrop close, scroll lock and footer slot. Tailwind CSS, dark mode.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

A modal that handles the fiddly parts for you: it teleports to the body, traps Tab focus inside, closes on Escape or a backdrop click, locks page scroll without layout shift, and hands focus back to the button that opened it.

Use it for confirmations, short forms and anything that needs the user's full attention. Pass a title and description as props or slots, drop an icon into the icon slot, and put actions in the footer slot, which receives a close() helper.

The panel is labelled with aria-labelledby and aria-describedby automatically. On phones it slides up from the bottom; on larger screens it scales in centred. Pick a width with the size prop and point initialFocus at the safest button for destructive confirmations.

  • v-model:open with Teleport to body
  • Focus trap, Escape close and focus return
  • Scroll lock without layout shift
  • Title, description, icon and footer slots
  • Four sizes, bottom sheet on mobile

Code

11 files in the repo
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'

export type ModalSize = 'sm' | 'md' | 'lg' | 'xl'

const props = withDefaults(defineProps<{
  /** Whether the dialog is shown. Use with `v-model:open`. */
  open?: boolean
  title?: string
  description?: string
  size?: ModalSize
  /** Close when the dimmed backdrop is clicked. */
  closeOnBackdrop?: boolean
  /** Close when Escape is pressed. */
  closeOnEsc?: boolean
  /** Show the × button in the top-right corner. */
  showClose?: boolean
  /** CSS selector (inside the dialog) of the element to focus on open. Defaults to the first focusable element. */
  initialFocus?: string
}>(), {
  open: false,
  title: '',
  description: '',
  size: 'md',
  closeOnBackdrop: true,
  closeOnEsc: true,
  showClose: true,
  initialFocus: undefined,
})

const emit = defineEmits<{
  'update:open': [open: boolean]
  close: []
}>()

defineSlots<{
  default?: () => unknown
  icon?: () => unknown
  title?: () => unknown
  description?: () => unknown
  footer?: (props: { close: () => void }) => unknown
}>()

const uid = useId()
const titleId = `${uid}-title`
const descriptionId = `${uid}-description`

const panel = ref<HTMLElement | null>(null)
let returnFocusTo: HTMLElement | null = null
let pointerDownOnBackdrop = false

const sizeClass: Record<ModalSize, string> = {
  sm: 'sm:max-w-sm',
  md: 'sm:max-w-lg',
  lg: 'sm:max-w-2xl',
  xl: 'sm:max-w-4xl',
}

const FOCUSABLE = [
  'a[href]', 'area[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])', 'textarea:not([disabled])', 'iframe', '[contenteditable="true"]',
  '[tabindex]:not([tabindex="-1"])',
].join(',')

function focusables(): HTMLElement[] {
  if (!panel.value) return []
  return Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))
    .filter((el) => el.getClientRects().length > 0)
}

function close() {
  emit('update:open', false)
  emit('close')
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape' && props.closeOnEsc) {
    event.stopPropagation()
    close()
    return
  }
  if (event.key !== 'Tab') return
  const list = focusables()
  const first = list[0]
  const last = list[list.length - 1]
  if (!first || !last) {
    event.preventDefault()
    panel.value?.focus()
    return
  }
  const active = document.activeElement
  if (event.shiftKey && (active === first || active === panel.value)) {
    event.preventDefault()
    last.focus()
  } else if (!event.shiftKey && active === last) {
    event.preventDefault()
    first.focus()
  }
}

// Only treat it as a backdrop click when the press both started and ended on the backdrop,
// so selecting text inside the dialog and releasing outside doesn't close it.
function onBackdropPointerDown(event: PointerEvent) {
  pointerDownOnBackdrop = event.target === event.currentTarget
}
function onBackdropClick(event: MouseEvent) {
  if (props.closeOnBackdrop && pointerDownOnBackdrop && event.target === event.currentTarget) close()
  pointerDownOnBackdrop = false
}

// Pull focus back if something outside the dialog receives it (e.g. a programmatic focus call).
function onFocusIn(event: FocusEvent) {
  const target = event.target as Node | null
  if (panel.value && target && !panel.value.contains(target)) {
    (focusables()[0] ?? panel.value).focus({ preventScroll: true })
  }
}

let previousOverflow = ''
let previousPaddingRight = ''
function lockScroll() {
  const scrollbar = window.innerWidth - document.documentElement.clientWidth
  previousOverflow = document.body.style.overflow
  previousPaddingRight = document.body.style.paddingRight
  document.body.style.overflow = 'hidden'
  if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`
}
function unlockScroll() {
  document.body.style.overflow = previousOverflow
  document.body.style.paddingRight = previousPaddingRight
}

let active = false
async function activate() {
  if (active) return
  active = true
  returnFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null
  lockScroll()
  document.addEventListener('focusin', onFocusIn)
  await nextTick()
  const target = (props.initialFocus && panel.value?.querySelector<HTMLElement>(props.initialFocus))
    || focusables().find((el) => !el.hasAttribute('data-modal-close'))
    || focusables()[0]
    || panel.value
  target?.focus({ preventScroll: true })
}
function deactivate() {
  if (!active) return
  active = false
  document.removeEventListener('focusin', onFocusIn)
  unlockScroll()
  returnFocusTo?.focus({ preventScroll: true })
  returnFocusTo = null
}

watch(() => props.open, (isOpen) => (isOpen ? activate() : deactivate()), { immediate: true })
onBeforeUnmount(deactivate)
</script>

<template>
  <Teleport to="body">
    <Transition
      enter-active-class="transition-opacity duration-200 ease-out"
      enter-from-class="opacity-0"
      leave-active-class="transition-opacity duration-150 ease-in"
      leave-to-class="opacity-0"
    >
      <div v-if="open" class="fixed inset-0 z-50 bg-gray-950/50 backdrop-blur-[2px] dark:bg-black/70" aria-hidden="true" />
    </Transition>

    <Transition
      enter-active-class="transition duration-200 ease-out"
      enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
      leave-active-class="transition duration-150 ease-in"
      leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
    >
      <div
        v-if="open"
        class="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto p-4 sm:items-center sm:p-6"
        @pointerdown="onBackdropPointerDown"
        @click="onBackdropClick"
      >
        <div
          ref="panel"
          role="dialog"
          aria-modal="true"
          :aria-labelledby="title || $slots.title ? titleId : undefined"
          :aria-describedby="description || $slots.description ? descriptionId : undefined"
          tabindex="-1"
          class="relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-2xl bg-white text-left shadow-2xl ring-1 ring-gray-900/5 outline-none sm:max-h-[calc(100dvh-3rem)] dark:bg-gray-900 dark:ring-white/10"
          :class="sizeClass[size]"
          @keydown="onKeydown"
        >
          <div class="flex gap-4 px-6 pt-6" :class="$slots.default ? 'pb-2' : 'pb-6'">
            <div v-if="$slots.icon" class="shrink-0">
              <slot name="icon" />
            </div>
            <div class="min-w-0 flex-1" :class="showClose ? 'pr-8' : ''">
              <h2 v-if="title || $slots.title" :id="titleId" class="text-lg font-semibold text-gray-900 dark:text-white">
                <slot name="title">{{ title }}</slot>
              </h2>
              <div v-if="description || $slots.description" :id="descriptionId" class="mt-1.5 text-sm/6 text-gray-600 dark:text-gray-400">
                <slot name="description">{{ description }}</slot>
              </div>
            </div>
          </div>

          <button
            v-if="showClose"
            type="button"
            class="absolute top-4 right-4 rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-violet-600 dark:hover:bg-white/10 dark:hover:text-gray-200 dark:focus-visible:outline-violet-400"
            aria-label="Close dialog"
            data-modal-close
            @click="close"
          >
            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">
              <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
            </svg>
          </button>

          <div v-if="$slots.default" class="min-h-0 flex-1 overflow-y-auto px-6 pt-2 pb-6 text-sm/6 text-gray-700 dark:text-gray-300">
            <slot />
          </div>

          <div
            v-if="$slots.footer"
            class="flex flex-col-reverse gap-3 border-t border-gray-100 bg-gray-50 px-6 py-4 sm:flex-row sm:justify-end dark:border-white/5 dark:bg-white/[0.03]"
          >
            <slot name="footer" :close="close" />
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

Props

PropTypeDefaultDescription
openbooleanfalseWhether the dialog is visible. Bind with v-model:open.
titlestring''Dialog heading, also used as the accessible name. Can be replaced with the title slot.
descriptionstring''Supporting text under the title, linked with aria-describedby.
size'sm' | 'md' | 'lg' | 'xl''md'Maximum width of the panel on screens from the sm breakpoint up.
closeOnBackdropbooleantrueClose when the backdrop is clicked.
closeOnEscbooleantrueClose when Escape is pressed.
showClosebooleantrueShow the close (×) button in the top-right corner.
initialFocusstringundefinedCSS selector of the element inside the dialog to focus on open. Defaults to the first focusable element.

Events

EventPayloadDescription
update:open(open: boolean)Emitted with false when the dialog asks to close (Escape, backdrop, close button or footer close()).
close()Emitted whenever the dialog closes itself.

Slots

SlotDescription
defaultDialog body. Scrolls when the content is taller than the viewport.
titleCustom title content (replaces the title prop).
descriptionCustom description content (replaces the description prop).
iconOptional icon shown beside the title, e.g. a warning badge.
footerAction buttons. Receives { close }.
All modals & overlays →
Preview of Slide-over PanelFree

Component · Modals & overlays

Slide-over Panel

Vue 3 slide-over drawer that opens from the left or right, with v-model:open, focus trap, Escape close, header and footer slots. Tailwind CSS, dark mode.

by Vueplay
Preview of ToolFree

Component · Modals & overlays

Tool

A lightweight and customizable tooltip component with dynamic dark/light themes, responsive positioning (top, bottom, left, right), and smooth hover effects with adaptive colors.

Preview of Dropdown MenuFree

Component · Navigation

Dropdown Menu

Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.

by Vueplay1 installs

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings