Drawer

A panel that slides in from the edge of the screen and follows the finger. A drop-in replacement for Vaul, with the same API.

"use client"

import * as React from "react"
import { cn } from "cn"
import { Drawer as DrawerPrimitive } from "@animate-ui/react/primitives/radix-ui"
import { Bar, BarChart, ResponsiveContainer } from "recharts"

import { Button } from "@/components/ui/button"
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"

function Drawer({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
  return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}

function DrawerTrigger({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}

function DrawerPortal({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}

function DrawerClose({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}

function DrawerOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
  return (
    <DrawerPrimitive.Overlay
      data-slot="drawer-overlay"
      className={cn(
        "bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
        className
      )}
      {...props}
    />
  )
}

function DrawerContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
  return (
    <DrawerPortal data-slot="drawer-portal">
      <DrawerOverlay />
      <DrawerPrimitive.Content
        data-slot="drawer-content"
        className={cn(
          "bg-popover text-popover-foreground flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b group/drawer-content fixed z-50 data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
          className
        )}
        {...props}
      >
        <div className="bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full mx-auto hidden shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
        {children}
      </DrawerPrimitive.Content>
    </DrawerPortal>
  )
}

function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-header"
      className={cn(
        "gap-0.5 p-4 md:gap-0.5 md:text-left flex flex-col group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center",
        className
      )}
      {...props}
    />
  )
}

function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-footer"
      className={cn("gap-2 p-4 mt-auto flex flex-col", className)}
      {...props}
    />
  )
}

function DrawerTitle({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  return (
    <DrawerPrimitive.Title
      data-slot="drawer-title"
      className={cn("text-foreground text-base font-medium", className)}
      {...props}
    />
  )
}

function DrawerDescription({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  return (
    <DrawerPrimitive.Description
      data-slot="drawer-description"
      className={cn("text-muted-foreground text-sm", className)}
      {...props}
    />
  )
}

export {
  Drawer,
  DrawerPortal,
  DrawerOverlay,
  DrawerTrigger,
  DrawerClose,
  DrawerContent,
  DrawerHeader,
  DrawerFooter,
  DrawerTitle,
  DrawerDescription,
}

const data = [
  {
    goal: 400,
  },
  {
    goal: 300,
  },
  {
    goal: 200,
  },
  {
    goal: 300,
  },
  {
    goal: 200,
  },
  {
    goal: 278,
  },
  {
    goal: 189,
  },
  {
    goal: 239,
  },
  {
    goal: 300,
  },
  {
    goal: 200,
  },
  {
    goal: 278,
  },
  {
    goal: 189,
  },
  {
    goal: 349,
  },
]

export default function DrawerDemo({
  direction = "bottom",
}: {
  direction?: "bottom" | "top" | "left" | "right"
}) {
  const [goal, setGoal] = React.useState(350)

  function onClick(adjustment: number) {
    setGoal(Math.max(200, Math.min(400, goal + adjustment)))
  }

  return (
    <Drawer direction={direction}>
      <DrawerTrigger asChild>
        <Button variant="outline">Open Drawer</Button>
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Move Goal</DrawerTitle>
            <DrawerDescription>Set your daily activity goal.</DrawerDescription>
          </DrawerHeader>
          <div className="p-4 pb-0">
            <div className="flex items-center justify-center space-x-2">
              <Button
                variant="outline"
                size="icon"
                className="h-8 w-8 shrink-0 rounded-full"
                onClick={() => onClick(-10)}
                disabled={goal <= 200}
              >
                <HugeiconsIcon icon={MinusSignIcon} strokeWidth={2} />
                <span className="sr-only">Decrease</span>
              </Button>
              <div className="flex-1 text-center">
                <div className="text-7xl font-bold tracking-tighter">
                  {goal}
                </div>
                <div className="text-[0.70rem] text-muted-foreground uppercase">
                  Calories/day
                </div>
              </div>
              <Button
                variant="outline"
                size="icon"
                className="h-8 w-8 shrink-0 rounded-full"
                onClick={() => onClick(10)}
                disabled={goal >= 400}
              >
                <HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} />
                <span className="sr-only">Increase</span>
              </Button>
            </div>
            <div className="mt-3 h-[120px]">
              <ResponsiveContainer width="100%" height="100%">
                <BarChart data={data}>
                  <Bar
                    dataKey="goal"
                    style={
                      {
                        fill: "var(--chart-1)",
                      } as React.CSSProperties
                    }
                  />
                </BarChart>
              </ResponsiveContainer>
            </div>
          </div>
          <DrawerFooter>
            <Button>Submit</Button>
            <DrawerClose asChild>
              <Button variant="outline">Cancel</Button>
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Installation

Install the packages

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

Add the shadcn/ui component

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

Animate it

The CLI finds your shadcn/ui 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

shadcn/ui's Radix Drawer is built on Vaul, which is no longer maintained. Animate UI's Drawer has exactly Vaul's API (the Drawer object and its parts, the same props, the same data-vaul-* attributes), built on Radix UI's Dialog and Motion. animate-ui animate drawer swaps the vaul import for it, so you can also remove the vaul dependency from your project.

Usage

Import the primitive from Animate UI instead of Vaul. Everything else stays the same.

import { Drawer } from '@animate-ui/react/primitives/radix-ui/drawer';

export function MoveGoal() {
  return (
    <Drawer.Root>
      <Drawer.Trigger>Move goal</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay />
        <Drawer.Content>
          <Drawer.Handle />
          <Drawer.Title>Move goal</Drawer.Title>
          <Drawer.Description>Set your daily activity goal.</Drawer.Description>
          <Drawer.Close>Save</Drawer.Close>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}

The import * as Vaul from 'vaul' style works too, as do the named exports (Root, NestedRoot, Content, Overlay, Portal, Handle) and the types (DialogProps, ContentProps, HandleProps, WithFadeFromProps, WithoutFadeFromProps). direction, snapPoints with activeSnapPoint, setActiveSnapPoint and fadeFromIndex, dismissible, handleOnly, modal, closeThreshold, shouldScaleBackground (on the element with data-vaul-drawer-wrapper), NestedRoot, container, onDrag, onRelease, onClose and onAnimationEnd behave as in Vaul, and data-vaul-no-drag keeps an element from starting a drag.

Animation

Animate UI v1 had no drawer: it keeps Vaul's API and behaviour, with Animate UI's motion.

  • Content slides in from its edge (direction, its own size plus any gap to the edge), 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, which Vaul's bleed below it covers), then settles back. A hint of bounce, where Vaul's own curve zips in, then stops. With snap points, it slides to the active one the same way.
  • 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 your classes give it one, lifts off the page with it: none off screen, all of it once it's in (from its first snap point up), on every frame of a slide or a drag, so it fades as it leaves rather than riding out at full strength.
  • 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). Once a drag has moved the drawer, it also follows the drawer's position, like Vaul's; with snap points, it always does, coming in between the snap point before fadeFromIndex and that one. Over a background image (a gradient), it fades its opacity instead.
  • Dragging follows the pointer on the same frame: the pointer sets the position directly, with no transition, no fade and no React render. Pulled past the open position, it rubber-bands. On release, it closes past closeThreshold (25% of its size) or on a flick, and otherwise eases back on its curve. Dismissed by a drag, it's thrown: it keeps the finger's speed to its edge (linear, over the time the rest of the way takes at that speed, up to 360ms, like Base UI's drawer), so it neither stalls on release nor crawls out. With snap points, it goes to the closest one, a flick moves one point (a fast one skips to the first or last), and a flick down from the first closes it.
  • Scrollable content keeps scrolling: a drag doesn't start when the content under the pointer can scroll that way, nor shortly after it scrolled (scrollLockTimeout).
  • A drag dismissal continues from where the pointer let go; opening again while it closes picks up from where it is. The click that ends a drag doesn't activate the button under it.
  • Nested drawers: the parent steps back (16px towards its open edge, slightly smaller) on the drawer's curve while a nested drawer is open, and follows the nested drawer's drag.
  • shouldScaleBackground scales the [data-vaul-drawer-wrapper] element with the drawer's position, and paints the body black (setBackgroundColorOnScale, not with noBodyStyles).
  • forceMount doesn't keep the closed drawer in the DOM: it plays its exit, then leaves, so a closed drawer never locks the page's scroll or hides it from screen readers.
  • Under reduced motion, the drawer fades in place and settles without moving; a drag still follows the pointer.
  • AnimateUIProvider tempo scales every duration and spring.

Not reproduced from Vaul: its iOS Safari workarounds (position: fixed on the body, so preventScrollRestoration and disablePreventScroll are accepted but have no effect; Radix's Dialog locks the scroll of a modal drawer). repositionInputs and fixed keep a bottom drawer above the on-screen keyboard, more simply than Vaul. A Handle tap on a drawer without snap points does nothing.

API Reference

Every part takes exactly the props of Vaul's Drawer (its Content, Overlay, Trigger, Close, Title and Description take Radix UI's Dialog props). Animate UI adds the animation props below.

Root, NestedRoot, Trigger, Portal, Handle, Title, Description, Close

Vaul props only.

Content

Vaul props, plus:

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

Overlay

Vaul props, plus:

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

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

Last updated: 10/18/2026

On this page