Open a panel from any edge. Built on Base UI, it supports swipe-to-dismiss, snap points, and nested drawers.
"use client"import * as React from "react"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"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", },]function useIsMobile(query = "(max-width: 767px)") { const [isMobile, setIsMobile] = React.useState(false) React.useEffect(() => { const media = window.matchMedia(query) const onChange = () => setIsMobile(media.matches) onChange() media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) }, [query]) return isMobile}export function Example() { const [open, setOpen] = React.useState(false) const [deliveryTime, setDeliveryTime] = React.useState("asap") const isMobile = useIsMobile() return ( <Drawer open={open} onOpenChange={setOpen} showSwipeHandle={isMobile} swipeDirection={isMobile ? "down" : "right"} > <DrawerTrigger render={<Button variant="secondary" />}> Open Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Pick a delivery time</DrawerTitle> <DrawerDescription> We'll prepare your order as soon as possible. </DrawerDescription> </DrawerHeader> <div className="flex-1 overflow-y-auto p-4"> <RadioGroup value={deliveryTime} onValueChange={setDeliveryTime} className="gap-2" > {deliveryTimes.map((time) => ( <FieldLabel key={time.value} htmlFor={time.id}> <Field orientation="horizontal"> <FieldContent> <FieldTitle className="flex items-center gap-2"> {time.label} {time.badge ? ( <Badge variant="secondary">{time.badge}</Badge> ) : null} </FieldTitle> <FieldDescription>{time.description}</FieldDescription> </FieldContent> <RadioGroupItem value={time.value} id={time.id} /> </Field> </FieldLabel> ))} </RadioGroup> </div> <DrawerFooter> <Button onClick={() => setOpen(false)}>Confirm Delivery Time</Button> <DrawerClose render={<Button variant="outline" />}> Cancel </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> )}Installation
npx @arctis-sh/@arctis-sh/ui@latest add drawerAdd the following to your global styles. On iOS Safari, the absolutely positioned overlay needs a positioned body to cover the viewport after scrolling. This project already sets position: relative on html and body in globals.css.
body { position: relative;}Usage
import { Button } from "@/components/ui/button"import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@/components/ui/drawer"<Drawer> <DrawerTrigger render={<Button variant="outline" />}> Open </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Discard draft?</DrawerTitle> <DrawerDescription>Unsaved changes will be lost.</DrawerDescription> </DrawerHeader> <div className="p-4">{/* Content here */}</div> <DrawerFooter> <Button>Submit</Button> <DrawerClose render={<Button variant="outline" />}> Cancel </DrawerClose> </DrawerFooter> </DrawerContent></Drawer>Composition
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooterDrawerContent composes the portal, overlay, viewport, and popup from Base UI. For lower-level control, DrawerPortal, DrawerOverlay, and DrawerSwipeHandle are also exported.
Custom Sizes
A vertical drawer sizes itself to its content and is capped at calc(100dvh - 6rem) by default. A side drawer spans 75% of the viewport width, or 24rem on larger screens.
Set a custom vertical drawer height with h-* and max-h-* utilities on DrawerContent.
<DrawerContent className="h-[50vh]"> ...</DrawerContent>Set a custom side drawer width with w-* and max-w-* utilities on DrawerContent.
<DrawerContent className="w-96"> ...</DrawerContent>When the same component renders in multiple directions, scope an override to one axis using the data-[swipe-axis=*] variants.
<DrawerContent className="data-[swipe-axis=y]:max-h-[50vh] data-[swipe-axis=x]:w-96"> ...</DrawerContent>For a scrollable region, make the scroll container a flex item. Avoid h-full; it does not resolve inside a content-sized drawer.
<DrawerContent> <DrawerHeader>...</DrawerHeader> <div className="flex-1 overflow-y-auto p-4">{/* Scrollable content */}</div> <DrawerFooter>...</DrawerFooter></DrawerContent>Styling
The drawer exposes CSS variables for lower-level styling. Set sizing variables on DrawerContent and the overlay variable on [data-slot=drawer-overlay].
| Variable | Default | Description |
| --- | --- | --- |
| --drawer-inset | 0.5rem | Floats the drawer from the viewport edges. |
| --drawer-bleed-background | transparent | Fills the gap behind the drawer on swipe overshoot. |
| --drawer-overlay-min-opacity | 0 | Minimum overlay opacity. Defaults to 0.5 when snap points are active. |
The drawer also sets data attributes on the popup (data-slot="drawer-popup") you can target with variants such as data-[swipe-direction=down]: on DrawerContent, or group-data-[swipe-axis=y]/drawer-popup: on its descendants.
| Attribute | Values | Set when |
| --- | --- | --- |
| data-swipe-direction | up, right, down, left | Always. |
| data-swipe-axis | x, y | Always. |
| data-snap-points | Present | The drawer has snap points. |
| data-expanded | Present | The drawer is at the full snap point. |
| data-swiping | Present | A swipe is in progress. |
| data-nested-drawer-open | Present | A nested drawer is open on top. |
Position
Use the swipeDirection prop to set the side of the drawer.
Choose up, right, down, or left.
import { Button } from "@/components/ui/button"import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@/components/ui/drawer"export function Example() { return ( <Drawer swipeDirection="left"> <DrawerTrigger render={<Button variant="secondary" />}> Open Left Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Move Goal</DrawerTitle> <DrawerDescription>Set your daily activity goal.</DrawerDescription> </DrawerHeader> <div className="min-h-0 flex-1 p-4"> <div className="h-full w-full rounded-md bg-secondary" /> </div> <DrawerFooter> <DrawerClose render={<Button />}>Close</DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> )}Swipe Handle
Pass showSwipeHandle on Drawer to render a drag handle on the swipe edge.
import { Button } from "@/components/ui/button"import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@/components/ui/drawer"export function Example() { return ( <Drawer showSwipeHandle> <DrawerTrigger render={<Button variant="secondary" />}> Open Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Drawer</DrawerTitle> <DrawerDescription>Drawer with a swipe handle.</DrawerDescription> </DrawerHeader> <div className="flex-1 p-4"> <div className="h-80 w-full rounded-md bg-secondary" /> </div> <DrawerFooter> <DrawerClose render={<Button />}>Close</DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> )}Nested
Open a drawer from inside another. Parent drawers stay mounted and stack behind the active drawer.
"use client"import * as React from "react"import { Button } from "@/components/ui/button"import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@/components/ui/drawer"function useIsMobile(query = "(max-width: 767px)") { const [isMobile, setIsMobile] = React.useState(false) React.useEffect(() => { const media = window.matchMedia(query) const onChange = () => setIsMobile(media.matches) onChange() media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) }, [query]) return isMobile}export function Example() { const isMobile = useIsMobile() const swipeDirection = isMobile ? "down" : "right" return ( <Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}> <DrawerTrigger render={<Button variant="secondary" />}> Open Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Drawer</DrawerTitle> <DrawerDescription> Open another drawer from the same direction. </DrawerDescription> </DrawerHeader> <div className="min-h-0 flex-1 p-4"> <div className="h-full w-full rounded-md bg-secondary group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:h-auto" /> </div> <DrawerFooter> <Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}> <DrawerTrigger render={<Button variant="outline" />}> Open Nested Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Nested Drawer</DrawerTitle> <DrawerDescription> The parent drawer stays mounted behind this one. </DrawerDescription> </DrawerHeader> <div className="min-h-0 flex-1 p-4"> <div className="h-full w-full rounded-md bg-secondary group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:h-auto" /> </div> <DrawerFooter> <Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection} > <DrawerTrigger render={<Button variant="outline" />}> Open Third Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Third Drawer</DrawerTitle> <DrawerDescription> Two drawers are stacked behind this one. </DrawerDescription> </DrawerHeader> <div className="min-h-0 flex-1 p-4"> <div className="h-full w-full rounded-md bg-secondary group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:h-auto" /> </div> <DrawerFooter> <Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection} > <DrawerTrigger render={<Button variant="outline" />}> Open Fourth Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Fourth Drawer</DrawerTitle> <DrawerDescription> This is the frontmost drawer in the stack. </DrawerDescription> </DrawerHeader> <div className="min-h-0 flex-1 p-4"> <div className="h-full w-full rounded-md bg-secondary group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:h-auto" /> </div> <DrawerFooter> <DrawerClose render={<Button variant="outline" />} > Close </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> <DrawerClose render={<Button variant="outline" />} > Close </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> <DrawerClose render={<Button variant="outline" />}> Close </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> <DrawerClose render={<Button variant="outline" />}> Close </DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> )}Non Modal
Set modal={false} to allow interaction with the rest of the page while the drawer is open. Combine with disablePointerDismissal to prevent the drawer from closing on outside presses. Use modal="trap-focus" to keep focus inside the drawer while leaving scroll and pointer interaction unrestricted.
import { Button } from "@/components/ui/button"import { Drawer, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@/components/ui/drawer"export function Example() { return ( <Drawer modal={false} disablePointerDismissal swipeDirection="right"> <DrawerTrigger render={<Button variant="outline" />}> Non Modal </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Non Modal Drawer</DrawerTitle> </DrawerHeader> <div className="min-h-0 flex-1 p-4"> <div className="h-full w-full rounded-md bg-secondary" /> </div> <DrawerFooter> <DrawerClose render={<Button />}>Close</DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> )}Snap Points
Use snapPoints to define preset heights for a vertical drawer. Numbers from 0 to 1 are viewport fractions; numbers greater than 1 are pixels. Strings support px and rem.
Track the active snap point with the controlled snapPoint and onSnapPointChange props. At the full snap point, the drawer gets a data-expanded attribute you can style with the data-expanded: variant.
import { Button } from "@/components/ui/button"import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@/components/ui/drawer"const SNAP_POINTS = ["31rem", 1]export function Example() { return ( <Drawer snapPoints={SNAP_POINTS} showSwipeHandle> <DrawerTrigger render={<Button variant="outline" />}> Open Snap Drawer </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Snap points</DrawerTitle> <DrawerDescription> Drag the drawer to snap between a compact peek and a near full-height view. </DrawerDescription> </DrawerHeader> <div className="flex-1 p-4"> <div className="h-80 w-full rounded-md bg-secondary" /> </div> <DrawerFooter> <DrawerClose render={<Button />}>Close</DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> )}API Reference
Drawer
| Prop | Type | Default |
|---|---|---|
| swipeDirection | "up" | "right" | "down" | "left" | "down" |
| showSwipeHandle | boolean | false |
| modal | boolean | "trap-focus" | true |
| disablePointerDismissal | boolean | false |
| snapPoints | (number | string)[] | — |
| snapPoint | number | string | null | — |
| onSnapPointChange | (snapPoint: number | string | null) => void | — |
| snapToSequentialPoints | boolean | false |
| open | boolean | — |
| defaultOpen | boolean | false |
| onOpenChange | (open: boolean) => void | — |
| onOpenChangeComplete | (open: boolean) => void | — |
<Drawer swipeDirection="down" showSwipeHandle snapPoints={[0.5, 1]}> <DrawerTrigger render={<Button variant="outline" />}> Open </DrawerTrigger> <DrawerContent>...</DrawerContent></Drawer>DrawerTrigger
Renders a native button by default. Pass a Button (or other element) with the Base UI render prop.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
| render | React.ReactElement | — |
<DrawerTrigger render={<Button variant="outline" />}> Open</DrawerTrigger>DrawerClose
Closes the drawer. Prefer render={<Button … />} so close actions match your buttons.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
| render | React.ReactElement | — |
<DrawerClose render={<Button variant="outline" />}> Cancel</DrawerClose>DrawerContent
Floating panel inset from the viewport edge by --drawer-inset, with a full radius and border. className is applied to the popup.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
<DrawerContent className="data-[swipe-axis=y]:max-h-[50vh]"> ...</DrawerContent>DrawerHeader
| Prop | Type | Default |
|---|---|---|
| className | string | — |
<DrawerHeader> <DrawerTitle>Title</DrawerTitle> <DrawerDescription>Description</DrawerDescription></DrawerHeader>DrawerFooter
| Prop | Type | Default |
|---|---|---|
| className | string | — |
<DrawerFooter> <Button>Submit</Button> <DrawerClose render={<Button variant="outline" />}> Cancel </DrawerClose></DrawerFooter>DrawerTitle
| Prop | Type | Default |
|---|---|---|
| className | string | — |
<DrawerTitle>Title</DrawerTitle>DrawerDescription
| Prop | Type | Default |
|---|---|---|
| className | string | — |
<DrawerDescription>Description</DrawerDescription>DrawerPortal
| Prop | Type | Default |
|---|---|---|
| container | HTMLElement | document.body |
DrawerOverlay
Scrim behind the panel. Only rendered when modal is true.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
DrawerSwipeHandle
Drag affordance on the swipe edge. Usually rendered via showSwipeHandle on Drawer.
| Prop | Type | Default |
|---|---|---|
| className | string | — |