Drawer
A draggable drawer on the vaul primitive - opens from any of the four edges, drags to dismiss, and shows a grab handle on the bottom direction.
"use client"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerDemo() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open drawer</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Deploy to production</DrawerTitle>
<DrawerDescription>
This promotes the current preview build to the live environment.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button tone="brand">Deploy</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/drawerThe vaul dependency, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install vaul@^1.1.2 tw-animate-css@^1.4.0The drawer depends on vaul for the drag gesture and portal.
Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/drawer.tsx"use client"
import type * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
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(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 motion-reduce:animate-none",
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(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-popover-foreground",
"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-[80dvh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=top]:border-border",
"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-[80dvh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=bottom]:border-border",
"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=right]:border-l data-[vaul-drawer-direction=right]:border-border data-[vaul-drawer-direction=right]:sm:max-w-sm",
"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=left]:border-r data-[vaul-drawer-direction=left]:border-border data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-surface-3 group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{/* max-h was already there (80dvh) but the scroll container was
NOT: a limit was set, scrolling was never given (measured:
overflow-y visible, last row off-screen). The scroll container
stays OUTSIDE the handle so the drag handle is always reachable;
without `min-h-0`, overflow has no effect on a flex child. */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain">
{children}
</div>
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-lg font-semibold leading-none text-foreground", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the surface colors will be missing.
Usage
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open drawer</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Deploy to production</DrawerTitle>
<DrawerDescription>
This promotes the current preview build to the live environment.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button tone="brand">Deploy</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>Set direction on the Drawer root. The default bottom direction suits mobile sheets and shows the drag handle; use left or right for side panels.
Examples
Bottom with footer
The bottom direction renders the drag handle at the top of the panel; DrawerFooter pins its actions to the bottom edge and DrawerClose asChild dismisses without extra state.
Direction
Set direction to top, right, bottom or left. The right and left directions are full-height columns; only the bottom direction shows the drag handle.
Props
The Drawer root forwards every vaul prop; the props below are the ones you set most often.
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "top" | "right" | "bottom" | "left" | "bottom" | Edge the drawer opens from, set on the Drawer root. Top and bottom are full-width panels capped at 80vh tall; left and right are full-height columns that are w-3/4 on mobile and capped at max-w-sm from the sm breakpoint up. The drag handle only renders on the bottom direction. |
open | boolean | undefined | Controlled open state on the Drawer root. Pair with onOpenChange to drive the drawer from React state. Omit both for uncontrolled behavior via DrawerTrigger and DrawerClose. |
onOpenChange | (open: boolean) => void | undefined | Called on the Drawer root whenever the open state changes, including drag-to-dismiss, Escape and overlay clicks. |
ai2 Drawer: a draggable drawer for React on vaul
The ai2 Drawer is a shadcn-compatible drawer component for React, built on the vaul primitive and styled with Tailwind CSS v4. It is a panel that slides in from any of the four screen edges over a dimmed overlay and can be dragged to dismiss, ideal for mobile action sheets, filter panels, edit forms and detail views.
It ships through the shadcn registry format, so one CLI command, an MCP agent, or a copy-paste puts the source in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Drawer?
It is a ten-part composition: Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerClose, DrawerOverlay and DrawerPortal. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working without changes.
Under the hood it is vaul: the panel renders in a portal over a fixed overlay and responds to a drag gesture, so a user can flick it away as well as press Escape or click the overlay. The one styling decision you make is the direction prop on the root, which anchors the drawer to the top, right, bottom or left edge. The bottom direction renders a rounded drag handle at the top of the panel.
Why use it
- Four directions from one prop: direction="bottom" (the default), top, left or right, set on the Drawer root. Top and bottom are full-width panels capped at 80vh; left and right are full-height columns capped at max-w-sm from the sm breakpoint up.
- Drag to dismiss: vaul tracks the drag gesture so the drawer follows the pointer and closes when flicked past the threshold. The bottom direction shows a grab handle so the affordance is obvious.
- Accessible by construction: vaul builds on a dialog pattern: it traps focus while open, wires the accessible name through DrawerTitle and the description through DrawerDescription, closes on Escape and returns focus to the trigger.
- Header and footer slots: DrawerHeader stacks the title and description and centers them on the top and bottom directions; DrawerFooter uses mt-auto to pin actions to the far edge of the panel.
- Controlled or uncontrolled: Let DrawerTrigger manage the open state, or pass open and onOpenChange to the root to drive the drawer from router state or a keyboard shortcut.
Features
- shadcn registry install: One command adds the component, the vaul dependency and the @ai2/tokens theme to your project.
- Directional layout: Each direction sets its own inset, radius and border via data-vaul-drawer-direction attributes, so one component covers edge panels and bottom sheets alike.
- Bottom drag handle: A rounded bar renders at the top of the panel only on the bottom direction, marking the drawer as draggable without extra markup.
- Portal rendering: The overlay and panel render in a portal at z-50, so the drawer escapes any overflow or stacking context in the surrounding layout.
- Data attributes for styling: Every part exposes data-slot, and the panel exposes data-vaul-drawer-direction and data-state, so you can restyle direction and open state from CSS without forking the component.
- TypeScript source: The file you install is typed end to end and forwards every vaul prop, so autocomplete covers the full API.
Production tips
- Always include DrawerTitle: The title is what the drawer announces as its accessible name. For a visually clean panel, keep the title and hide it with sr-only instead of omitting it.
- Match direction to the use case: Bottom suits mobile action and filter sheets, right and left suit detail panels and navigation, top works best for short announcements. Only the bottom direction shows the drag handle.
- Constrain the inner width: For the bottom direction, wrap the header and footer in a mx-auto w-full max-w-sm div so the content stays readable on wide screens while the panel spans the full width.
- Use DrawerClose for cancel actions: Wrap your Cancel button in DrawerClose asChild instead of wiring onOpenChange manually. It dismisses the drawer and keeps focus management intact.
- Prefer the bottom sheet on mobile: The drag-to-dismiss gesture reads most naturally on a bottom sheet, which is why the drag handle is reserved for that direction.
Works with the rest of ai2
The drawer composes naturally with the rest of the registry. Use an ai2 Button as the trigger and for footer actions, lay out edit forms inside with ai2 Field and ai2 Input rows, and divide long panels with an ai2 Separator.
When the surface should sit in the center of the screen instead of an edge, reach for Dialog. When you want an edge panel without the drag gesture, the Sheet shares the same anatomy on the radix Dialog primitive. Everything reads one token source, so combinations stay consistent in both light and dark mode.