Drawer

A panel that slides in from the edge of the screen and can be swiped away.

<script setup lang="ts">
import { useMediaQuery } from "@vueuse/core"
import { computed, ref } from "vue"
import { toast } from "vue-sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
  FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Toaster } from "@/components/ui/sonner"
import "vue-sonner/style.css"

const props = withDefaults(
  defineProps<{ direction?: "bottom" | "top" | "left" | "right" }>(),
  { direction: "right" },
)

const deliveryTimes = [
  {
    value: "asap",
    id: "delivery-asap",
    label: "Standard delivery",
    description: "25–35 min · Driver assigned now",
    badge: "Fastest",
  },
  {
    value: "5-00",
    id: "delivery-5-00",
    label: "5:00 PM – 5:15 PM",
    description: "Prep starts at 4:45 PM",
  },
  {
    value: "5-30",
    id: "delivery-5-30",
    label: "5:30 PM – 5:45 PM",
    description: "Good if you're heading home",
  },
  {
    value: "6-00",
    id: "delivery-6-00",
    label: "6:00 PM – 6:15 PM",
    description: "Most popular · High demand",
  },
  {
    value: "6-30",
    id: "delivery-6-30",
    label: "6:30 PM – 6:45 PM",
    description: "Last slot before kitchen closes",
  },
]

const SWIPE_DIRECTION = {
  bottom: "down",
  top: "up",
  left: "left",
  right: "right",
} as const

const open = ref(false)
const deliveryTime = ref("asap")
const isMobile = useMediaQuery("(max-width: 767px)")
const swipeDirection = computed(() => isMobile.value ? "down" : SWIPE_DIRECTION[props.direction])

function handleConfirm() {
  const selected = deliveryTimes.find(time => time.value === deliveryTime.value)

  if (!selected) {
    return
  }

  open.value = false
  toast("Delivery time confirmed", {
    description: selected.label,
  })
}
</script>

<template>
  <Drawer v-model:open="open" :swipe-direction="swipeDirection">
    <DrawerTrigger as-child>
      <Button variant="secondary">
        Open Drawer
      </Button>
    </DrawerTrigger>
    <DrawerContent>
      <DrawerHeader>
        <DrawerTitle>Pick a delivery time</DrawerTitle>
        <DrawerDescription>
          We'll prepare your order as soon as possible.
        </DrawerDescription>
      </DrawerHeader>
      <div class="flex-1 scroll-fade overflow-y-auto p-4">
        <RadioGroup v-model="deliveryTime" class="gap-2">
          <FieldLabel v-for="time in deliveryTimes" :key="time.value" :for="time.id">
            <Field orientation="horizontal">
              <FieldContent>
                <FieldTitle class="flex items-center gap-2">
                  {{ time.label }}
                  <Badge v-if="time.badge" variant="secondary">
                    {{ time.badge }}
                  </Badge>
                </FieldTitle>
                <FieldDescription>{{ time.description }}</FieldDescription>
              </FieldContent>
              <RadioGroupItem :id="time.id" :value="time.value" />
            </Field>
          </FieldLabel>
        </RadioGroup>
      </div>
      <DrawerFooter>
        <Button class="h-[34px]" @click="handleConfirm">
          Confirm Delivery Time
        </Button>
        <DrawerClose as-child>
          <Button variant="outline">
            Cancel
          </Button>
        </DrawerClose>
      </DrawerFooter>
    </DrawerContent>
  </Drawer>
  <Toaster />
</template>

Installation

Install the packages

npm install @animate-ui/vue motion-v
pnpm add @animate-ui/vue motion-v
yarn add @animate-ui/vue motion-v
bun add @animate-ui/vue motion-v

Add the shadcn-vue component

npx shadcn-vue@latest add drawer
pnpm dlx shadcn-vue@latest add drawer
yarn dlx shadcn-vue@latest add drawer
bun x shadcn-vue@latest add drawer

Animate it

The CLI finds your shadcn-vue components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate drawer
pnpm dlx @animate-ui/cli@latest animate drawer
yarn dlx @animate-ui/cli@latest animate drawer
bun x @animate-ui/cli@latest animate drawer

Usage

Import the parts from Animate UI instead of reka-ui. Everything else stays the same.

<script setup lang="ts">
import {
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerOverlay,
  DrawerPortal,
  DrawerRoot,
  DrawerTitle,
  DrawerTrigger,
} from '@animate-ui/vue/primitives/reka-ui';
</script>

<template>
  <DrawerRoot>
    <DrawerTrigger>Daily goals</DrawerTrigger>
    <DrawerPortal>
      <DrawerOverlay />
      <DrawerContent>
        <DrawerTitle>Daily goals</DrawerTitle>
        <DrawerDescription>Swipe down to dismiss.</DrawerDescription>
        <DrawerClose>Done</DrawerClose>
      </DrawerContent>
    </DrawerPortal>
  </DrawerRoot>
</template>

shadcn-vue's Drawer is built on reka-ui's: animate-ui animate drawer swaps its reka-ui imports and removes the CSS slide, fade and transform transition it would run on top.

Animation

The motion of Animate UI's React drawer (Base UI's), with reka-ui's swipe.

  • The content slides in from its edge (swipe-direction, a full size of its own), opaque, a sheet sliding in rather than a fade, in 0.4s of cubic-bezier(0.34, 1.2, 0.64, 1): it answers at once, goes a hair past its rest (about 1% of its size), then settles back. A hint of bounce, where Vaul's curve zips in, then stops.
  • Closed (Escape, a click outside, a Close), it leaves quickly, seen speeding off its edge (0.16s on an ease-in, cubic-bezier(0.4, 0, 1, 1)), so it never reads as a constant slide and its last pixels don't crawl out.
  • Its shadow, if it has one, lifts off the page with it: its starting and ending styles drop it, so it grows from none as it slides in, on the same transition, and fades as it leaves.
  • While you drag it, it follows the finger on the same frame: no transition runs during a swipe. It's dismissed like Base UI's: dragged past half its size, or flicked at 0.5px/ms or more (reka-ui alone would dismiss it 40px away). Let go short of that and it eases back; flick it and it's thrown: it keeps the finger's speed to its edge, linear, over reka-ui's --drawer-swipe-strength share of 360ms (the time the rest of the way takes at that speed).
  • The overlay comes in and clears exactly like a dialog's: its tint deepens while its backdrop blur grows from 0, in 400ms on a soft ease-out both ways, so the page behind blurs progressively (fading its opacity would cross-fade the sharp page with an already blurred one). It follows the swipe in between: reka-ui leaves its --drawer-swipe-progress at 0, Animate UI sets it from the content's swipe. The content waits for it as it leaves, so the overlay is never cut short. Its classes' color and blur are read once it mounts; over a background image (a gradient), it fades its opacity instead.
  • Both stay mounted until their transitions end: reka-ui's own presence only waits for CSS animations, so the content and the overlay get Base UI's data-starting-style on the frame they mount and data-ending-style while they leave (data-swipe-dismiss too, when a swipe dismissed it).
  • Snap points, nested drawers and swipe-direction work as in reka-ui.
  • CSS-driven, not Motion: reka-ui drives the swipe through CSS variables, so a JavaScript animation would fight the finger. The primitive brings its own small stylesheet (zero specificity) and the timing as inline variables: a spring becomes the linear() curve Motion itself would use, a tween keeps its cubic-bezier, and AnimateUIProvider tempo reaches it.
  • Under reduced motion, the drawer fades in place; a swipe dismissal still leaves along the swipe.

API Reference

Every part takes exactly the props of reka-ui's Drawer. Animate UI adds the animation props below.

DrawerRoot, DrawerProvider, DrawerTrigger, DrawerPortal, DrawerViewport, DrawerSwipeArea, DrawerHandle, DrawerTitle, DrawerDescription, DrawerClose, DrawerIndent, DrawerIndentBackground, DrawerVirtualKeyboardProvider

reka-ui props only. They are not animated.

DrawerContent

reka-ui props, plus:

PropTypeDefault
transition?
Transition{ duration: 0.4, ease: [0.34, 1.2, 0.64, 1] }
exit-transition?
Transition{ duration: 0.16, ease: [0.4, 0, 1, 1] }

DrawerOverlay

reka-ui props, plus:

PropTypeDefault
transition?
Transition{ duration: 0.4, ease: [0.215, 0.61, 0.355, 1] }
exit-transition?
Transitiontransition

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/23/2026