Examples
Basic
Prop | Default | Type | Description |
---|---|---|---|
activeSnapPoint | - | number | string | null | Specifies the currently active snap point for the drawer. |
closeThreshold | 0.25 | number | Number between 0 and 1 that determines when the drawer should be closed. |
shouldScaleBackground | - | boolean | Determines whether the background content should scale down when the dialog is open. |
setBackgroundColorOnScale | true | boolean | When false , the body's background color will not change when the drawer is open. |
scrollLockTimeout | 500 | number | Duration (in milliseconds) for which the drawer is not draggable after scrolling content inside of the drawer. |
fixed | - | boolean | When 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. |
dismissible | true | boolean | When false , dragging, clicking outside, pressing Esc , etc., will not close the drawer. |
modal | true | boolean | When true , interaction with outside elements is disabled, and only dialog content is visible to screen readers. |
open | - | boolean | The controlled open state of the dialog. Can be bound using v-model:open . |
overlay | true | boolean | Determines whether to show the overlay. |
defaultOpen | - | boolean | Shows the drawer immediately upon loading. |
nested | - | boolean | Enables nested drawers. |
direction | bottom | top | bottom | left | right | Specifies the direction of the drawer. |
noBodyStyles | - | boolean | When true , the body does not get any styles assigned from Vaul. |
preventScrollRestoration | - | boolean | Prevents the browser from restoring the scroll position when the drawer is closed. |
snapPoints | - | number | string | Array 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
<script setup lang="ts">
const goal = ref(350)
const directions = ['top', 'right', 'bottom', 'left'] as const
</script>
<template>
<div class="flex flex-wrap items-start gap-4">
<NDrawer
title="Move Goal"
description="Set your daily activity goal."
:una="{
drawerHeader: 'mx-auto max-w-sm w-full',
drawerFooter: 'mx-auto max-w-sm w-full',
}"
>
<template #trigger>
<NButton btn="solid-gray">
Open Drawer
</NButton>
</template>
<template #body>
<div class="mx-auto max-w-sm w-full">
<div class="p-4 pb-0">
<div class="flex items-center justify-center space-x-2">
<NButton
btn="solid-gray"
square="8"
icon
label="i-lucide-minus"
rounded="full"
:disabled="goal <= 200"
@click="goal -= 10"
/>
<div class="flex-1 text-center">
<div class="text-7xl font-bold tracking-tighter">
{{ goal }}
</div>
<div class="text-[0.70rem] text-muted uppercase">
Calories/day
</div>
</div>
<NButton
btn="solid-gray"
square="8"
icon
label="i-lucide-plus"
rounded="full"
:disabled="goal >= 400"
@click="goal += 10"
/>
</div>
<div class="mt-3 h-[120px] border rounded" />
</div>
</div>
</template>
<template #footer>
<NButton>Submit</NButton>
<NDrawerClose as-child>
<NButton btn="solid-gray">
Cancel
</NButton>
</NDrawerClose>
</template>
</NDrawer>
<NDrawer
direction="right"
title="Move Goal"
description="Set your daily activity goal."
>
<template #trigger>
<NButton btn="solid-gray">
Scrollable Content
</NButton>
</template>
<template #body>
<div class="overflow-y-auto px-4 text-sm">
<h4 class="mb-4 text-lg font-medium leading-none">
Lorem Ipsum
</h4>
<p v-for="index in 10" :key="index" class="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
</template>
<template #footer>
<NButton>Submit</NButton>
<NDrawerClose as-child>
<NButton btn="solid-gray">
Cancel
</NButton>
</NDrawerClose>
</template>
</NDrawer>
<NDrawer
v-for="direction in directions"
:key="direction"
:direction="direction"
title="Move Goal"
description="Set your daily activity goal."
>
<template #trigger>
<NButton btn="solid-gray" class="capitalize">
{{ direction }}
</NButton>
</template>
<template #body>
<div class="overflow-y-auto px-4 text-sm">
<p v-for="index in 10" :key="index" class="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute
irure dolor in reprehenderit in voluptate velit esse cillum
dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</p>
</div>
</template>
<template #footer>
<NButton>Submit</NButton>
<NDrawerClose as-child>
<NButton btn="solid-gray">
Cancel
</NButton>
</NDrawerClose>
</template>
</NDrawer>
</div>
</template>
Slots
Name | Props | Description |
---|---|---|
default | - | Allows advanced customization using sub-components, replacing the default drawer structure. |
trigger | open | The 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
<script setup lang="ts">
const goal = ref(350)
const directions = ['top', 'right', 'bottom', 'left'] as const
</script>
<template>
<div class="flex flex-wrap items-start gap-4">
<NDrawer>
<NDrawerTrigger as-child>
<NButton btn="solid-gray">
Open Drawer
</NButton>
</NDrawerTrigger>
<NDrawerContent>
<div class="mx-auto max-w-sm w-full">
<NDrawerHeader>
<NDrawerTitle>Move Goal</NDrawerTitle>
<NDrawerDescription>Set your daily activity goal.</NDrawerDescription>
</NDrawerHeader>
<div class="p-4 pb-0">
<div class="flex items-center justify-center space-x-2">
<NButton
btn="solid-gray"
square="8"
icon
label="i-lucide-minus"
rounded="full"
:disabled="goal <= 200"
@click="goal -= 10"
/>
<div class="flex-1 text-center">
<div class="text-7xl font-bold tracking-tighter">
{{ goal }}
</div>
<div class="text-[0.70rem] text-muted uppercase">
Calories/day
</div>
</div>
<NButton
btn="solid-gray"
square="8"
icon
label="i-lucide-plus"
rounded="full"
:disabled="goal >= 400"
@click="goal += 10"
/>
</div>
<div class="mt-3 h-[120px] border rounded" />
</div>
<NDrawerFooter>
<NButton>Submit</NButton>
<NDrawerClose as-child>
<NButton btn="solid-gray">
Cancel
</NButton>
</NDrawerClose>
</NDrawerFooter>
</div>
</NDrawerContent>
</NDrawer>
<NDrawer direction="right">
<NDrawerTrigger as-child>
<NButton btn="solid-gray">
Scrollable Content
</NButton>
</NDrawerTrigger>
<NDrawerContent>
<NDrawerHeader>
<NDrawerTitle>Move Goal</NDrawerTitle>
<NDrawerDescription>Set your daily activity goal.</NDrawerDescription>
</NDrawerHeader>
<div class="overflow-y-auto px-4 text-sm">
<h4 class="mb-4 text-lg font-medium leading-none">
Lorem Ipsum
</h4>
<p v-for="index in 10" :key="index" class="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
<NDrawerFooter>
<NButton>Submit</NButton>
<NDrawerClose as-child>
<NButton btn="solid-gray">
Cancel
</NButton>
</NDrawerClose>
</NDrawerFooter>
</NDrawerContent>
</NDrawer>
<NDrawer v-for="direction in directions" :key="direction" :direction>
<NDrawerTrigger as-child>
<NButton btn="solid-gray" class="capitalize">
{{ direction }}
</NButton>
</NDrawerTrigger>
<NDrawerContent>
<NDrawerHeader>
<NDrawerTitle>Move Goal</NDrawerTitle>
<NDrawerDescription>
Set your daily activity goal.
</NDrawerDescription>
</NDrawerHeader>
<div class="overflow-y-auto px-4 text-sm">
<p v-for="index in 10" :key="index" class="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute
irure dolor in reprehenderit in voluptate velit esse cillum
dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</p>
</div>
<NDrawerFooter>
<NButton>Submit</NButton>
<NDrawerClose as-child>
<NButton btn="solid-gray">
Cancel
</NButton>
</NDrawerClose>
</NDrawerFooter>
</NDrawerContent>
</NDrawer>
</div>
</template>
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>
<script setup lang="ts">
import type { NDrawerCloseProps } from '../../types'
import { DrawerClose } from 'vaul-vue'
const props = defineProps<NDrawerCloseProps>()
</script>
<template>
<DrawerClose
data-slot="drawer-close"
v-bind="props"
>
<slot />
</DrawerClose>
</template>
<script setup lang="ts">
import type { DialogContentEmits } from 'reka-ui'
import type { NDrawerContentProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardPropsEmits } from 'reka-ui'
import { DrawerContent, DrawerPortal } from 'vaul-vue'
import { cn } from '../../utils'
import DrawerOverlay from './DrawerOverlay.vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<NDrawerContentProps>(), {
overlay: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, ['class', 'una', '_drawerOverlay', '_drawerClose'])
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DrawerPortal>
<DrawerOverlay
v-if="overlay"
v-bind="_drawerOverlay"
:una
/>
<DrawerContent
data-slot="drawer-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn(
'drawer-content',
'drawer-content-top',
'drawer-content-bottom',
'drawer-content-right',
'drawer-content-left',
'group',
props.una?.drawerContent,
props.class,
)"
>
<div
:class="cn(
'drawer-handle',
props.una?.drawerHandle,
)"
/>
<slot />
</DrawerContent>
</DrawerPortal>
</template>
<script setup lang="ts">
import type { NDrawerDescriptionProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { DrawerDescription } from 'vaul-vue'
import { cn } from '../../utils'
const props = defineProps<NDrawerDescriptionProps>()
const delegatedProps = reactiveOmit(props, ['class'])
</script>
<template>
<DrawerDescription
data-slot="drawer-description"
v-bind="delegatedProps"
:class="cn(
'drawer-description',
props.una?.drawerDescription,
props.class,
)"
>
<slot />
</DrawerDescription>
</template>
<script setup lang="ts">
import type { NDrawerFooterProps } from '../../types'
import { cn } from '../../utils'
const props = defineProps<NDrawerFooterProps>()
</script>
<template>
<div
data-slot="drawer-footer"
:class="cn(
'drawer-footer',
props.una?.drawerFooter,
props.class,
)"
>
<slot />
</div>
</template>
<script setup lang="ts">
import type { NDrawerHeaderProps } from '../../types'
import { cn } from '../../utils'
const props = defineProps<NDrawerHeaderProps>()
</script>
<template>
<div
data-slot="drawer-header"
:class="cn(
'drawer-header',
props.una?.drawerHeader,
props.class,
)"
>
<slot />
</div>
</template>
<script setup lang="ts">
import type { NDrawerOverlayProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { DrawerOverlay } from 'vaul-vue'
import { cn } from '../../utils'
const props = defineProps<NDrawerOverlayProps>()
const delegatedProps = reactiveOmit(props, ['class'])
</script>
<template>
<DrawerOverlay
data-slot="drawer-overlay"
v-bind="delegatedProps"
:class="cn(
'drawer-overlay',
props.una?.drawerOverlay,
props.class,
)"
/>
</template>
<script setup lang="ts">
import type { NDrawerTitleProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { DrawerTitle } from 'vaul-vue'
import { cn } from '../../utils'
const props = defineProps<NDrawerTitleProps>()
const delegatedProps = reactiveOmit(props, ['class'])
</script>
<template>
<DrawerTitle
data-slot="drawer-title"
v-bind="delegatedProps"
:class="cn(
'drawer-title',
props.una?.drawerTitle,
props.class,
)"
>
<slot />
</DrawerTitle>
</template>
<script setup lang="ts">
import type { NDrawerTriggerProps } from '../../types'
import { DrawerTrigger } from 'vaul-vue'
const props = defineProps<NDrawerTriggerProps>()
</script>
<template>
<DrawerTrigger
data-slot="drawer-trigger"
v-bind="props"
>
<slot />
</DrawerTrigger>
</template>