Reka UI logoReka
backdrop
Components

Drawer

Alpha
A panel that slides in from the edge of the screen, with support for swipe-to-dismiss, snap points, and nested drawers.

Features

  • Slides in from any edge — bottom, top, left or right.
  • Swipe-to-dismiss with momentum, plus optional swipe-to-open.
  • Supports snap points for partially-open resting positions.
  • Modal, focus-trapped non-modal, and fully non-modal modes.
  • Can be controlled or uncontrolled.
  • Manages screen reader announcements with Title and Description components.
  • Esc closes the component automatically.

Installation

Install the component from your command line.

sh
$ npm add reka-ui

Anatomy

Import all parts and piece them together.

vue
<script setup>
import {
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerHandle,
  DrawerOverlay,
  DrawerPortal,
  DrawerRoot,
  DrawerTitle,
  DrawerTrigger,
} from 'reka-ui'
</script>

<template>
  <DrawerRoot>
    <DrawerTrigger />
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>
        <DrawerHandle />
        <DrawerTitle />
        <DrawerDescription />
        <DrawerClose />
      </DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

Animating the drawer

The Drawer is unstyled, so the enter/exit transitions and the swipe gesture are driven entirely by your CSS. DrawerContent exposes the drag offset through CSS custom properties so you can wire up the live transform:

  • --drawer-swipe-movement-y — vertical drag offset (for top/bottom drawers)
  • --drawer-swipe-movement-x — horizontal drag offset (for left/right drawers)
  • --drawer-snap-point-offset — offset of the active snap point, when snap points are used
  • --drawer-swipe-progress — 0 at rest, approaching 1 as the drawer is swiped away
css
.DrawerContent {
  /* Follow the pointer while dragging. */
  transform: translateY(var(--drawer-swipe-movement-y, 0px));
  transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1);
}

/* Use the independent `translate` property for the enter/exit keyframes so they
   compose with the `transform` above instead of clobbering the drag offset. */
.DrawerContent[data-state='open'] {
  animation: slideIn 450ms cubic-bezier(0.32, 0.72, 0, 1);
}
.DrawerContent[data-state='closed'] {
  animation: slideOut 450ms cubic-bezier(0.32, 0.72, 0, 1);
}

/* Cut the transition while actively dragging so the drawer tracks the pointer. */
.DrawerContent[data-swiping] {
  transition-duration: 0ms;
}

@keyframes slideIn { from { translate: 0 100%; } }
@keyframes slideOut { to { translate: 0 100%; } }

Bleeding past the edge

Dragging a drawer away from the edge it is anchored to doesn't dismiss it — the gesture is damped and the drawer rubber-bands back on release. Without extra room the drawer lifts clean off the edge during that pull and the overlay shows through the gap.

Give the drawer some bleed: extra area past its anchored edge, held off-screen by an equal negative margin. Pulling the drawer now slides the bleed into view instead of the overlay, so it reads as a stretch rather than a detachment.

css
.DrawerContent {
  --bleed: 48px;
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--bleed));
  margin-bottom: calc(-1 * var(--bleed));
}

/* The bleed already sits below the viewport edge, so the drawer only has to
   travel `height - bleed` to clear it. */
@keyframes slideIn { from { translate: 0 calc(100% - var(--bleed)); } }
@keyframes slideOut { to { translate: 0 calc(100% - var(--bleed)); } }

For a side drawer, swap the axis and point the bleed at the edge the drawer is anchored to — a right-anchored drawer bleeds right, a left-anchored one bleeds left:

css
/* Anchored right */
.DrawerContent {
  padding-inline-end: calc(24px + var(--bleed));
  margin-inline-end: calc(-1 * var(--bleed));
}
@keyframes slideIn { from { translate: calc(100% - var(--bleed)) 0; } }

/* Anchored left */
.DrawerContent {
  padding-inline-start: calc(24px + var(--bleed));
  margin-inline-start: calc(-1 * var(--bleed));
}
@keyframes slideIn { from { translate: calc(-100% + var(--bleed)) 0; } }

Soft keyboard

On mobile the software keyboard slides over the bottom of the viewport without resizing it, so a bottom-anchored drawer — and the field the user just tapped — end up underneath it.

Wrap the drawer in DrawerVirtualKeyboardProvider to make it react. It requires a DrawerViewport around DrawerContent: the viewport is the measurement root and hosts the keyboard variable, which inherits into the popup and everything in it.

While a field inside the drawer is focused, the provider scrolls it into the band left visible above the keyboard and publishes the keyboard's height as --drawer-keyboard-inset on the viewport.

  • Keep the popup frame stable — put header and footer content outside a plain scrollable body so only the body moves.
  • Lift a pinned footer — offset it by var(--drawer-keyboard-inset, 0px), either as padding or as part of its height.
  • Always include the 0px fallback — the variable is only set while the keyboard is aligned, so a bare var(--drawer-keyboard-inset) is invalid before the first alignment and after cleanup.
css
.DrawerFooter {
  padding-bottom: calc(1rem + var(--drawer-keyboard-inset, 0px));
}

To lift the whole sheet instead, give up the keyboard's height so its top stays on screen:

css
.DrawerContent {
  height: calc(90dvh - var(--drawer-keyboard-inset, 0px));
  max-height: calc(90dvh - var(--drawer-keyboard-inset, 0px));
}

Alignment is suspended while the viewport is pinch-zoomed, and viewport changes under 60px are treated as browser chrome rather than the keyboard.

API Reference

Root

Contains all the parts of a drawer. Manages open state, modality, swipe direction and snap points, and provides the context consumed by every other part.

Trigger

The button that opens the drawer.

Data AttributeValue
[data-state]"open" | "closed"

Portal

When used, portals your overlay and content parts into the body.

Overlay

A layer that covers the inert portion of the view when the drawer is open. Only rendered when the drawer is modal.

tip
Built with Presence component - supports any animation techniques while maintaining access to presence emitted events.
Data AttributeValue
[data-state]"open" | "closed"
[data-swipe-direction]"up" | "down" | "left" | "right"
[data-swiping]"Present when the drawer is being dragged"

Content

Contains the content to be rendered in the open drawer. Owns the swipe gesture and exposes the drag offset through CSS custom properties (see Animating the drawer). Also aliased as DrawerPopup for Base UI parity.

tip
Built with Presence component - supports any animation techniques while maintaining access to presence emitted events.
Data AttributeValue
[data-state]"open" | "closed"
[data-swipe-direction]"up" | "down" | "left" | "right"
[data-swiping]"Present while the drawer is being dragged"
[data-nested-drawer-open]"Present when a nested drawer is open"

Close

The button that closes the drawer.

Title

An accessible title to be announced when the drawer is opened.

If you want to hide the title, wrap it inside our Visually Hidden utility like this <VisuallyHidden asChild>.

Description

An optional accessible description to be announced when the drawer is opened.

If you want to hide the description, wrap it inside our Visually Hidden utility like this <VisuallyHidden asChild>. If you want to remove the description entirely, remove this part and pass :aria-describedby="undefined" to DrawerContent.

Handle

A visual grab handle that hints the drawer can be dragged. It is purely decorative (aria-hidden) — the whole content is draggable regardless.

Data AttributeValue
[data-state]"open" | "closed"

SwipeArea

An off-screen edge area that lets users swipe the drawer open. By default it listens on the opposite side of the Root's swipeDirection.

Data AttributeValue
[data-state]"open" | "closed"
[data-swipe-direction]"up" | "down" | "left" | "right"

Viewport

An optional wrapper around DrawerContent. Mirrors Base UI's Drawer.Viewport and carries a data-drawer-viewport attribute for downstream selectors. Required when using VirtualKeyboardProvider, which measures the keyboard against it.

VirtualKeyboardProvider

Makes the drawer react to the software keyboard: it keeps the focused field visible and exposes --drawer-keyboard-inset on DrawerViewport, which it requires (see Soft keyboard). Takes no props and renders no markup of its own.

Indent

Wraps page content that should visually shift (scale/indent) as the drawer is swiped, mimicking the native iOS "card stack" effect. Reads the visual state from a parent DrawerProvider and syncs the --drawer-swipe-progress and --drawer-height CSS variables onto its element.

Data AttributeValue
[data-active]"Present when a drawer is open"
[data-inactive]"Present when no drawer is open"

IndentBackground

The backdrop layer revealed behind an indented page (typically a solid colour that peeks out as the page scales down). Companion to Indent.

Data AttributeValue
[data-active]"Present when a drawer is open"
[data-inactive]"Present when no drawer is open"

Examples

Choosing a side

DrawerRoot slides up from the bottom by default. Set swipeDirection to change the edge the drawer attaches to and the direction users swipe to dismiss it.

vue
<template>
  <DrawerRoot swipe-direction="right">
    <DrawerTrigger>Open</DrawerTrigger>
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>...</DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>
css
/* A right-anchored drawer slides along the X axis instead. */
.DrawerContent {
  position: fixed;
  inset-block: 0;
  right: 0;
  width: 20rem;
  transform: translateX(var(--drawer-swipe-movement-x, 0px));
  transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1);
}
.DrawerContent[data-state='open'] { animation: slideInRight 450ms cubic-bezier(0.32, 0.72, 0, 1); }
.DrawerContent[data-state='closed'] { animation: slideOutRight 450ms cubic-bezier(0.32, 0.72, 0, 1); }
.DrawerContent[data-swiping] { transition-duration: 0ms; }

@keyframes slideInRight { from { translate: 100% 0; } }
@keyframes slideOutRight { to { translate: 100% 0; } }

Where a swipe can start

A touch swipe can start anywhere on the drawer, including on buttons, links and labels. A tap that doesn't move past the drag threshold still registers as a click. Mouse and pen swipes skip interactive elements so that clicks and text selection keep working.

Add data-reka-swipe-ignore to an element to stop swipes from starting on it with any input type. Use it for controls that handle their own drag gestures.

vue
<template>
  <DrawerContent>
    <nav>...</nav>
    <div data-reka-swipe-ignore>
      <!-- A carousel, map, or signature pad -->
    </div>
  </DrawerContent>
</template>

Snap points

Provide snapPoints to give the drawer intermediate resting positions. Each point is a fraction (0–1), a pixel value (> 1), or a string like '148px' / '30rem'. Use v-model:snapPoint to read or control the active one.

vue
<script setup>
import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'reka-ui'
import { ref } from 'vue'

const snapPoints = [0.4, 0.75, 1]
const activeSnapPoint = ref(0.4)
</script>

<template>
  <DrawerRoot
    v-model:snap-point="activeSnapPoint"
    :snap-points="snapPoints"
  >
    <DrawerTrigger>Open</DrawerTrigger>
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>...</DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

Non-modal drawer

By default the drawer is modal: it traps focus, locks scroll, and dismisses on outside press. Set modal to 'trap-focus' to keep the focus trap while still allowing interaction with the rest of the page (no overlay is rendered), or to false for a fully non-modal panel.

vue
<template>
  <DrawerRoot :modal="false">
    <DrawerTrigger>Open</DrawerTrigger>
    <DrawerPortal>
      <DrawerContent>...</DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

Reacting to why the drawer closed

The update:open event carries a details object whose reason tells you what triggered the change — useful for distinguishing a deliberate close from a swipe.

vue
<script setup>
import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'reka-ui'

function onOpenChange(open, details) {
  if (!open && details.reason === 'swipe') {
    // user flicked the drawer away
  }
}
</script>

<template>
  <DrawerRoot @update:open="onOpenChange">
    <DrawerTrigger>Open</DrawerTrigger>
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>...</DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

Possible reasons are swipe, escape-key, outside-press, click, cancel, trigger-press and close-press.

Preventing the drawer from closing

Call details.cancel() in update:open to keep the drawer in its current state. This works for every reason, including a swipe: the drawer settles back open, or back to its snap point.

vue
<script setup>
import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'reka-ui'

function onOpenChange(open, details) {
  if (!open && hasUnsavedChanges()) {
    details.cancel()
    showDiscardConfirmation()
  }
}
</script>

<template>
  <DrawerRoot @update:open="onOpenChange">
    <DrawerTrigger>Open</DrawerTrigger>
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>...</DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

v-model:open always writes the new value, even after details.cancel(). To guard a controlled drawer, bind :open and update your state only when the change wasn't canceled:

vue
<script setup>
import { ref } from 'vue'

const open = ref(false)

function onOpenChange(value, details) {
  if (!value && hasUnsavedChanges()) {
    details.cancel()
    showDiscardConfirmation()
    return
  }
  open.value = value
}
</script>

<template>
  <DrawerRoot :open="open" @update:open="onOpenChange">
    ...
  </DrawerRoot>
</template>

A controlled drawer that leaves open unchanged also settles back after a swipe.

Close using slot props

DrawerRoot exposes open and close via its default slot, so you can close the drawer programmatically from anywhere inside it.

vue
<template>
  <DrawerRoot v-slot="{ close }">
    <DrawerTrigger>Open</DrawerTrigger>
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>
        <form @submit.prevent="close">
          <!-- some inputs -->
          <button type="submit">
            Save
          </button>
        </form>
      </DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

Accessibility

Adheres to the Dialog WAI-ARIA design pattern.

Keyboard Interactions

KeyDescription
Space
Opens/closes the drawer.
Enter
Opens/closes the drawer.
Tab
Moves focus to the next focusable element.
Shift + Tab
Moves focus to the previous focusable element.
Esc
Closes the drawer and moves focus to DrawerTrigger.

Custom APIs

Create your own API by abstracting the primitive parts into your own component.

Abstract the overlay and the portal

This example abstracts the DrawerPortal and DrawerOverlay parts.

Usage

vue
<script setup>
import { Drawer, DrawerContent, DrawerTrigger } from './your-drawer'
</script>

<template>
  <Drawer>
    <DrawerTrigger>Drawer trigger</DrawerTrigger>
    <DrawerContent>Drawer Content</DrawerContent>
  </Drawer>
</template>

Implementation

ts
// your-drawer.ts
export { default as DrawerContent } from 'DrawerContent.vue'
export { DrawerRoot as Drawer, DrawerTrigger } from 'reka-ui'
vue
<!-- DrawerContent.vue -->
<script setup lang="ts">
import type { DrawerContentEmits, DrawerContentProps } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, useForwardPropsEmits } from 'reka-ui'

const props = defineProps<DrawerContentProps>()
const emits = defineEmits<DrawerContentEmits>()

const forwarded = useForwardPropsEmits(props, emits)
</script>

<template>
  <DrawerPortal>
    <DrawerOverlay />
    <DrawerContent v-bind="forwarded">
      <DrawerHandle />
      <slot />
    </DrawerContent>
  </DrawerPortal>
</template>