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 clsxnpm install @base-ui/react @stylexjs/stylex clsxyarn add @base-ui/react @stylexjs/stylex clsxbun add @base-ui/react @stylexjs/stylex clsxSet 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.
"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.
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
└── DrawerCloseDrawerContent 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} · {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} · {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
| 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 |