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.
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
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.
<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>
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | false | Whether the dialog is visible. Bind with v-model:open. |
| title | string | '' | Dialog heading, also used as the accessible name. Can be replaced with the title slot. |
| description | string | '' | 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. |
| closeOnBackdrop | boolean | true | Close when the backdrop is clicked. |
| closeOnEsc | boolean | true | Close when Escape is pressed. |
| showClose | boolean | true | Show the close (×) button in the top-right corner. |
| initialFocus | string | undefined | CSS selector of the element inside the dialog to focus on open. Defaults to the first focusable element. |
| Event | Payload | Description |
|---|---|---|
| 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. |
| Slot | Description |
|---|---|
| default | Dialog body. Scrolls when the content is taller than the viewport. |
| title | Custom title content (replaces the title prop). |
| description | Custom description content (replaces the description prop). |
| icon | Optional icon shown beside the title, e.g. a warning badge. |
| footer | Action buttons. Receives { close }. |
Component · Modals & overlays
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.
Component · Modals & overlays
A lightweight and customizable tooltip component with dynamic dark/light themes, responsive positioning (top, bottom, left, right), and smooth hover effects with adaptive colors.
Component · Navigation
Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.
Component · Hero sections
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.