Drawer

Edge panel with swipe gestures, snap points, and nesting.

Open a panel from any edge. Built on Base UI, it supports swipe-to-dismiss, snap points, and nested drawers.

tsx
"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&apos;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 drawer

Add 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
    └── DrawerFooter

DrawerContent 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.

tsx
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.

tsx
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.

tsx
"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.

tsx
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.

tsx
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

PropTypeDefault
swipeDirection"up" | "right" | "down" | "left""down"
showSwipeHandlebooleanfalse
modalboolean | "trap-focus"true
disablePointerDismissalbooleanfalse
snapPoints(number | string)[]
snapPointnumber | string | null
onSnapPointChange(snapPoint: number | string | null) => void
snapToSequentialPointsbooleanfalse
openboolean
defaultOpenbooleanfalse
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.

PropTypeDefault
classNamestring
renderReact.ReactElement
<DrawerTrigger render={<Button variant="outline" />}>  Open</DrawerTrigger>

DrawerClose

Closes the drawer. Prefer render={<Button … />} so close actions match your buttons.

PropTypeDefault
classNamestring
renderReact.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.

PropTypeDefault
classNamestring
<DrawerContent className="data-[swipe-axis=y]:max-h-[50vh]">  ...</DrawerContent>

DrawerHeader

PropTypeDefault
classNamestring
<DrawerHeader>  <DrawerTitle>Title</DrawerTitle>  <DrawerDescription>Description</DrawerDescription></DrawerHeader>

DrawerFooter

PropTypeDefault
classNamestring
<DrawerFooter>  <Button>Submit</Button>  <DrawerClose render={<Button variant="outline" />}>    Cancel  </DrawerClose></DrawerFooter>

DrawerTitle

PropTypeDefault
classNamestring
<DrawerTitle>Title</DrawerTitle>

DrawerDescription

PropTypeDefault
classNamestring
<DrawerDescription>Description</DrawerDescription>

DrawerPortal

PropTypeDefault
containerHTMLElementdocument.body

DrawerOverlay

Scrim behind the panel. Only rendered when modal is true.

PropTypeDefault
classNamestring

DrawerSwipeHandle

Drag affordance on the swipe edge. Usually rendered via showSwipeHandle on Drawer.

PropTypeDefault
classNamestring