Usage
Use a Button or any other component in the default slot of the Drawer.
Then, use the #content slot to add the content displayed when the Drawer is open.
<template>
<PDrawer>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="h-48 m-4" />
</template>
</PDrawer>
</template>You can also use the #header, #body and #footer slots to customize the Drawer's content.
Title
Use the title prop to set the title of the Drawer's header.
<template>
<PDrawer title="Drawer with title">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="h-48" />
</template>
</PDrawer>
</template>Description
Use the description prop to set the description of the Drawer's header.
<template>
<PDrawer
title="Drawer with description"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="h-48" />
</template>
</PDrawer>
</template>Close
Use the close prop to display a close button in the Drawer. Defaults to false.
You can pass any property from the Button component to customize it.
<template>
<PDrawer
title="Drawer with close button"
:close="{
color: 'primary',
variant: 'outline',
class: 'rounded-full'
}"
>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="h-48" />
</template>
</PDrawer>
</template>Close Icon
Use the close-icon prop to customize the close button Icon. Defaults to i-lucide-x.
<template>
<PDrawer title="Drawer with close button" close close-icon="i-lucide-arrow-right">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="h-48" />
</template>
</PDrawer>
</template>Direction
Use the direction prop to control the direction of the Drawer. Defaults to bottom.
<template>
<PDrawer direction="right">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="min-w-96 min-h-96 size-full m-4" />
</template>
</PDrawer>
</template>Inset
Use the inset prop to inset the Drawer from the edges.
<template>
<PDrawer direction="right" inset>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="min-w-96 min-h-96 size-full m-4" />
</template>
</PDrawer>
</template>Handle
Use the handle prop to control whether the Drawer has a handle or not. Defaults to true.
<template>
<PDrawer :handle="false">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="h-48 m-4" />
</template>
</PDrawer>
</template>Overlay
Use the overlay prop to control whether the Drawer has an overlay or not. Defaults to true.
<template>
<PDrawer :overlay="false">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="h-48 m-4" />
</template>
</PDrawer>
</template>Modal
Use the modal prop to control whether the Drawer blocks interaction with outside content. Defaults to true. Set it to 'trap-focus' to trap keyboard focus while allowing outside pointer interaction, without a backdrop or body scroll lock.
modal is set to false, the overlay is automatically disabled and outside content becomes interactive.<template>
<PDrawer>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="h-48 m-4" />
</template>
</PDrawer>
</template>Dismissible
Use the dismissible prop to control whether the Drawer is dismissible when swiping, clicking outside of it, or pressing Escape. Defaults to true.
close:prevent event will be emitted when the user tries to close it.modal: false with dismissible: false to make the Drawer's background interactive without closing it.<script setup lang="ts">
const open = ref(false)
</script>
<template>
<PDrawer
v-model:open="open"
title="Drawer non-dismissible"
close
:dismissible="false"
:modal="false"
:handle="false"
>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="size-full min-h-48" />
</template>
</PDrawer>
</template><script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<PDrawer
v-model:open="open"
title="Drawer non-dismissible"
close
:dismissible="false"
:modal="false"
:handle="false"
>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="size-full min-h-48" />
</template>
</PDrawer>
</template>Scale Background Soon
Wrap page content in DrawerProvider with scale to scale it whenever a descendant Drawer is open. Leave the Drawer's portal enabled so the drawer renders outside the scaled content.
<template>
<PApp>
<PDrawerProvider
scale
class="min-h-dvh"
:ui="{ content: 'min-h-dvh' }"
>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</PDrawerProvider>
</PApp>
</template>
Snap Points Soon
Use snap-points to define snap positions, default-snap-point for the initial uncontrolled position, or v-model:snap-point to control it. Enable snap-to-sequential-points to advance one position at a time.
<PDrawer :snap-points="[0.5, 1]" :default-snap-point="0.5" snap-to-sequential-points title="Snap points">
<PButton label="Open" />
<template #body>
<Placeholder class="h-screen w-full" />
</template>
</PDrawer>
Numeric fractions use the viewport height; numbers greater than 1 are pixels. Strings support px and rem.
Migrating from Vaul Soon
Drawer now uses Akar. Replace activeSnapPoint and its update event with snapPoint / update:snapPoint. Nesting is automatic; remove nested. Replace the Vaul wrapper attribute and scaling props with PDrawerProvider scale.
The Vaul-only props handleOnly, closeThreshold, scrollLockTimeout, fixed, noBodyStyles, and preventScrollRestoration have been removed. The handle is now a visual swipe affordance; it no longer cycles snap points on click. Use after:enter, after:leave, or update:openComplete instead of animationEnd; drag, release, and close events are no longer exposed.
update:open receives (open, details), where details.reason describes the interaction and details.cancel() rejects it. content now uses Akar's drawer-specific types, including initialFocus and finalFocus.
Examples
Control open state
You can control the open state by using the default-open prop or the v-model:open directive.
<script setup lang="ts">
const open = ref(false)
defineShortcuts({
o: () => (open.value = !open.value)
})
</script>
<template>
<PDrawer v-model:open="open">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="m-4 h-48" />
</template>
</PDrawer>
</template><script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
defineShortcuts({
o: () => (open.value = !open.value)
})
</script>
<template>
<PDrawer v-model:open="open">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #content>
<Placeholder class="m-4 h-48" />
</template>
</PDrawer>
</template>defineShortcuts, you can toggle the Drawer by pressing O.Responsive drawer
You can render a Modal component on desktop and a Drawer on mobile for example.
<script lang="ts" setup>
import { createReusableTemplate, useMediaQuery } from '@vueuse/core'
const [DefineFormTemplate, ReuseFormTemplate] = createReusableTemplate()
const isDesktop = useMediaQuery('(min-width: 768px)')
const open = ref(false)
const state = reactive({
email: undefined
})
const title = 'Edit profile'
const description = "Make changes to your profile here. Click save when you're done."
</script>
<template>
<DefineFormTemplate>
<PForm :state="state" class="space-y-4">
<PFormField label="Email" name="email" required>
<PInput v-model="state.email" placeholder="you@example.com" required />
</PFormField>
<PButton label="Save changes" type="submit" />
</PForm>
</DefineFormTemplate>
<PModal v-if="isDesktop" v-model:open="open" :title="title" :description="description">
<PButton label="Edit profile" color="neutral" variant="outline" />
<template #body>
<ReuseFormTemplate />
</template>
</PModal>
<PDrawer v-else v-model:open="open" :title="title" :description="description">
<PButton label="Edit profile" color="neutral" variant="outline" />
<template #body>
<ReuseFormTemplate />
</template>
</PDrawer>
</template>Nested drawers
Drawers nested inside another Drawer’s content are detected automatically; no nested prop is needed.
<template>
<PDrawer :ui="{ content: 'h-full', overlay: 'bg-background-inverted/30' }">
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #footer>
<PDrawer :ui="{ content: 'h-full', overlay: 'bg-background-inverted/30' }">
<PButton color="neutral" variant="outline" label="Open nested" />
<template #content>
<Placeholder class="m-4 flex-1" />
</template>
</PDrawer>
</template>
</PDrawer>
</template>With footer slot
Use the #footer slot to add content after the Drawer's body.
<script setup lang="ts">
const open = ref(false)
</script>
<template>
<PDrawer
v-model:open="open"
title="Drawer with footer"
description="This is useful when you want a form in a Drawer."
:ui="{ container: 'max-w-xl mx-auto' }"
>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="h-48" />
</template>
<template #footer>
<PButton label="Submit" color="neutral" class="justify-center" />
<PButton
label="Cancel"
color="neutral"
variant="outline"
class="justify-center"
@click="open = false"
/>
</template>
</PDrawer>
</template><script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<PDrawer
v-model:open="open"
title="Drawer with footer"
description="This is useful when you want a form in a Drawer."
:ui="{ container: 'max-w-xl mx-auto' }"
>
<PButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
<template #body>
<Placeholder class="h-48" />
</template>
<template #footer>
<PButton label="Submit" color="neutral" class="justify-center" />
<PButton
label="Cancel"
color="neutral"
variant="outline"
class="justify-center"
@click="open = false"
/>
</template>
</PDrawer>
</template>With command palette
You can use a CommandPalette component inside the Drawer's content.
<script setup lang="ts">
const searchTerm = ref('');
const { data: users, status, execute } = await useLazyFetch('https://jsonplaceholder.typicode.com/users', {
key: 'drawer-command-palette-users',
query: { q: searchTerm },
transform: (data: Array<{ id: number; name: string; email: string }>) => {
return data?.map((user) => ({ id: user.id, label: user.name, suffix: user.email, avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, loading: 'lazy' as const } })) || [];
},
immediate: false,
});
const groups = computed(() => [{
id: 'users',
label: searchTerm.value ? `Users matching “${searchTerm.value}”...` : 'Users',
items: users.value || [],
ignoreFilter: true,
}]);
function onOpen() {
if (!users.value?.length) {
execute();
}
}
</script>
<template>
<PDrawer
:handle="false"
@update:open="onOpen"
>
<PButton
label="Search users..."
color="neutral"
variant="subtle"
icon="i-lucide-search"
/>
<template #content>
<PCommandPalette
v-model:search-term="searchTerm"
:loading="status === 'pending'"
:groups="groups"
placeholder="Search users..."
class="h-80"
/>
</template>
</PDrawer>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const searchTerm = ref('');
const { data: users, status, execute } = await useLazyFetch('https://jsonplaceholder.typicode.com/users', {
key: 'drawer-command-palette-users',
query: { q: searchTerm },
transform: (data: Array<{ id: number; name: string; email: string }>) => {
return data?.map((user) => ({ id: user.id, label: user.name, suffix: user.email, avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, loading: 'lazy' as const } })) || [];
},
immediate: false,
});
const groups = computed(() => [{
id: 'users',
label: searchTerm.value ? `Users matching “${searchTerm.value}”...` : 'Users',
items: users.value || [],
ignoreFilter: true,
}]);
function onOpen() {
if (!users.value?.length) {
execute();
}
}
</script>
<template>
<PDrawer
:handle="false"
@update:open="onOpen"
>
<PButton
label="Search users..."
color="neutral"
variant="subtle"
icon="i-lucide-search"
/>
<template #content>
<PCommandPalette
v-model:search-term="searchTerm"
:loading="status === 'pending'"
:groups="groups"
placeholder="Search users..."
class="h-80"
/>
</template>
</PDrawer>
</template>
useLazyFetch with immediate: false to only fetch data when the Drawer opens.API
Props
| Prop | Default | Type |
|---|---|---|
as | 'div' | anyThe element or component this component should render as. |
direction | 'bottom' | "top" | "right" | "bottom" | "left"The edge from which the drawer opens. |
dismissible | true | boolean Allow dismissal by swiping, pressing Escape, or interacting outside. |
transition | true | boolean Animate the drawer and overlay. |
title | string | |
description | string | |
inset | false | boolean Whether to inset the drawer from the edges. |
content | DrawerContentProps & Partial<EmitsToProps<DrawerContentImplEmits>>The content of the drawer. | |
overlay | true | boolean Render an overlay behind the drawer. |
handle | true | boolean Render a handle on the drawer. |
portal | true | string | false | true | HTMLElementRender the drawer in a portal. |
close | false | boolean | Omit<ButtonProps, LinkPropsKeys> Display a close button to dismiss the drawer.
|
closeIcon | appConfig.ui.icons.close | anyThe icon displayed in the close button. |
open | boolean v-model:open | |
defaultOpen | boolean | |
modal | false | true | "trap-focus"Modality of the drawer.
| |
snapPoints | DrawerSnapPoint[]Preset snap positions (fractions 0-1, pixels >1, or '148px'/'30rem' strings) | |
snapPoint | null | string | numberv-model:snapPoint | |
defaultSnapPoint | null | string | number | |
snapToSequentialPoints | boolean When true, snaps to the next sequential snap point (one step at a time). When false, snaps to the nearest snap point by distance. | |
ui | { overlay?: SlotClass; content?: SlotClass; handle?: SlotClass; container?: SlotClass; header?: SlotClass; wrapper?: SlotClass; title?: SlotClass; description?: SlotClass; actions?: SlotClass; body?: SlotClass; footer?: SlotClass; close?: SlotClass; } |
Slots
| Slot | Type |
|---|---|
default | {} |
content | {} |
header | {} |
title | {} |
description | {} |
actions | {} |
close | { ui: object; } |
body | {} |
footer | {} |
Emits
| Event | Type |
|---|---|
after:leave | [] |
after:enter | [] |
close:prevent | [] |
update:open | [value: boolean, details: DrawerOpenChangeDetails] |
update:openComplete | [value: boolean] |
update:snapPoint | [value: string | number | null] |
Theme
export default defineAppConfig({
ui: {
drawer: {
slots: {
overlay: 'pohon-drawer-overlay fixed inset-0 bg-background-elevated/75',
content: 'pohon-drawer-content fixed bg-background ring ring-border flex min-h-0 focus:outline-none',
handle: [
'bg-background-accented rounded-full shrink-0',
'transition-opacity ease-out'
],
container: 'w-full min-h-0 flex flex-col gap-4 p-4 overflow-y-auto overscroll-contain',
header: 'flex items-center gap-1.5 min-h-8',
wrapper: 'min-w-0 flex-1',
title: 'color-text-highlighted font-600',
description: 'mt-1 color-text-muted text-sm',
actions: 'flex items-center gap-1.5 shrink-0 ms-auto',
body: 'flex-1',
footer: 'flex flex-col gap-1.5',
close: ''
},
variants: {
direction: {
top: {
content: 'mb-24 flex-col-reverse',
handle: 'mb-4'
},
right: {
content: 'flex-row rtl:flex-row-reverse',
handle: 'ml-4'
},
bottom: {
content: 'mt-24 flex-col',
handle: 'mt-4'
},
left: {
content: 'flex-row-reverse rtl:flex-row',
handle: 'mr-4'
}
},
inset: {
true: {
content: 'rounded-lg after:hidden overflow-hidden'
}
},
transition: {
true: {
content: 'pohon-drawer-transition',
overlay: 'pohon-drawer-transition'
}
},
snapPoints: {
true: ''
}
},
compoundVariants: [
{
direction: [
'top',
'bottom'
],
class: {
content: 'h-auto max-h-[96%]',
handle: 'w-12 h-1.5 mx-auto'
}
},
{
direction: [
'top',
'bottom'
],
snapPoints: true,
class: {
content: 'h-full'
}
},
{
direction: [
'right',
'left'
],
class: {
content: 'w-auto max-w-[calc(100%-2rem)]',
handle: 'h-12 w-1.5 my-auto'
}
},
{
direction: [
'right',
'left'
],
snapPoints: true,
class: {
content: 'w-full'
}
},
{
direction: 'top',
inset: true,
class: {
content: 'inset-x-4 top-4'
}
},
{
direction: 'top',
inset: false,
class: {
content: 'inset-x-0 top-0 rounded-b-lg'
}
},
{
direction: 'bottom',
inset: true,
class: {
content: 'inset-x-4 bottom-4'
}
},
{
direction: 'bottom',
inset: false,
class: {
content: 'inset-x-0 bottom-0 rounded-t-lg'
}
},
{
direction: 'left',
inset: true,
class: {
content: 'inset-y-4 left-4'
}
},
{
direction: 'left',
inset: false,
class: {
content: 'inset-y-0 left-0 rounded-r-lg'
}
},
{
direction: 'right',
inset: true,
class: {
content: 'inset-y-4 right-4'
}
},
{
direction: 'right',
inset: false,
class: {
content: 'inset-y-0 right-0 rounded-l-lg'
}
}
]
}
}
})import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
drawer: {
slots: {
overlay: 'pohon-drawer-overlay fixed inset-0 bg-background-elevated/75',
content: 'pohon-drawer-content fixed bg-background ring ring-border flex min-h-0 focus:outline-none',
handle: [
'bg-background-accented rounded-full shrink-0',
'transition-opacity ease-out'
],
container: 'w-full min-h-0 flex flex-col gap-4 p-4 overflow-y-auto overscroll-contain',
header: 'flex items-center gap-1.5 min-h-8',
wrapper: 'min-w-0 flex-1',
title: 'color-text-highlighted font-600',
description: 'mt-1 color-text-muted text-sm',
actions: 'flex items-center gap-1.5 shrink-0 ms-auto',
body: 'flex-1',
footer: 'flex flex-col gap-1.5',
close: ''
},
variants: {
direction: {
top: {
content: 'mb-24 flex-col-reverse',
handle: 'mb-4'
},
right: {
content: 'flex-row rtl:flex-row-reverse',
handle: 'ml-4'
},
bottom: {
content: 'mt-24 flex-col',
handle: 'mt-4'
},
left: {
content: 'flex-row-reverse rtl:flex-row',
handle: 'mr-4'
}
},
inset: {
true: {
content: 'rounded-lg after:hidden overflow-hidden'
}
},
transition: {
true: {
content: 'pohon-drawer-transition',
overlay: 'pohon-drawer-transition'
}
},
snapPoints: {
true: ''
}
},
compoundVariants: [
{
direction: [
'top',
'bottom'
],
class: {
content: 'h-auto max-h-[96%]',
handle: 'w-12 h-1.5 mx-auto'
}
},
{
direction: [
'top',
'bottom'
],
snapPoints: true,
class: {
content: 'h-full'
}
},
{
direction: [
'right',
'left'
],
class: {
content: 'w-auto max-w-[calc(100%-2rem)]',
handle: 'h-12 w-1.5 my-auto'
}
},
{
direction: [
'right',
'left'
],
snapPoints: true,
class: {
content: 'w-full'
}
},
{
direction: 'top',
inset: true,
class: {
content: 'inset-x-4 top-4'
}
},
{
direction: 'top',
inset: false,
class: {
content: 'inset-x-0 top-0 rounded-b-lg'
}
},
{
direction: 'bottom',
inset: true,
class: {
content: 'inset-x-4 bottom-4'
}
},
{
direction: 'bottom',
inset: false,
class: {
content: 'inset-x-0 bottom-0 rounded-t-lg'
}
},
{
direction: 'left',
inset: true,
class: {
content: 'inset-y-4 left-4'
}
},
{
direction: 'left',
inset: false,
class: {
content: 'inset-y-0 left-0 rounded-r-lg'
}
},
{
direction: 'right',
inset: true,
class: {
content: 'inset-y-4 right-4'
}
},
{
direction: 'right',
inset: false,
class: {
content: 'inset-y-0 right-0 rounded-l-lg'
}
}
]
}
}
})
]
})