Component · Modals & overlays
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.
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 Vueplayv1.0.0Updated 3 hours ago
A side drawer for details, filters and edit forms: it slides in over the page while the list underneath stays in view. Use it when a full modal would feel heavy, or when the content is tall and reads better as a column.
It behaves like a proper modal dialog. Focus moves to the first field (not the close button), Tab stays inside the panel, Escape and backdrop clicks close it, page scroll is locked, and focus returns to whatever opened it. The slide animation is skipped for people who prefer reduced motion.
Choose the edge with side and the width with size; on phones it fills the screen. The body scrolls on its own between a fixed header and a sticky footer, and both header and footer slots receive a close() helper.
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
export type SlideOverSize = 'sm' | 'md' | 'lg' | 'xl'
const props = withDefaults(defineProps<{
/** Whether the panel is shown. Use with `v-model:open`. */
open?: boolean
/** Edge the panel slides in from. */
side?: 'left' | 'right'
title?: string
description?: string
size?: SlideOverSize
closeOnBackdrop?: boolean
closeOnEsc?: boolean
showClose?: boolean
}>(), {
open: false,
side: 'right',
title: '',
description: '',
size: 'md',
closeOnBackdrop: true,
closeOnEsc: true,
showClose: true,
})
const emit = defineEmits<{
'update:open': [open: boolean]
close: []
}>()
defineSlots<{
default?: () => unknown
/** Replaces the title and description block. */
header?: (props: { close: () => void }) => unknown
footer?: (props: { close: () => void }) => unknown
}>()
const uid = useId()
const titleId = `${uid}-title`
const descriptionId = `${uid}-description`
const panel = ref<HTMLElement | null>(null)
const sizeClass: Record<SlideOverSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-xl',
xl: 'max-w-3xl',
}
const FOCUSABLE = [
'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])',
'textarea:not([disabled])', '[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()
return
}
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.value)) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
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 returnFocusTo: HTMLElement | null = null
let previousOverflow = ''
let previousPaddingRight = ''
let active = false
async function activate() {
if (active) return
active = true
returnFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null
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`
document.addEventListener('focusin', onFocusIn)
await nextTick()
// Prefer the first field over the close button so forms are ready to type into.
const target = focusables().find((el) => !el.hasAttribute('data-slide-over-close')) ?? focusables()[0] ?? panel.value
target?.focus({ preventScroll: true })
}
function deactivate() {
if (!active) return
active = false
document.removeEventListener('focusin', onFocusIn)
document.body.style.overflow = previousOverflow
document.body.style.paddingRight = previousPaddingRight
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-300 ease-out"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-200 ease-in"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 z-50 bg-gray-950/40 dark:bg-black/60"
aria-hidden="true"
@click="closeOnBackdrop && close()"
/>
</Transition>
<Transition
enter-active-class="transform transition duration-300 ease-out motion-reduce:transition-none"
:enter-from-class="side === 'right' ? 'translate-x-full' : '-translate-x-full'"
leave-active-class="transform transition duration-200 ease-in motion-reduce:transition-none"
:leave-to-class="side === 'right' ? 'translate-x-full' : '-translate-x-full'"
>
<div
v-if="open"
ref="panel"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? titleId : undefined"
:aria-describedby="description ? descriptionId : undefined"
tabindex="-1"
class="fixed inset-y-0 z-50 flex w-full flex-col bg-white shadow-2xl outline-none dark:bg-gray-900 dark:ring-1 dark:ring-white/10"
:class="[sizeClass[size], side === 'right' ? 'right-0' : 'left-0']"
@keydown="onKeydown"
>
<header class="flex items-start gap-4 border-b border-gray-100 px-5 py-5 sm:px-6 dark:border-white/10">
<div class="min-w-0 flex-1">
<slot name="header" :close="close">
<h2 v-if="title" :id="titleId" class="text-base font-semibold text-gray-900 dark:text-white">{{ title }}</h2>
<p v-if="description" :id="descriptionId" class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ description }}</p>
</slot>
</div>
<button
v-if="showClose"
type="button"
data-slide-over-close
class="-m-1.5 rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-teal-600 dark:hover:bg-white/10 dark:hover:text-gray-200 dark:focus-visible:outline-teal-400"
aria-label="Close panel"
@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>
</header>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-6 sm:px-6">
<slot />
</div>
<footer v-if="$slots.footer" class="flex justify-end gap-3 border-t border-gray-100 bg-gray-50 px-5 py-4 sm:px-6 dark:border-white/10 dark:bg-white/[0.03]">
<slot name="footer" :close="close" />
</footer>
</div>
</Transition>
</Teleport>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | false | Whether the panel is visible. Bind with v-model:open. |
| side | 'left' | 'right' | 'right' | Edge of the screen the panel slides in from. |
| title | string | '' | Panel heading, also used as the dialog's accessible name. |
| description | string | '' | Supporting text under the title, linked with aria-describedby. |
| size | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Maximum panel width. On small screens the panel is full width. |
| closeOnBackdrop | boolean | true | Close when the dimmed backdrop is clicked. |
| closeOnEsc | boolean | true | Close when Escape is pressed. |
| showClose | boolean | true | Show the close (×) button in the header. |
| Event | Payload | Description |
|---|---|---|
| update:open | (open: boolean) | Emitted with false when the panel asks to close. |
| close | () | Emitted whenever the panel closes itself. |
| Slot | Description |
|---|---|
| default | Panel body. Scrolls independently of the header and footer. |
| header | Replaces the title and description block. Receives { close }. |
| footer | Action bar pinned to the bottom. Receives { close }. |
Component · Modals & overlays
Accessible Vue 3 modal dialog with v-model:open, focus trap, Escape and backdrop close, scroll lock and footer slot. 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 · 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.
Component · Feature sections
Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.