Drawer
A panel that slides in from the edge of the screen. The trigger and close match Button. The popup has no stroke, and the open edge is rounded. The title is Light and the description is Regular.
Usage guidelines
- Drawer extends Dialog. It adds gesture support, snap points, and indent effects. If you don’t need those, use Dialog. A panel that slides in from the edge and doesn’t need gestures is a positioned Dialog.
Anatomy
<UiDrawerProvider>
<UiDrawerIndentBackground />
<UiDrawerIndent>
<UiDrawerRoot>
<UiDrawerTrigger />
<UiDrawerSwipeArea />
<UiDrawerPortal>
<UiDrawerBackdrop />
<UiDrawerViewport>
<UiDrawerPopup>
<UiDrawerContent>
<UiDrawerTitle />
<UiDrawerDescription />
<UiDrawerClose />
</UiDrawerContent>
</UiDrawerPopup>
</UiDrawerViewport>
</UiDrawerPortal>
</UiDrawerRoot>
</UiDrawerIndent>
</UiDrawerProvider>
Drawer supports swipe gestures to dismiss. Set swipeDirection to control which direction dismisses the drawer. UiDrawerContent allows text selection of its children without swipe interference when using a mouse. Add data-swipe-ignore to a descendant to skip swipe dismissal for all input types.
Use UiDrawerVirtualKeyboardProvider when a bottom sheet contains form fields and you want the sheet to react to software keyboards. Drawers without this provider are unaffected.
No drop-shadow and no border. The open edge is rounded.
Examples
State
By default, Drawer is an uncontrolled component that manages its own state.
<UiDrawerRoot>
<UiDrawerTrigger>Open</UiDrawerTrigger>
<UiDrawerPortal>
<UiDrawerViewport>
<UiDrawerPopup>
<UiDrawerContent>
<UiDrawerTitle>Example drawer</UiDrawerTitle>
<UiDrawerClose>Close</UiDrawerClose>
</UiDrawerContent>
</UiDrawerPopup>
</UiDrawerViewport>
</UiDrawerPortal>
</UiDrawerRoot>
Use open / v-model:open if you need to access or control the state. For example, close it after a form submits, or open it from another place in the app.
<script setup>
const open = ref(false)
</script>
<template>
<UiDrawerRoot v-model:open="open" swipe-direction="right">
<UiDrawerTrigger>Open</UiDrawerTrigger>
…
</UiDrawerRoot>
</template>
Position
Positioning is handled by your styles. swipeDirection defaults to "down" for bottom sheets. Use "up", "left", or "right" for other drawer positions. The hero demo above is a right side sheet (swipeDirection="right"). This example is the default bottom sheet.
<UiDrawerRoot swipe-direction="right">
Nested drawers
You can nest drawers within one another. Use [data-nested-drawer-open] and var(--nested-drawers) to style the parent. Child backdrops are not rendered, so the parent stays visible behind the one on top. The parent popup scales with calc(1 - 0.05 * var(--nested-drawers)).
This demo stacks nested drawers so the frontmost sheet stays anchored to the bottom while the ones behind it are scaled down.
Snap points
Use snapPoints to snap a bottom sheet to preset heights. Numbers between 0 and 1 represent fractions of the viewport height, and numbers greater than 1 are treated as pixel values. String values support px and rem units (for example, '148px' or '30rem').
const snapPoints = ['148px', 1]
<UiDrawerRoot
:snap-points="snapPoints"
v-model:snap-point="snapPoint"
>
Apply the snap point offset in your styles when using vertical drawers:
.popup {
transform: translateY(
calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y))
);
}
By default, the drawer can skip snap points when swiping quickly. Set snapToSequentialPoints so drag distance (not velocity) chooses the next point.
Virtual keyboard aware
Wrap a bottom sheet in UiDrawerVirtualKeyboardProvider to make it react to software keyboards when it contains form controls. The provider sets --drawer-keyboard-inset from visualViewport.
- Keep the popup frame stable: place header and footer content outside a scrollable body.
- Lift a pinned footer input with
padding-bottom: var(--drawer-keyboard-inset, 0px). Always include the0pxfallback — the variable is only set while a keyboard is aligned.
<UiDrawerRoot>
<UiDrawerVirtualKeyboardProvider>
…
</UiDrawerVirtualKeyboardProvider>
</UiDrawerRoot>
Indent effect
Scale the background down when any drawer opens by wrapping the tree in UiDrawerProvider and placing UiDrawerIndentBackground + UiDrawerIndent around the page. Any UiDrawerRoot within the provider notifies it when it opens, which activates the indent parts ([data-active]). Portal into the framed container with to so the overlay is clipped to the demo.
Non-modal
Set modal={false} (:modal="false") to opt out of focus trapping and page scroll lock. Combine with disablePointerDismissal to keep the drawer open on outside clicks. There is no backdrop; Close, Escape, or swipe still dismiss.
Mobile navigation
You can build a full-screen mobile navigation sheet using Drawer parts, including flick-to-dismiss from the top. Scroll Area keeps a long component list inside the sheet.
Swipe to open
Place UiDrawerSwipeArea along the edge of the viewport to enable swipe-to-open. The swipe direction defaults to the opposite of Root swipeDirection.
Swipe from the right edge to open the drawer.
Close confirmation
Nest an Alert Dialog inside a controlled Drawer. If the parent would close while the textarea has text, keep the drawer open and show the alert instead. Shown on backdrop click, Escape, Close, or a dismiss swipe.
Action sheet with separate destructive action
An action sheet with a grouped list of actions plus a separate destructive action. Filled actions use .cta / data-cta — never $red.
Detached triggers
For a one-off, put UiDrawerTrigger inside Root. When that is impractical, create a handle with createDrawerHandle() and pass it to both the trigger and the root.
<script setup>
import { createDrawerHandle } from '~/lib/ui/drawer'
const demoDrawer = createDrawerHandle<{ title: string }>()
</script>
<template>
<UiDrawerTrigger :handle="demoDrawer" :payload="{ title: 'Profile' }">
Profile
</UiDrawerTrigger>
<UiDrawerRoot v-slot="{ payload }" :handle="demoDrawer">
…
</UiDrawerRoot>
</template>
The drawer can render different content depending on which trigger opened it. Pass payload on the trigger and read it from Root’s default slot.
Stacking and animations
Use CSS transitions to animate opening, closing, swipe, and nested stacking. data-starting-style is applied when a drawer starts to open, and data-ending-style when it starts to close.
--nested-drawers is the stack depth. The frontmost drawer has index 0.
.popup {
--stack-step: 0.05;
--stack-scale: calc(1 - (var(--nested-drawers) * var(--stack-step)));
transform: translateY(var(--drawer-swipe-movement-y)) scale(var(--stack-scale));
}
data-nested-drawer-open marks drawers behind the frontmost one. Pair it with data-nested-drawer-swiping to show parent content again while the child is being swiped.
.content {
transition: opacity 300ms;
}
.popup[data-nested-drawer-open] .content {
opacity: 0;
}
.popup[data-nested-drawer-open][data-nested-drawer-swiping] .content {
opacity: 1;
}
--drawer-swipe-movement-x, --drawer-swipe-movement-y, and --drawer-snap-point-offset offset the popup while dragging. --drawer-swipe-progress fades the backdrop; --drawer-swipe-strength can scale the release duration.
.backdrop {
--backdrop-opacity: 0.2;
opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress)));
}
.popup[data-ending-style][data-swipe-direction='right'] {
transform: translateX(100%);
}
.popup[data-ending-style][data-swipe-direction='down'] {
transform: translateY(100%);
}
The nested-drawers demo above uses this stacking.
API reference
Root
Groups all parts of the drawer. Renders nothing of its own. Default slot receives { payload, triggerId }.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Uncontrolled initial open. Use open / v-model:open for a controlled drawer. |
open | boolean | — | Controlled open state (v-model:open). |
handle | BaseDrawerHandle | — | Links detached triggers. Create with createDrawerHandle(). |
triggerId | string | null | — | Active trigger id in controlled mode. |
disablePointerDismissal | boolean | false | Do not close on backdrop (outside) press. Escape and Close still work. |
modal | boolean | true | true traps focus and locks page scroll. false allows interaction with the rest of the page. |
swipeDirection | 'up' | 'down' | 'left' | 'right' | 'down' | Edge the drawer sits on, and the direction used to dismiss. |
snapPoints | Array<number | string> | — | Snap heights. 0–1 = viewport fraction; >1 = pixels; strings in px / rem. |
defaultSnapPoint | number | string | null | first point | Uncontrolled initial snap. |
snapPoint | number | string | null | — | Controlled snap (v-model:snap-point). |
snapToSequentialPoints | boolean | false | Choose the next snap by drag distance instead of nearest point. |
| Event | Type | Description |
|---|---|---|
@update:open | (open: boolean) => void | Emitted when the drawer opens or closes. |
@update:snapPoint | (point: number | string | null) => void | Emitted when the active snap point changes. |
Provider
Coordinates indent / background effects for every drawer under it. Renders nothing of its own.
IndentBackground
Layer behind UiDrawerIndent. Receives [data-active] while any descendant drawer is open.
Indent
Scales its children while a descendant drawer is open ([data-active], --drawer-swipe-progress).
Trigger
Button that opens the drawer. Renders a <button>.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | — | Ignore presses. Sets data-disabled. |
handle | BaseDrawerHandle | — | Detached handle. Required when the trigger is outside Root. |
payload | unknown | — | Value exposed on Root’s slot when this trigger opens the drawer. |
id | string | generated | Trigger id. Used with Root triggerId. |
| Attribute | Description |
|---|---|
data-popup-open | Present when this trigger’s drawer is open. |
data-disabled | Present when the trigger is disabled. |
SwipeArea
Edge hit target that opens the drawer when dragged.
| Prop | Type | Default | Description |
|---|---|---|---|
swipeDirection | 'up' | 'down' | 'left' | 'right' | opposite of Root | Direction of the open gesture. |
disabled | boolean | — | Ignore presses. |
VirtualKeyboardProvider
Watches visualViewport and exposes --drawer-keyboard-inset (and keyboardInset on Root) while a software keyboard is open.
Portal
Teleports to body while the overlay is present (useCssPresence). Pass to to portal into a container (indent / swipe demos).
| Prop | Type | Default | Description |
|---|---|---|---|
to | string | HTMLElement | 'body' | Teleport target. |
| Attribute | Description |
|---|---|
data-open | Present when the drawer is open. |
data-closed | Present while present but not open (exit transition). |
data-nested | Present when nested in another overlay. |
data-starting-style | Present while animating in. |
data-ending-style | Present while animating out. |
Backdrop
Covers the viewport. Click dismisses unless Root disablePointerDismissal is set. Nested drawers skip rendering the child backdrop unless forceRender is set. Opacity is 0.2 × (1 - --drawer-swipe-progress).
| Prop | Type | Default | Description |
|---|---|---|---|
forceRender | boolean | — | Render even when nested. |
| Attribute | Description |
|---|---|
data-open | Present when the drawer is open. |
data-closed | Present when the drawer is closed. |
data-starting-style | Present while animating in. |
data-ending-style | Present while animating out. |
data-swiping | Present while the popup is being dragged. |
Viewport
Pins the popup to the swipe edge.
| Attribute | Description |
|---|---|
data-open | Present when the drawer is open. |
data-closed | Present when the drawer is closed. |
data-nested | Present when nested in another overlay. |
data-nested-drawer-open | Present when a nested drawer is open. |
data-starting-style | Present while animating in. |
data-ending-style | Present while animating out. |
data-swipe-direction | 'up' | 'down' | 'left' | 'right'. |
Popup
role="dialog". Escape closes. Focus stays inside until close when modal is true. Enter and leave use data-starting-style / data-ending-style (450ms cubic-bezier(0.32, 0.72, 0, 1)). Drag in the swipe direction to dismiss. No animation library. No drop-shadow.
| Prop | Type | Default | Description |
|---|---|---|---|
initialFocus | HTMLElement | false | null | first tabbable | Element to focus on open. false skips moving focus. |
finalFocus | HTMLElement | false | null | previous element | Element to focus on close. false skips restoring focus. |
| Attribute | Description |
|---|---|
data-open | Present when the drawer is open. |
data-closed | Present when the drawer is closed. |
data-nested | Present when nested in another overlay. |
data-nested-drawer-open | Present when a nested drawer is open. |
data-nested-drawer-swiping | Present while a nested drawer is being dragged. |
data-starting-style | Present while animating in. |
data-ending-style | Present while animating out. |
data-swipe-direction | 'up' | 'down' | 'left' | 'right'. |
data-swiping | Present while this popup is being dragged. |
data-expanded | Present when the active snap is near full height. |
data-has-snap | Present when Root has snapPoints. |
| Variable | Description |
|---|---|
--nested-drawers | How many drawers are nested inside this one. |
--drawer-swipe-movement-x / --drawer-swipe-movement-y | Drag offset in px. |
--drawer-swipe-progress | 0–1 how far the dismiss swipe has traveled. |
--drawer-swipe-strength | Release duration multiplier. |
--drawer-snap-point-offset | Extra translateY for the active snap. |
--drawer-keyboard-inset | Software keyboard overlap, with a 0px fallback. |
--drawer-height | Measured popup height. |
Content
Scrollable body. Mark a child with data-swipe-ignore to skip swipe there. Fades out when [data-nested-drawer-open] unless [data-nested-drawer-swiping].
Title
Heading that labels the drawer. Renders an <h2>.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | generated | Override the label id used by the popup. |
Description
Supporting text. Renders a <p>.
Close
Button that sets open to false. Add class="cta" for a filled action.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | — | Ignore presses. Sets data-disabled. |
| Attribute | Description |
|---|---|
data-disabled | Present when the button is disabled. |
createDrawerHandle
Creates a handle that connects UiDrawerRoot with detached UiDrawerTrigger components.
const handle = createDrawerHandle<{ title: string }>()
handle.show({ title: 'Profile' }, 'drawer-trigger-1')
handle.hide()
| Field | Description |
|---|---|
open | Ref of whether the drawer is open. |
payload | Ref of the active trigger payload. |
triggerId | Ref of the active trigger id. |
show(payload?, triggerId?) | Open the drawer. |
hide() | Close the drawer. |