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 the 0px fallback — 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 here

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

PropTypeDefaultDescription
defaultOpenbooleanfalseUncontrolled initial open. Use open / v-model:open for a controlled drawer.
openboolean—Controlled open state (v-model:open).
handleBaseDrawerHandle—Links detached triggers. Create with createDrawerHandle().
triggerIdstring | null—Active trigger id in controlled mode.
disablePointerDismissalbooleanfalseDo not close on backdrop (outside) press. Escape and Close still work.
modalbooleantruetrue 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.
snapPointsArray<number | string>—Snap heights. 0–1 = viewport fraction; >1 = pixels; strings in px / rem.
defaultSnapPointnumber | string | nullfirst pointUncontrolled initial snap.
snapPointnumber | string | null—Controlled snap (v-model:snap-point).
snapToSequentialPointsbooleanfalseChoose the next snap by drag distance instead of nearest point.
EventTypeDescription
@update:open(open: boolean) => voidEmitted when the drawer opens or closes.
@update:snapPoint(point: number | string | null) => voidEmitted 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>.

PropTypeDefaultDescription
disabledboolean—Ignore presses. Sets data-disabled.
handleBaseDrawerHandle—Detached handle. Required when the trigger is outside Root.
payloadunknown—Value exposed on Root’s slot when this trigger opens the drawer.
idstringgeneratedTrigger id. Used with Root triggerId.
AttributeDescription
data-popup-openPresent when this trigger’s drawer is open.
data-disabledPresent when the trigger is disabled.

SwipeArea

Edge hit target that opens the drawer when dragged.

PropTypeDefaultDescription
swipeDirection'up' | 'down' | 'left' | 'right'opposite of RootDirection of the open gesture.
disabledboolean—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).

PropTypeDefaultDescription
tostring | HTMLElement'body'Teleport target.
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent while present but not open (exit transition).
data-nestedPresent when nested in another overlay.
data-starting-stylePresent while animating in.
data-ending-stylePresent 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).

PropTypeDefaultDescription
forceRenderboolean—Render even when nested.
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-starting-stylePresent while animating in.
data-ending-stylePresent while animating out.
data-swipingPresent while the popup is being dragged.

Viewport

Pins the popup to the swipe edge.

AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-nestedPresent when nested in another overlay.
data-nested-drawer-openPresent when a nested drawer is open.
data-starting-stylePresent while animating in.
data-ending-stylePresent while animating out.
data-swipe-direction'up' | 'down' | 'left' | 'right'.

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.

PropTypeDefaultDescription
initialFocusHTMLElement | false | nullfirst tabbableElement to focus on open. false skips moving focus.
finalFocusHTMLElement | false | nullprevious elementElement to focus on close. false skips restoring focus.
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-nestedPresent when nested in another overlay.
data-nested-drawer-openPresent when a nested drawer is open.
data-nested-drawer-swipingPresent while a nested drawer is being dragged.
data-starting-stylePresent while animating in.
data-ending-stylePresent while animating out.
data-swipe-direction'up' | 'down' | 'left' | 'right'.
data-swipingPresent while this popup is being dragged.
data-expandedPresent when the active snap is near full height.
data-has-snapPresent when Root has snapPoints.
VariableDescription
--nested-drawersHow many drawers are nested inside this one.
--drawer-swipe-movement-x / --drawer-swipe-movement-yDrag offset in px.
--drawer-swipe-progress0–1 how far the dismiss swipe has traveled.
--drawer-swipe-strengthRelease duration multiplier.
--drawer-snap-point-offsetExtra translateY for the active snap.
--drawer-keyboard-insetSoftware keyboard overlap, with a 0px fallback.
--drawer-heightMeasured 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>.

PropTypeDefaultDescription
idstringgeneratedOverride 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.

PropTypeDefaultDescription
disabledboolean—Ignore presses. Sets data-disabled.
AttributeDescription
data-disabledPresent 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()
FieldDescription
openRef of whether the drawer is open.
payloadRef of the active trigger payload.
triggerIdRef of the active trigger id.
show(payload?, triggerId?)Open the drawer.
hide()Close the drawer.