Vue componentFree

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 Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

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.

  • Left or right edge, four widths
  • v-model:open with slide and fade transitions
  • Focus trap, Escape close and focus return
  • Scrolling body with fixed header and footer
  • Respects prefers-reduced-motion

Code

11 files in the repo
<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>

Props

PropTypeDefaultDescription
openbooleanfalseWhether the panel is visible. Bind with v-model:open.
side'left' | 'right''right'Edge of the screen the panel slides in from.
titlestring''Panel heading, also used as the dialog's accessible name.
descriptionstring''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.
closeOnBackdropbooleantrueClose when the dimmed backdrop is clicked.
closeOnEscbooleantrueClose when Escape is pressed.
showClosebooleantrueShow the close (×) button in the header.

Events

EventPayloadDescription
update:open(open: boolean)Emitted with false when the panel asks to close.
close()Emitted whenever the panel closes itself.

Slots

SlotDescription
defaultPanel body. Scrolls independently of the header and footer.
headerReplaces the title and description block. Receives { close }.
footerAction bar pinned to the bottom. Receives { close }.
All modals & overlays →
Preview of Modal DialogFree

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.

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 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
Preview of Bento GridFree

Component · Feature sections

Bento Grid

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.

by Vueplay

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