Drawer

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

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

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See 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.

Copy and paste the following code into your project.

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,
}

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.

styles/theme.css
body {
  position: relative;
}

Update the import paths to match your project setup.

Usage

import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
<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

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.

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.

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>
  )
}

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.

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.

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.

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.

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>
  )
}

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.

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 for the full list. Each styled part also takes xstyle and className.

Drawer

PropTypeDefault
swipeDirection"up" | "right" | "down" | "left""down"
showSwipeHandlebooleanfalse
snapPoints(number | string)[]—
snapPointnumber | string | null—
onSnapPointChange(snapPoint, details) => void—
snapToSequentialPointsbooleanfalse
modalboolean | "trap-focus"true
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
disablePointerDismissalbooleanfalse

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

PropTypeDefault
overlayPropsDrawerOverlayProps—
viewportPropsDrawerViewportProps—
bodyPropsDrawerContentProps (the inner body)—
initialFocusboolean | RefObject | (type) => …—
finalFocusboolean | RefObject | (type) => …—

Custom properties

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

PropertyDefaultEffect
--carbon-drawer-inset0pxFloats the drawer off the viewport edges
--carbon-drawer-bleedthe popover fillFills the strip a swipe uncovers past the edge
--carbon-drawer-overlay-min-opacity0, 0.5 when snappedHow far the scrim thins as the drawer is dragged away
--carbon-drawer-peek1remHow far a covered drawer shows above the one on top of it
--carbon-drawer-stack-step0.05How much a covered drawer scales back per level

Data attributes

AttributeOnEffect
data-slot="drawer-popup"the box that movesPopup slot
data-slot="drawer-content"the inner bodyClips and scrolls; fades out when covered
data-slot="drawer-overlay"the scrimThins with the swipe
data-swipe-direction, data-swipe-axisthe popupup/right/down/left, and x/y
data-swipingpopup and scrimA swipe is in progress, so transitions are off
data-snap-points, data-expandedpopup and scrimSnap points are set; the drawer is at the top
data-nested-drawer-openthe popupAnother drawer is open on top of this one
data-starting-style, data-ending-stylepopup and scrimBase UI writes them while the drawer animates