# Drawer

A panel that enters from an edge of the screen and is dismissed by swiping it back.

```tsx
import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Minus, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { HStack, VStack } from "@/components/ui/stack"
import { color, font, leading, radius, space, text, tracking } from "@/theme/tokens.stylex"

const styles = stylex.create({
  body: { maxWidth: "24rem", marginInline: "auto", width: "100%" },
  round: { borderRadius: radius.full },
  goal: {
    fontFamily: font.mono,
    fontSize: text.xl4,
    lineHeight: leading.xl4,
    fontWeight: 600,
    letterSpacing: tracking.tight,
    fontVariantNumeric: "tabular-nums",
  },
  unit: { fontSize: text.xs, lineHeight: leading.xs, color: color.mutedForeground },
  chart: { display: "flex", alignItems: "flex-end", gap: space.s1, height: space.s16 },
  bar: { flexGrow: 1, borderRadius: radius.xs, backgroundColor: color.muted },
})

const bars = [40, 62, 35, 78, 55, 90, 48]

export default function DrawerDemo() {
  const [goal, setGoal] = useState(350)

  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>Open drawer</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Move goal</DrawerTitle>
          <DrawerDescription>Set your daily activity goal.</DrawerDescription>
        </DrawerHeader>
        <VStack gap={6} padding={4} xstyle={styles.body}>
          <HStack align="center" justify="between" gap={4}>
            <Button
              variant="outline"
              size="icon"
              xstyle={styles.round}
              aria-label="Decrease goal"
              onClick={() => setGoal((value) => Math.max(200, value - 10))}
            >
              <Minus />
            </Button>
            <VStack align="center" gap={0.5}>
              <span {...stylex.props(styles.goal)}>{goal}</span>
              <span {...stylex.props(styles.unit)}>calories / day</span>
            </VStack>
            <Button
              variant="outline"
              size="icon"
              xstyle={styles.round}
              aria-label="Increase goal"
              onClick={() => setGoal((value) => Math.min(600, value + 10))}
            >
              <Plus />
            </Button>
          </HStack>
          <div {...stylex.props(styles.chart)} aria-hidden="true">
            {bars.map((height) => {
              const sx = stylex.props(styles.bar)
              return <div key={height} {...sx} style={{ ...sx.style, height: `${height}%` }} />
            })}
          </div>
        </VStack>
        <DrawerFooter>
          <DrawerClose render={<Button variant="secondary" />}>Cancel</DrawerClose>
          <Button>Submit</Button>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

## Installation

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/installation), and copy `state-styles.ts` from the same page.
   Copy `states.ts` for the shared popup surface and the footer's tray, and
   `markers.stylex.ts` for the `drawerPopup` marker the content reads while you
   swipe. The drawer
   also needs `interop.css` for the backdrop's swipe timing and the release
   duration driven by swipe strength.

3. Copy and paste the following code into your project.

   ```tsx title="components/ui/drawer.tsx"
   "use client"

   import { useRender } from "@base-ui/react/use-render"
   import { createContext, use } from "react"
   import type { CSSProperties } from "react"
   import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"
   import * as stylex from "@stylexjs/stylex"
   import type { StyleXStyles } from "@stylexjs/stylex"
   import { clsx } from "clsx"

   import { stateStyles } from "@/lib/state-styles"
   import { breakpoints } from "@/theme/breakpoints.stylex"
   import { drawerPopup } from "@/theme/markers.stylex"
   import { states } from "@/theme/states"
   import {
     color,
     duration,
     ease,
     layer,
     leading,
     radius,
     shadow,
     space,
     text,
   } from "@/theme/tokens.stylex"

   type SwipeDirection = NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>
   type SwipeAxis = "x" | "y"

   const styles = stylex.create({
     overlayHidden: { opacity: 0 },
     hidden: { transform: "var(--carbon-drawer-closed-transform)" },
     overlayEnding: { pointerEvents: "none" },
     swiping: { transitionDuration: "0s" },
     nested: { overflow: "hidden", filter: "brightness(0.95)" },
     nestedY: { height: "var(--carbon-drawer-stack-height)" },
     overlay: {
       position: { default: "fixed", "@supports (-webkit-touch-callout: none)": "absolute" },
       inset: 0,
       zIndex: layer.popup,
       minHeight: "100dvh",
       backgroundColor: color.overlay,
       backdropFilter: "blur(2px)",
       userSelect: "none",
       opacity:
         "max(var(--carbon-drawer-overlay-min-opacity, 0), calc(1 - var(--drawer-swipe-progress)))",
       transitionProperty: "opacity",
       transitionDuration: {
         default: duration.drawer,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
       transitionTimingFunction: ease.decelerate,
     },
     overlaySnapPoints: { "--carbon-drawer-overlay-min-opacity": "0.5" },

     viewport: {
       position: "fixed",
       inset: 0,
       zIndex: layer.popup,
       pointerEvents: "none",
       userSelect: "none",
     },
     viewportModal: { pointerEvents: "auto" },

     popup: {
       position: "fixed",
       zIndex: layer.popup,
       pointerEvents: "auto",
       display: "flex",
       minHeight: 0,
       height: "var(--drawer-height, auto)",
       marginBlockStart: "var(--carbon-drawer-inset, 0px)",
       marginBlockEnd: "var(--carbon-drawer-inset, 0px)",
       marginInlineStart: "var(--carbon-drawer-inset, 0px)",
       marginInlineEnd: "var(--carbon-drawer-inset, 0px)",
       boxShadow: shadow.popupLifted,
       fontSize: text.sm,
       lineHeight: leading.sm,
       userSelect: "none",
       willChange: "transform",
       interpolateSize: "allow-keywords",
       transform:
         "translate3d(var(--carbon-drawer-x, 0px), var(--carbon-drawer-y, 0px), 0) scale(var(--carbon-drawer-stack-scale))",
       transitionProperty: "transform, height, opacity, filter",
       transitionDuration: {
         default: duration.drawer,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
       transitionTimingFunction: ease.decelerate,

       "--carbon-drawer-peek": "1rem",
       "--carbon-drawer-stack-step": "0.05",
       "--carbon-drawer-stack-height": "var(--drawer-frontmost-height, var(--drawer-height, 0px))",
       "--carbon-drawer-stack-progress": "clamp(0, var(--drawer-swipe-progress), 1)",
       "--carbon-drawer-stack-peek-offset":
         "max(0px, calc((var(--nested-drawers) - var(--carbon-drawer-stack-progress)) * var(--carbon-drawer-peek)))",
       "--carbon-drawer-stack-scale-base":
         "max(0, calc(1 - (var(--nested-drawers) * var(--carbon-drawer-stack-step))))",
       "--carbon-drawer-stack-scale":
         "clamp(0, calc(var(--carbon-drawer-stack-scale-base) + (var(--carbon-drawer-stack-step) * var(--carbon-drawer-stack-progress))), 1)",
       "--carbon-drawer-stack-shrink": "calc(1 - var(--carbon-drawer-stack-scale))",

       "::after": {
         content: "''",
         position: "absolute",
         pointerEvents: "none",
         backgroundColor: `var(--carbon-drawer-bleed, ${color.popover})`,
       },
     },

     popupY: {
       insetInline: 0,
       flexDirection: "column",
       maxHeight: `calc(100dvh - ${space.s24})`,
       "::after": { insetInline: 0, height: space.s12 },
     },
     popupYSnapPoints: { height: "100dvh", maxHeight: "100dvh" },
     popupX: {
       insetBlock: 0,
       flexDirection: "row",
       width: { default: "75%", [breakpoints.md]: "24rem" },
       "::after": { insetBlock: 0, width: space.s12 },
     },

     popupUp: {
       insetBlockStart: 0,
       transformOrigin: "top",
       borderEndStartRadius: radius.xl2,
       borderEndEndRadius: radius.xl2,
       "--carbon-drawer-closed-transform":
         "translate3d(0, calc(-100% - var(--carbon-drawer-inset, 0px) - 2px), 0)",
       "--carbon-drawer-y":
         "calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-movement-y, 0px) + var(--carbon-drawer-stack-peek-offset) + (var(--carbon-drawer-stack-shrink) * var(--carbon-drawer-stack-height)))",
       "::after": { insetBlockEnd: "100%" },
     },
     popupDown: {
       insetBlockEnd: 0,
       transformOrigin: "bottom",
       borderStartStartRadius: radius.xl2,
       borderStartEndRadius: radius.xl2,
       "--carbon-drawer-closed-transform":
         "translate3d(0, calc(100% + var(--carbon-drawer-inset, 0px) + 2px), 0)",
       "--carbon-drawer-y":
         "calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-movement-y, 0px) - var(--carbon-drawer-stack-peek-offset) - (var(--carbon-drawer-stack-shrink) * var(--carbon-drawer-stack-height)))",
       "::after": { insetBlockStart: "100%" },
     },
     popupLeft: {
       insetInlineStart: 0,
       transformOrigin: "left",
       borderStartEndRadius: radius.xl2,
       borderEndEndRadius: radius.xl2,
       "--carbon-drawer-closed-transform":
         "translate3d(calc(-100% - var(--carbon-drawer-inset, 0px) - 2px), 0, 0)",
       "--carbon-drawer-x":
         "calc(var(--drawer-swipe-movement-x, 0px) + var(--carbon-drawer-stack-peek-offset) + (var(--carbon-drawer-stack-shrink) * 100%))",
       "::after": { insetInlineEnd: "100%" },
     },
     popupRight: {
       insetInlineEnd: 0,
       transformOrigin: "right",
       borderStartStartRadius: radius.xl2,
       borderEndStartRadius: radius.xl2,
       "--carbon-drawer-closed-transform":
         "translate3d(calc(100% + var(--carbon-drawer-inset, 0px) + 2px), 0, 0)",
       "--carbon-drawer-x":
         "calc(var(--drawer-swipe-movement-x, 0px) - var(--carbon-drawer-stack-peek-offset) - (var(--carbon-drawer-stack-shrink) * 100%))",
       "::after": { insetInlineStart: "100%" },
     },

     content: {
       display: "flex",
       flexDirection: "column",
       flexGrow: 1,
       minHeight: 0,
       overflow: "hidden",
       overscrollBehavior: "contain",
       borderRadius: "inherit",
       userSelect: {
         default: "text",
         [stylex.when.ancestor("[data-swiping]", drawerPopup)]: "none",
       },
       opacity: {
         default: 1,
         [stylex.when.ancestor(
           ":not([data-nested-drawer-swiping])[data-nested-drawer-open]",
           drawerPopup,
         )]: 0,
       },
       transitionProperty: "opacity",
       transitionDuration: {
         default: duration.panel,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
       transitionTimingFunction: ease.decelerate,
     },

     handle: {
       position: "relative",
       zIndex: layer.overlay,
       display: "flex",
       flexShrink: 0,
       cursor: { default: "grab", ":active": "grabbing" },
       opacity: {
         default: 1,
         [stylex.when.ancestor(
           ":not([data-nested-drawer-swiping])[data-nested-drawer-open]",
           drawerPopup,
         )]: 0,
       },
       transitionProperty: "opacity",
       transitionDuration: { default: duration.move, "@media (prefers-reduced-motion: reduce)": "0s" },
       transitionTimingFunction: ease.standard,
       "::after": {
         content: "''",
         display: "block",
         flexShrink: 0,
         borderRadius: radius.full,
         backgroundColor: color.muted,
       },
     },
     handleY: {
       height: space.s3,
       width: "100%",
       justifyContent: "center",
       "::after": { height: space.s1, width: space.s24 },
     },
     handleX: {
       width: space.s3,
       height: "100%",
       alignItems: "center",
       "::after": { width: space.s1, height: space.s24 },
     },
     handleUp: { alignItems: "flex-start", order: 1 },
     handleDown: { alignItems: "flex-end" },
     handleLeft: { justifyContent: "flex-start", order: 1 },
     handleRight: { justifyContent: "flex-end" },

     header: {
       display: "grid",
       rowGap: space.s1,
       flexShrink: 0,
       paddingInline: space.s4,
       paddingBlockStart: space.s4,
     },
     footer: {
       display: "flex",
       flexShrink: 0,
       flexDirection: { default: "column-reverse", [breakpoints.md]: "row" },
       justifyContent: { default: null, [breakpoints.md]: "flex-end" },
       gap: space.s2,
       marginBlockStart: "auto",
     },
     title: { fontSize: text.base, lineHeight: leading.base, fontWeight: 500 },
     description: { color: color.mutedForeground, textWrap: "balance" },
   })

   const popupDirections = {
     up: styles.popupUp,
     right: styles.popupRight,
     down: styles.popupDown,
     left: styles.popupLeft,
   } satisfies Record<SwipeDirection, StyleXStyles>

   const handleDirections = {
     up: styles.handleUp,
     right: styles.handleRight,
     down: styles.handleDown,
     left: styles.handleLeft,
   } satisfies Record<SwipeDirection, StyleXStyles>

   const axisOf = (direction: SwipeDirection): SwipeAxis =>
     direction === "down" || direction === "up" ? "y" : "x"

   type DrawerContextValue = {
     hasSnapPoints: boolean
     modal: NonNullable<DrawerPrimitive.Root.Props["modal"]>
     showSwipeHandle: boolean
     swipeDirection: SwipeDirection
     swipeAxis: SwipeAxis
   }

   const DrawerContext = createContext<DrawerContextValue>({
     hasSnapPoints: false,
     modal: true,
     showSwipeHandle: false,
     swipeDirection: "down",
     swipeAxis: "y",
   })

   type Overrides = { xstyle?: StyleXStyles; className?: string }
   type StateOverrides = Overrides & { style?: CSSProperties }

   type DrawerProps<Payload> = DrawerPrimitive.Root.Props<Payload> & {
     showSwipeHandle?: boolean
   }

   function Drawer<Payload>({
     modal = true,
     showSwipeHandle = false,
     snapPoints,
     swipeDirection = "down",
     ...props
   }: DrawerProps<Payload>) {
     const hasSnapPoints = snapPoints != null && snapPoints.length > 0

     return (
       <DrawerContext
         value={{
           hasSnapPoints,
           modal,
           showSwipeHandle,
           swipeDirection,
           swipeAxis: axisOf(swipeDirection),
         }}
       >
         <DrawerPrimitive.Root
           modal={modal}
           snapPoints={snapPoints}
           swipeDirection={swipeDirection}
           {...props}
         />
       </DrawerContext>
     )
   }

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

   function DrawerPortal(props: DrawerPrimitive.Portal.Props) {
     return <DrawerPrimitive.Portal {...props} />
   }

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

   type DrawerOverlayProps = Omit<DrawerPrimitive.Backdrop.Props, "className" | "style"> &
     StateOverrides

   function DrawerOverlay({ xstyle, className, style, ...props }: DrawerOverlayProps) {
     const { hasSnapPoints } = use(DrawerContext)

     return (
       <DrawerPrimitive.Backdrop
         data-slot="drawer-overlay"
         data-snap-points={hasSnapPoints || undefined}
         {...props}
         {...stateStyles(
           (state) => [
             styles.overlay,
             hasSnapPoints && styles.overlaySnapPoints,
             (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
               styles.overlayHidden,
             state.transitionStatus === "ending" && styles.overlayEnding,
             xstyle,
           ],
           { className, style },
         )}
       />
     )
   }

   type DrawerViewportProps = Omit<DrawerPrimitive.Viewport.Props, "className"> & Overrides

   function DrawerViewport({ xstyle, className, ...props }: DrawerViewportProps) {
     const { modal } = use(DrawerContext)
     const sx = stylex.props(styles.viewport, modal === true && styles.viewportModal, xstyle)

     return (
       <DrawerPrimitive.Viewport
         data-slot="drawer-viewport"
         data-modal={String(modal)}
         {...sx}
         {...props}
         className={clsx(sx.className, className)}
       />
     )
   }

   function DrawerSwipeHandle({
     render,
     ref,
     style,
     xstyle,
     className,
     ...props
   }: useRender.ComponentProps<"div"> & Overrides) {
     const { swipeAxis, swipeDirection } = use(DrawerContext)
     const sx = stylex.props(
       styles.handle,
       swipeAxis === "y" ? styles.handleY : styles.handleX,
       handleDirections[swipeDirection],
       xstyle,
     )

     return useRender({
       defaultTagName: "div",
       render,
       ref,
       props: {
         "data-slot": "drawer-swipe-handle",
         "aria-hidden": "true",
         ...sx,
         ...props,
         className: clsx(sx.className, className),
         style: { ...sx.style, ...style },
       },
     })
   }

   type DrawerContentProps = Omit<DrawerPrimitive.Popup.Props, "className" | "style"> &
     StateOverrides & {
       overlayProps?: DrawerOverlayProps
       viewportProps?: DrawerViewportProps
       bodyProps?: Omit<DrawerPrimitive.Content.Props, "className"> & Overrides
     }

   function DrawerContent({
     xstyle,
     className,
     style,
     children,
     overlayProps,
     viewportProps,
     bodyProps,
     ...props
   }: DrawerContentProps) {
     const { hasSnapPoints, modal, showSwipeHandle, swipeAxis, swipeDirection } = use(DrawerContext)
     const { xstyle: bodyXstyle, className: bodyClassName, ...restBodyProps } = bodyProps ?? {}

     const bodySx = stylex.props(styles.content, bodyXstyle)

     return (
       <DrawerPortal>
         {modal === true && <DrawerOverlay {...overlayProps} />}
         <DrawerViewport {...viewportProps}>
           <DrawerPrimitive.Popup
             data-slot="drawer-popup"
             data-swipe-axis={swipeAxis}
             data-snap-points={hasSnapPoints || undefined}
             {...props}
             {...stateStyles(
               (state) => [
                 states.popupSurface,
                 styles.popup,
                 swipeAxis === "y" ? styles.popupY : styles.popupX,
                 swipeAxis === "y" && hasSnapPoints && styles.popupYSnapPoints,
                 popupDirections[swipeDirection],
                 (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                   styles.hidden,
                 (state.swiping || state.nestedDrawerSwiping) && styles.swiping,
                 state.nestedDrawerOpen && styles.nested,
                 state.nestedDrawerOpen && swipeAxis === "y" && styles.nestedY,
                 xstyle,
                 drawerPopup,
               ],
               { className, style },
             )}
           >
             {showSwipeHandle && <DrawerSwipeHandle />}
             <DrawerPrimitive.Content
               data-slot="drawer-content"
               {...bodySx}
               {...restBodyProps}
               className={clsx(bodySx.className, bodyClassName)}
             >
               {children}
             </DrawerPrimitive.Content>
           </DrawerPrimitive.Popup>
         </DrawerViewport>
       </DrawerPortal>
     )
   }

   function DrawerHeader({
     render,
     ref,
     style,
     xstyle,
     className,
     ...props
   }: useRender.ComponentProps<"div"> & Overrides) {
     const sx = stylex.props(styles.header, xstyle)
     return useRender({
       defaultTagName: "div",
       render,
       ref,
       props: {
         "data-slot": "drawer-header",
         ...sx,
         ...props,
         className: clsx(sx.className, className),
         style: { ...sx.style, ...style },
       },
     })
   }

   function DrawerFooter({
     render,
     ref,
     style,
     xstyle,
     className,
     ...props
   }: useRender.ComponentProps<"div"> & Overrides) {
     const sx = stylex.props(states.trayFooter, styles.footer, xstyle)
     return useRender({
       defaultTagName: "div",
       render,
       ref,
       props: {
         "data-slot": "drawer-footer",
         ...sx,
         ...props,
         className: clsx(sx.className, className),
         style: { ...sx.style, ...style },
       },
     })
   }

   function DrawerTitle({ xstyle, className, ...props }: DrawerPrimitive.Title.Props & Overrides) {
     const sx = stylex.props(styles.title, xstyle)
     return (
       <DrawerPrimitive.Title
         data-slot="drawer-title"
         {...sx}
         {...props}
         className={clsx(sx.className, className)}
       />
     )
   }

   function DrawerDescription({
     xstyle,
     className,
     ...props
   }: DrawerPrimitive.Description.Props & Overrides) {
     const sx = stylex.props(styles.description, xstyle)
     return (
       <DrawerPrimitive.Description
         data-slot="drawer-description"
         {...sx}
         {...props}
         className={clsx(sx.className, className)}
       />
     )
   }

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

4. Add this to the end of `styles/theme.css`. On iOS Safari the scrim is
   absolutely positioned, and it needs a positioned `body` to cover the viewport
   once the page has been scrolled.

   ```css title="styles/theme.css"
   body {
     position: relative;
   }
   ```

5. Update the import paths to match your project setup.

## Usage

```tsx
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
```

```tsx
<Drawer>
  <DrawerTrigger render={<Button variant="outline" />}>Open drawer</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Move goal</DrawerTitle>
      <DrawerDescription>Set your daily activity goal.</DrawerDescription>
    </DrawerHeader>
    <VStack padding={4}>{/* Content */}</VStack>
    <DrawerFooter>
      <DrawerClose render={<Button variant="outline" />}>Cancel</DrawerClose>
      <Button>Submit</Button>
    </DrawerFooter>
  </DrawerContent>
</Drawer>
```

## Composition

```text
Drawer
├── DrawerTrigger
└── DrawerContent          portal + overlay + viewport + popup + body
    ├── DrawerSwipeHandle  when `showSwipeHandle` is set
    ├── DrawerHeader
    │   ├── DrawerTitle
    │   └── DrawerDescription
    └── DrawerFooter
        └── DrawerClose
```

`DrawerContent` renders five parts: the portal, the scrim, the fixed viewport,
the popup that moves, and the inner body that clips and scrolls. Reach the three
it wraps through `overlayProps`, `viewportProps` and `bodyProps`, or compose
`DrawerPortal`, `DrawerOverlay` and `DrawerViewport` yourself.

`DrawerHeader` and `DrawerFooter` carry their own padding. Anything between them
does not, so give the body a `VStack` with `padding={4}`.

## Position

Use `swipeDirection` on `Drawer` to pick the edge. The drawer enters from the
edge it is dismissed toward, so `swipeDirection` sets both.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { HStack, VStack } from "@/components/ui/stack"
import { color, radius } from "@/theme/tokens.stylex"

const directions = ["up", "right", "down", "left"] as const

const styles = stylex.create({
  placeholder: {
    flexGrow: 1,
    minHeight: "8rem",
    borderRadius: radius.xl,
    backgroundColor: color.muted,
  },
})

export default function DrawerSides() {
  return (
    <HStack gap={2} wrap="wrap" justify="center">
      {directions.map((direction) => (
        <Drawer key={direction} swipeDirection={direction}>
          <DrawerTrigger render={<Button variant="outline" />}>{direction}</DrawerTrigger>
          <DrawerContent>
            <DrawerHeader>
              <DrawerTitle>Swipe {direction}</DrawerTitle>
              <DrawerDescription>
                A drawer enters from the edge it is dismissed toward.
              </DrawerDescription>
            </DrawerHeader>
            <VStack grow padding={4}>
              <div {...stylex.props(styles.placeholder)} />
            </VStack>
            <DrawerFooter>
              <DrawerClose render={<Button variant="secondary" />}>Close</DrawerClose>
            </DrawerFooter>
          </DrawerContent>
        </Drawer>
      ))}
    </HStack>
  )
}
```

A vertical drawer is the height of its content, capped at `calc(100dvh - 6rem)`.
A horizontal one is `75%` of the viewport width, or `24rem` from `48rem` up.
Override either through `xstyle` on `DrawerContent`.

## Swipe handle

Use `showSwipeHandle` on `Drawer` to render the grab bar. It turns with the
drawer, and it sits on the edge that faces the page.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { HStack, VStack } from "@/components/ui/stack"
import { color, radius } from "@/theme/tokens.stylex"

const styles = stylex.create({
  placeholder: {
    flexGrow: 1,
    minHeight: "8rem",
    borderRadius: radius.xl,
    backgroundColor: color.muted,
  },
})

export default function DrawerSwipeHandleDemo() {
  return (
    <HStack gap={2} wrap="wrap" justify="center">
      <Drawer showSwipeHandle>
        <DrawerTrigger render={<Button variant="outline" />}>Bottom</DrawerTrigger>
        <DrawerContent>
          <DrawerHeader>
            <DrawerTitle>Drag me down</DrawerTitle>
            <DrawerDescription>
              The bar marks the grab target; the strip around it drags too.
            </DrawerDescription>
          </DrawerHeader>
          <VStack grow padding={4}>
            <div {...stylex.props(styles.placeholder)} />
          </VStack>
          <DrawerFooter>
            <DrawerClose render={<Button variant="secondary" />}>Close</DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
      <Drawer showSwipeHandle swipeDirection="right">
        <DrawerTrigger render={<Button variant="outline" />}>Side</DrawerTrigger>
        <DrawerContent>
          <DrawerHeader>
            <DrawerTitle>Drag me right</DrawerTitle>
            <DrawerDescription>A side drawer stands its handle on end.</DrawerDescription>
          </DrawerHeader>
          <VStack grow padding={4}>
            <div {...stylex.props(styles.placeholder)} />
          </VStack>
          <DrawerFooter>
            <DrawerClose render={<Button variant="secondary" />}>Close</DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    </HStack>
  )
}
```

> **Note**
>
> The handle marks the gesture rather than enabling it. A drawer without one is
> still swipeable, and the grab target is the whole 12px strip, not the 4px bar
> drawn inside it.

## Scrollable body

Make the scrolling region a flex child with `grow` and `overflow-y: auto`. A
percentage height does not resolve inside a drawer that is sized by its content.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { VStack } from "@/components/ui/stack"
import { color, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  body: { overflowY: "auto" },
  row: {
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s3,
    paddingBlock: space.s2,
  },
  meta: { color: color.mutedForeground },
})

const releases = [
  { version: "2.4.0", note: "Drawer, hover card", date: "12 Sep" },
  { version: "2.3.1", note: "Checkbox indicator exit", date: "5 Sep" },
  { version: "2.3.0", note: "Checkbox, collapsible", date: "1 Sep" },
  { version: "2.2.0", note: "Avatar, card", date: "24 Aug" },
  { version: "2.1.0", note: "Input, input group, OTP", date: "18 Aug" },
  { version: "2.0.2", note: "Dialog footer bleed", date: "11 Aug" },
  { version: "2.0.1", note: "Alert dialog sizing", date: "7 Aug" },
  { version: "2.0.0", note: "StyleX migration", date: "29 Jul" },
  { version: "1.8.0", note: "Breadcrumb", date: "20 Jul" },
  { version: "1.7.0", note: "Button group", date: "14 Jul" },
]

export default function DrawerScrollable() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>Open changelog</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Changelog</DrawerTitle>
          <DrawerDescription>The body scrolls; the header and footer do not.</DrawerDescription>
        </DrawerHeader>
        <VStack grow gap={2} padding={4} xstyle={styles.body}>
          {releases.map((release) => (
            <div key={release.version} {...stylex.props(styles.row)}>
              <p>
                {release.version} &middot; {release.note}
              </p>
              <p {...stylex.props(styles.meta)}>{release.date}</p>
            </div>
          ))}
        </VStack>
        <DrawerFooter>
          <DrawerClose render={<Button variant="secondary" />}>Close</DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

## Nested

Open a drawer from inside another one. The drawer underneath stays mounted,
scales back and dims, and comes forward again as the one on top is swiped away.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { VStack } from "@/components/ui/stack"
import { color, radius } from "@/theme/tokens.stylex"

const styles = stylex.create({
  placeholder: {
    flexGrow: 1,
    minHeight: "6rem",
    borderRadius: radius.xl,
    backgroundColor: color.muted,
  },
})

export default function DrawerNested() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>Open drawer</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Shipping address</DrawerTitle>
          <DrawerDescription>The drawer behind scales back and dims.</DrawerDescription>
        </DrawerHeader>
        <VStack grow padding={4}>
          <div {...stylex.props(styles.placeholder)} />
        </VStack>
        <DrawerFooter>
          <Drawer showSwipeHandle>
            <DrawerTrigger render={<Button variant="outline" />}>Add a note</DrawerTrigger>
            <DrawerContent>
              <DrawerHeader>
                <DrawerTitle>Delivery note</DrawerTitle>
                <DrawerDescription>
                  Swipe this one down and the drawer underneath comes back with it.
                </DrawerDescription>
              </DrawerHeader>
              <VStack grow padding={4}>
                <div {...stylex.props(styles.placeholder)} />
              </VStack>
              <DrawerFooter>
                <DrawerClose render={<Button variant="secondary" />}>Cancel</DrawerClose>
                <Button>Save note</Button>
              </DrawerFooter>
            </DrawerContent>
          </Drawer>
          <Button>Save address</Button>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

## Non modal

Set `modal={false}` to keep the page behind the drawer interactive. There is no scrim and
no focus trap, and the viewport passes clicks through. Add
`disablePointerDismissal` so a press outside does not close it, or use
`modal="trap-focus"` to keep focus inside while leaving scroll and pointer
alone.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { VStack } from "@/components/ui/stack"
import { color, radius } from "@/theme/tokens.stylex"

const styles = stylex.create({
  placeholder: {
    flexGrow: 1,
    minHeight: "8rem",
    borderRadius: radius.xl,
    backgroundColor: color.muted,
  },
})

export default function DrawerNonModal() {
  return (
    <Drawer modal={false} disablePointerDismissal swipeDirection="right">
      <DrawerTrigger render={<Button variant="outline" />}>Open inspector</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Inspector</DrawerTitle>
          <DrawerDescription>
            No scrim and no focus trap. Scroll the page behind it, and it stays open until you close
            it.
          </DrawerDescription>
        </DrawerHeader>
        <VStack grow padding={4}>
          <div {...stylex.props(styles.placeholder)} />
        </VStack>
        <DrawerFooter>
          <DrawerClose render={<Button variant="secondary" />}>Close</DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

## Snap points

Use `snapPoints` to hold the drawer at set heights. A number between `0` and `1`
is a fraction of the viewport, a number above `1` is pixels, and a string
carries its own unit. Track the active one with `snapPoint` and
`onSnapPointChange`.

```tsx
import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { VStack } from "@/components/ui/stack"
import { color, radius, space } from "@/theme/tokens.stylex"

const snapPoints = [0.3, 0.6, 1]

const styles = stylex.create({
  body: { overflowY: "auto" },
  row: {
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s3,
    paddingBlock: space.s2,
  },
  meta: { color: color.mutedForeground },
})

const commits = [
  { message: "Fix the drawer bleed at fractional zoom", author: "sarah", sha: "9f3a12c" },
  { message: "Pin the scrim to the layout viewport on iOS", author: "mike", sha: "4b81ef0" },
  { message: "Derive the stack scale from the nesting count", author: "lisa", sha: "2c7d445" },
  { message: "Drop the duration while a swipe is live", author: "james", sha: "e10b93a" },
  { message: "Move the swipe handle to the leaving edge", author: "sarah", sha: "77c5d21" },
  { message: "Clamp a vertical drawer to the viewport", author: "mike", sha: "0ab34f9" },
  { message: "Read the snap offset off the popup", author: "lisa", sha: "5de70b6" },
  { message: "Give the handle a full-strip grab target", author: "james", sha: "c92f108" },
]

export default function DrawerSnapPoints() {
  const [snapPoint, setSnapPoint] = useState<number | string | null>(0.3)

  return (
    <Drawer
      showSwipeHandle
      snapPoints={snapPoints}
      snapPoint={snapPoint}
      onSnapPointChange={setSnapPoint}
    >
      <DrawerTrigger render={<Button variant="outline" />}>Open activity</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Recent activity</DrawerTitle>
          <DrawerDescription>
            Drag the handle to move between a peek, a half sheet and full height.
          </DrawerDescription>
        </DrawerHeader>
        <VStack grow gap={2} padding={4} xstyle={styles.body}>
          {commits.map((commit) => (
            <div key={commit.sha} {...stylex.props(styles.row)}>
              <p>{commit.message}</p>
              <p {...stylex.props(styles.meta)}>
                {commit.author} &middot; {commit.sha}
              </p>
            </div>
          ))}
        </VStack>
      </DrawerContent>
    </Drawer>
  )
}
```

> **Note**
>
> Snap points apply to `up` and `down` only. A drawer with snap points is full
> height and moves the content within it, which a side drawer has no room to do.

## Responsive

No prop switches between a drawer and a dialog. Render a drawer on a narrow
viewport and a dialog on a wide one with a media query. Both take the same parts
in the same order.

```tsx
import { useCallback, useSyncExternalStore } from "react"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

function useMediaQuery(query: string) {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const list = window.matchMedia(query)
      list.addEventListener("change", onChange)
      return () => list.removeEventListener("change", onChange)
    },
    [query],
  )

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => false,
  )
}

function ProfileFields() {
  return (
    <VStack gap={4}>
      <VStack gap={2}>
        <Label htmlFor="responsive-email">Email</Label>
        <Input id="responsive-email" type="email" defaultValue="rudro@arc1.io" />
      </VStack>
      <VStack gap={2}>
        <Label htmlFor="responsive-username">Username</Label>
        <Input id="responsive-username" defaultValue="@rudro" />
      </VStack>
    </VStack>
  )
}

export default function DrawerResponsive() {
  const isDesktop = useMediaQuery("(width >= 48rem)")

  if (isDesktop) {
    return (
      <Dialog>
        <DialogTrigger render={<Button variant="outline" />}>Edit profile</DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Edit profile</DialogTitle>
            <DialogDescription>Narrow the window to get the drawer instead.</DialogDescription>
          </DialogHeader>
          <ProfileFields />
          <DialogFooter>
            <Button>Save changes</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    )
  }

  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>Edit profile</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Edit profile</DrawerTitle>
          <DrawerDescription>Widen the window to get the dialog instead.</DrawerDescription>
        </DrawerHeader>
        <VStack padding={4}>
          <ProfileFields />
        </VStack>
        <DrawerFooter>
          <DrawerClose render={<Button variant="secondary" />}>Cancel</DrawerClose>
          <Button>Save changes</Button>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

## API reference

Pass `render` to replace a part's element or compose it with another component.

Every part wraps the Base UI `Drawer` part of the same name and accepts its
props. See the [Base UI reference](https://base-ui.com/react/components/drawer)
for the full list. Each styled part also takes `xstyle` and `className`.

### Drawer

| Prop                      | Type                                  | Default  |
| ------------------------- | ------------------------------------- | -------- |
| `swipeDirection`          | `"up" \| "right" \| "down" \| "left"` | `"down"` |
| `showSwipeHandle`         | `boolean`                             | `false`  |
| `snapPoints`              | `(number \| string)[]`                | —        |
| `snapPoint`               | `number \| string \| null`            | —        |
| `onSnapPointChange`       | `(snapPoint, details) => void`        | —        |
| `snapToSequentialPoints`  | `boolean`                             | `false`  |
| `modal`                   | `boolean \| "trap-focus"`             | `true`   |
| `open`                    | `boolean`                             | —        |
| `defaultOpen`             | `boolean`                             | `false`  |
| `onOpenChange`            | `(open: boolean, details) => void`    | —        |
| `disablePointerDismissal` | `boolean`                             | `false`  |

`showSwipeHandle` is the only prop that is not Base UI's. Everything else is
forwarded, and `swipeDirection`, `modal` and `snapPoints` are also read by the
parts below.

### DrawerContent

| Prop            | Type                                  | Default |
| --------------- | ------------------------------------- | ------- |
| `overlayProps`  | `DrawerOverlayProps`                  | —       |
| `viewportProps` | `DrawerViewportProps`                 | —       |
| `bodyProps`     | `DrawerContentProps` (the inner body) | —       |
| `initialFocus`  | `boolean \| RefObject \| (type) => …` | —       |
| `finalFocus`    | `boolean \| RefObject \| (type) => …` | —       |

### Custom properties

Set these through `xstyle` on `DrawerContent`, or on `[data-slot="drawer-popup"]`
in your own CSS.

| Property                              | Default                 | Effect                                                    |
| ------------------------------------- | ----------------------- | --------------------------------------------------------- |
| `--carbon-drawer-inset`               | `0px`                   | Floats the drawer off the viewport edges                  |
| `--carbon-drawer-bleed`               | the popover fill        | Fills the strip a swipe uncovers past the edge            |
| `--carbon-drawer-overlay-min-opacity` | `0`, `0.5` when snapped | How far the scrim thins as the drawer is dragged away     |
| `--carbon-drawer-peek`                | `1rem`                  | How far a covered drawer shows above the one on top of it |
| `--carbon-drawer-stack-step`          | `0.05`                  | How much a covered drawer scales back per level           |

### Data attributes

| Attribute                                  | On                 | Effect                                         |
| ------------------------------------------ | ------------------ | ---------------------------------------------- |
| `data-slot="drawer-popup"`                 | the box that moves | Popup slot                                     |
| `data-slot="drawer-content"`               | the inner body     | Clips and scrolls; fades out when covered      |
| `data-slot="drawer-overlay"`               | the scrim          | Thins with the swipe                           |
| `data-swipe-direction`, `data-swipe-axis`  | the popup          | `up`/`right`/`down`/`left`, and `x`/`y`        |
| `data-swiping`                             | popup and scrim    | A swipe is in progress, so transitions are off |
| `data-snap-points`, `data-expanded`        | popup and scrim    | Snap points are set; the drawer is at the top  |
| `data-nested-drawer-open`                  | the popup          | Another drawer is open on top of this one      |
| `data-starting-style`, `data-ending-style` | popup and scrim    | Base UI writes them while the drawer animates  |
