Drawer

A drawer component for Vue.

Examples

Basic

PropDefaultTypeDescription
activeSnapPoint-number | string | nullSpecifies the currently active snap point for the drawer.
closeThreshold0.25numberNumber between 0 and 1 that determines when the drawer should be closed.
shouldScaleBackground-booleanDetermines whether the background content should scale down when the dialog is open.
setBackgroundColorOnScaletruebooleanWhen false, the body's background color will not change when the drawer is open.
scrollLockTimeout500numberDuration (in milliseconds) for which the drawer is not draggable after scrolling content inside of the drawer.
fixed-booleanWhen true, prevents the drawer from moving upwards if there's space, instead changing its height to make it fully scrollable when the keyboard is open.
dismissibletruebooleanWhen false, dragging, clicking outside, pressing Esc, etc., will not close the drawer.
modaltruebooleanWhen true, interaction with outside elements is disabled, and only dialog content is visible to screen readers.
open-booleanThe controlled open state of the dialog. Can be bound using v-model:open.
overlaytruebooleanDetermines whether to show the overlay.
defaultOpen-booleanShows the drawer immediately upon loading.
nested-booleanEnables nested drawers.
directionbottomtop | bottom | left | rightSpecifies the direction of the drawer.
noBodyStyles-booleanWhen true, the body does not get any styles assigned from Vaul.
preventScrollRestoration-booleanPrevents the browser from restoring the scroll position when the drawer is closed.
snapPoints-number | stringArray of numbers (0 to 100) representing the percentage of the screen a given snap point should take up. Example: [0.2, 0.5, 0.8].
Preview
Code

Slots

NamePropsDescription
default-Allows advanced customization using sub-components, replacing the default drawer structure.
triggeropenThe trigger button used to open the drawer. Receives the current open state.
content-Replaces the entire content container, including header, body, and footer.
header-Custom content for the header section, replacing both title and description.
title-Custom content for the drawer title, replacing the default title prop.
description-Custom content for the drawer description, replacing the default description prop.
body-Content to display in the main section of the drawer, between header and footer.
footer-Custom content for the drawer footer. By default contains a close button.
close-Custom content for the close button that dismisses the drawer. Used within DrawerClose component.

Custom Rendering

Use the default slot for full control over the drawer's structure. This allows you to compose the drawer using its individual sub-components (like DrawerContent, DrawerClose, etc., listed in the Components section), similar to libraries like shadcn/ui.

Preview
Code

Presets

shortcuts/drawer.ts
type KbdPrefix = 'drawer'

export const staticDrawer: Record<`${KbdPrefix}-${string}` | KbdPrefix, string> = {
  // base
  'drawer': '',

  'drawer-overlay': 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',

  'drawer-content': 'bg-background fixed z-50 flex h-auto flex-col',
  'drawer-content-top': 'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg',
  'drawer-content-bottom': 'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg',
  'drawer-content-right': 'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm',
  'drawer-content-left': 'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm',
  'drawer-handle': 'mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]:block',

  'drawer-header': 'flex flex-col gap-1.5 p-4',
  'drawer-title': 'text-foreground font-semibold',
  'drawer-description': 'text-muted text-sm',
  'drawer-footer': 'mt-auto flex flex-col gap-2 p-4',
}

export const dynamicDrawer: [RegExp, (params: RegExpExecArray) => string][] = [
  // dynamic preset
]

export const drawer = [
  ...dynamicDrawer,
  staticDrawer,
]

Props

types/drawer.ts
import type { DialogContentProps, DialogOverlayProps } from 'reka-ui'
import type { DrawerCloseProps, DrawerDescriptionProps, DrawerRootProps, DrawerTitleProps, DrawerTriggerProps } from 'vaul-vue'

import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'

export interface NDrawerProps extends Omit<DrawerRootProps, 'fadeFromIndex'>, Pick<NDrawerContentProps, 'showClose' | 'overlay' | '_drawerClose' | '_drawerOverlay'> {
  /**
   * The title of the dialog.
   */
  title?: string
  /**
   * The description of the dialog.
   */
  description?: string
  // sub-components
  _drawerTitle?: NDrawerTitleProps
  _drawerDescription?: NDrawerDescriptionProps
  _drawerContent?: NDrawerContentProps
  _drawerTrigger?: NDrawerTriggerProps
  _drawerHeader?: NDrawerHeaderProps
  _drawerFooter?: NDrawerFooterProps
  /**
   * The index of the fade from the drawer.
   */
  fadeFromIndex?: number
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/drawer.ts
   */
  una?: NDrawerUnaProps
}

export interface NDrawerTitleProps extends DrawerTitleProps, BaseExtensions {
  una?: Pick<NDrawerUnaProps, 'drawerTitle'>
}

export interface NDrawerDescriptionProps extends DrawerDescriptionProps, BaseExtensions {
  una?: Pick<NDrawerUnaProps, 'drawerDescription'>
}

export interface NDrawerContentProps extends DialogContentProps, BaseExtensions {
  /**
   * Show close button.
   *
   * @default true
   */
  showClose?: boolean
  /**
   * Show overlay.
   *
   * @default true
   */
  overlay?: boolean
  /**
   * The close button props.
   */
  _drawerClose?: NDrawerCloseProps
  /**
   * The overlay props.
   */
  _drawerOverlay?: NDrawerOverlayProps
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/drawer.ts
   */
  una?: Pick<NDrawerUnaProps, 'drawerContent' | 'drawerOverlay' | 'drawerHandle'>
}

export interface NDrawerCloseProps extends DrawerCloseProps, NButtonProps {
}

export interface NDrawerOverlayProps extends BaseExtensions, DialogOverlayProps {
  una?: Pick<NDrawerUnaProps, 'drawerOverlay'>
}

export interface NDrawerTriggerProps extends DrawerTriggerProps {
}

export interface NDrawerHeaderProps extends BaseExtensions {
  una?: Pick<NDrawerUnaProps, 'drawerHeader'>
}

export interface NDrawerFooterProps extends BaseExtensions {
  una?: Pick<NDrawerUnaProps, 'drawerFooter'>
}

export interface NDrawerUnaProps {
  drawerTitle?: HTMLAttributes['class']
  drawerDescription?: HTMLAttributes['class']
  drawerOverlay?: HTMLAttributes['class']
  drawerContent?: HTMLAttributes['class']
  drawerHandle?: HTMLAttributes['class']
  drawerHeader?: HTMLAttributes['class']
  drawerFooter?: HTMLAttributes['class']
}

interface BaseExtensions {
  class?: HTMLAttributes['class']
}

Components

Drawer.vue
DrawerClose.vue
DrawerContent.vue
DrawerDescription.vue
DrawerFooter.vue
DrawerHeader.vue
DrawerOverlay.vue
DrawerTitle.vue
DrawerTrigger.vue
<script setup lang="ts">
import type { DrawerRootEmits } from 'vaul-vue'
import type { NDrawerProps } from '../../types'
import { reactivePick } from '@vueuse/core'
import { useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { DrawerRoot } from 'vaul-vue'
import { computed } from 'vue'
import { randomId } from '../../utils'
import DrawerClose from './DrawerClose.vue'
import DrawerContent from './DrawerContent.vue'
import DrawerDescription from './DrawerDescription.vue'
import DrawerFooter from './DrawerFooter.vue'
import DrawerHeader from './DrawerHeader.vue'
import DrawerTitle from './DrawerTitle.vue'
import DrawerTrigger from './DrawerTrigger.vue'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(defineProps<NDrawerProps>(), {
  shouldScaleBackground: true,
})
const emits = defineEmits<DrawerRootEmits>()
const DEFAULT_TITLE = randomId('drawer-title')
const DEFAULT_DESCRIPTION = randomId('drawer-description')

const title = computed(() => props.title ?? DEFAULT_TITLE)
const description = computed(() => props.description ?? DEFAULT_DESCRIPTION)

const rootProps = reactivePick(props, [
  'activeSnapPoint',
  'closeThreshold',
  'shouldScaleBackground',
  'setBackgroundColorOnScale',
  'scrollLockTimeout',
  'fixed',
  'dismissible',
  'modal',
  'open',
  'defaultOpen',
  'nested',
  'direction',
  'noBodyStyles',
  'handleOnly',
  'preventScrollRestoration',
  'snapPoints',
])

const forwarded = useForwardPropsEmits(rootProps, emits)
</script>

<template>
  <DrawerRoot
    v-slot="{ open }"
    data-slot="drawer"
    v-bind="forwarded"
  >
    <slot>
      <DrawerTrigger
        v-bind="_drawerTrigger"
        as-child
      >
        <slot name="trigger" :open />
      </DrawerTrigger>

      <DrawerContent
        v-bind="_drawerContent"
        :_drawer-overlay
        :una
      >
        <VisuallyHidden v-if="(title === DEFAULT_TITLE || !!$slots.title) || (description === DEFAULT_DESCRIPTION || !!$slots.description)">
          <DrawerTitle v-if="title === DEFAULT_TITLE || !!$slots.title">
            {{ title }}
          </DrawerTitle>

          <DrawerDescription v-if="description === DEFAULT_DESCRIPTION || !!$slots.description">
            {{ description }}
          </DrawerDescription>
        </VisuallyHidden>

        <slot name="content">
          <!-- Header -->
          <DrawerHeader
            v-if="!!$slots.header || (title !== DEFAULT_TITLE || !!$slots.title) || (description !== DEFAULT_DESCRIPTION || !!$slots.description)"
            v-bind="_drawerHeader"
            :una
          >
            <slot name="header">
              <DrawerTitle
                v-if="title !== DEFAULT_TITLE || !!$slots.title"
                v-bind="_drawerTitle"
                :una
              >
                <slot name="title">
                  {{ title }}
                </slot>
              </DrawerTitle>

              <DrawerDescription
                v-if="description !== DEFAULT_DESCRIPTION || !!$slots.description"
                v-bind="_drawerDescription"
                :una
              >
                <slot name="description">
                  {{ description }}
                </slot>
              </DrawerDescription>
            </slot>
          </DrawerHeader>

          <!-- Body -->
          <slot name="body" />

          <!-- Footer -->
          <DrawerFooter
            v-bind="_drawerFooter"
            :una
          >
            <slot name="footer">
              <DrawerClose
                v-bind="_drawerClose"
              >
                <slot name="close" />
              </DrawerClose>
            </slot>
          </DrawerFooter>
        </slot>
      </DrawerContent>
    </slot>
  </DrawerRoot>
</template>