Drawer

DrawerGitHub
A drawer that smoothly slides in and out of the screen.

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>

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.

When 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.

A close:prevent event will be emitted when the user tries to close it.
You can combine 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.

app.vue
<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>
In this example, leveraging defineShortcuts, you can toggle the Drawer by pressing O.
This lets you move the trigger outside of the Drawer or remove it entirely.

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>

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>
This example uses useLazyFetch with immediate: false to only fetch data when the Drawer opens.

API

Props

Prop Default Type
as
'div'
any

The 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 | HTMLElement

Render the drawer in a portal.

close
false
boolean | Omit<ButtonProps, LinkPropsKeys>

Display a close button to dismiss the drawer. { size: 'md', color: 'neutral', variant: 'ghost' }

closeIcon
appConfig.ui.icons.close
any

The icon displayed in the close button.

open
boolean

v-model:open

defaultOpen
boolean
modal
false | true | "trap-focus"

Modality of the drawer.

  • true (default): modal with focus trap, scroll lock, and outside-press dismiss
  • 'trap-focus': traps focus but allows outside pointer events (non-modal side panels)
  • false: non-modal
snapPoints
DrawerSnapPoint[]

Preset snap positions (fractions 0-1, pixels >1, or '148px'/'30rem' strings)

snapPoint
null | string | number

v-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

app.config.ts
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'
          }
        }
      ]
    }
  }
})
vite.config.ts
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'
              }
            }
          ]
        }
      }
    })
  ]
})

Changelog

No recent changes