Drawer
A drawer is a panel that slides up from the bottom of the screen, often used in mobile applications to present additional content or actions.
Features
- Supports modal and non-modal modes
- Focus is trapped and scrolling is blocked in modal mode
- Supports customizable snap points for partial or full expansion
- Allows dragging to adjust the sheet's height or swipe to dismiss
- Provides screen reader announcements via rendered title
- Pressing
Esccloses the drawer - Supports touch gestures for smooth interaction on mobile devices
- Supports multiple triggers sharing a single drawer instance
Installation
Install the drawer package:
npm install @zag-js/drawer @zag-js/react # or yarn add @zag-js/drawer @zag-js/react
npm install @zag-js/drawer @zag-js/solid # or yarn add @zag-js/drawer @zag-js/solid
npm install @zag-js/drawer @zag-js/vue # or yarn add @zag-js/drawer @zag-js/vue
npm install @zag-js/drawer @zag-js/svelte # or yarn add @zag-js/drawer @zag-js/svelte
Anatomy
To use the drawer component correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
Import the drawer package:
import * as drawer from "@zag-js/drawer"
The drawer package exports two key functions:
machine— State machine logic.connect— Maps machine state to JSX props and event handlers.
Pass a unique
idtouseMachineso generated element ids stay predictable.
Then use the framework integration helpers:
import * as drawer from "@zag-js/drawer" import { useMachine, normalizeProps } from "@zag-js/react" import { useId } from "react" export function Drawer() { const service = useMachine(drawer.machine, { id: useId() }) const api = drawer.connect(service, normalizeProps) return ( <> <button {...api.getTriggerProps()}>Open Drawer</button> <div {...api.getBackdropProps()} /> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div {...api.getGrabberProps()}> <div {...api.getGrabberIndicatorProps()} /> </div> <div> <div {...api.getTitleProps()}>Add New Contact</div> <label> <span>Name</span> <input type="text" /> </label> <label> <span>Email</span> <input type="email" /> </label> <div> <button>Add Contact</button> <button onClick={() => api.setOpen(false)}>Cancel</button> </div> </div> </div> </div> </> ) }
import * as drawer from "@zag-js/drawer" import { useMachine, normalizeProps } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" export function Drawer() { const service = useMachine(drawer.machine, { id: createUniqueId() }) const api = createMemo(() => drawer.connect(service, normalizeProps)) return ( <> <button {...api().getTriggerProps()}>Open Drawer</button> <div {...api().getBackdropProps()} /> <div {...api().getPositionerProps()}> <div {...api().getContentProps()}> <div {...api().getGrabberProps()}> <div {...api().getGrabberIndicatorProps()} /> </div> <div> <div {...api().getTitleProps()}>Add New Contact</div> <label> <span>Name</span> <input type="text" /> </label> <label> <span>Email</span> <input type="email" /> </label> <div> <button>Add Contact</button> <button onClick={() => api().setOpen(false)}>Cancel</button> </div> </div> </div> </div> </> ) }
<script setup> import * as drawer from "@zag-js/drawer" import { normalizeProps, useMachine } from "@zag-js/vue" import { useId, computed } from "vue" const service = useMachine(drawer.machine, { id: useId() }) const api = computed(() => drawer.connect(service, normalizeProps)) </script> <template> <div> <button v-bind="api.getTriggerProps()">Open Drawer</button> <div v-bind="api.getBackdropProps()" /> <div v-bind="api.getPositionerProps()"> <div v-bind="api.getContentProps()"> <div> <div v-bind="api.getGrabberProps()"> <div v-bind="api.getGrabberIndicatorProps()" /> </div> <div> <div v-bind="api.getTitleProps()">Add New Contact</div> <label> <span>Name</span> <input type="text" /> </label> <label> <span>Email</span> <input type="email" /> </label> <div> <button>Add Contact</button> <button @click="api.setOpen(false)">Cancel</button> </div> </div> </div> </div> </div> </div> </template>
<script lang="ts"> import * as drawer from "@zag-js/drawer" import { useMachine, normalizeProps } from "@zag-js/svelte" const id = $props.id() const service = useMachine(drawer.machine, { id }) const api = $derived(drawer.connect(service, normalizeProps)) </script> <div> <button {...api.getTriggerProps()}>Open Drawer</button> <div {...api.getBackdropProps()}></div> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div> <div {...api.getGrabberProps()}> <div {...api.getGrabberIndicatorProps()}></div> </div> <div> <div {...api.getTitleProps()}>Add New Contact</div> <label> <span>Name</span> <input type="text" /> </label> <label> <span>Email</span> <input type="email" /> </label> <div> <button>Add Contact</button> <button onclick={() => api.setOpen(false)}>Cancel</button> </div> </div> </div> </div> </div> </div>
Controlled drawer
To control the drawer's open state, pass the open and onOpenChange
properties.
import { useState } from "react" export function ControlledDrawer() { const [open, setOpen] = useState(false) const service = useMachine(drawer.machine, { open, onOpenChange(details) { setOpen(details.open) }, }) return ( // ... ) }
import { createSignal } from "solid-js" export function ControlledDrawer() { const [open, setOpen] = createSignal(false) const service = useMachine(drawer.machine, { get open() { return open() }, onOpenChange(details) { setOpen(details.open) }, }) return ( // ... ) }
<script lang="ts" setup> import { ref } from "vue" const openRef = ref(false) const service = useMachine(drawer.machine, { get open() { return openRef.value }, onOpenChange(details) { openRef.value = details.open }, }) </script>
<script lang="ts"> let open = $state(false) const service = useMachine(drawer.machine, { get open() { return open }, onOpenChange(details) { open = details.open }, }) </script>
Listening for open state changes
When the drawer state changes, the onOpenChange callback is invoked.
const service = useMachine(drawer.machine, { id: useId(), onOpenChange(details) { // details => { open: boolean } console.log("drawer open:", details.open) }, })
Configuring snap points
The drawer supports customizable snap points, which determine the heights at
which the sheet can "snap" when dragged. Snap points can be defined as numbers
(representing percentages of the content height, e.g., 0.5 for 50%) or pixel
values (e.g., "200px"). By default, it snaps to full sheet height (1).
const service = useMachine(drawer.machine, { id: useId(), snapPoints: [0.3, 0.5, 1], // 30%, 50%, and 100% of sheet height defaultSnapPoint: 0.5, // Start at 50% height onSnapPointChange: ({ snapPoint }) => { console.log("Active snap point:", snapPoint) }, })
Controlled snap point
Use snapPoint and onSnapPointChange to control the active snap point.
const service = useMachine(drawer.machine, { id: useId(), snapPoints: [0.3, 0.5, 1], snapPoint, onSnapPointChange(details) { setSnapPoint(details.snapPoint) }, })
Swipe-to-dismiss behavior
The drawer can be dismissed by swiping it down quickly or dragging it below a threshold. Customize this behavior with the following properties:
swipeVelocityThreshold: The minimum velocity (in pixels per second) required to dismiss on swipe (default:700).closeThreshold: The percentage of content height below which the sheet closes when dragged (default:0.25).
const service = useMachine(drawer.machine, { id: useId(), swipeVelocityThreshold: 800, closeThreshold: 0.3, })
Choosing swipe direction
Set swipeDirection to control where the drawer can be dismissed.
const service = useMachine(drawer.machine, { id: useId(), swipeDirection: "down", })
Sequential snap points
Set snapToSequentialPoints to force dragging through snap points in order.
const service = useMachine(drawer.machine, { id: useId(), snapPoints: [0.25, 0.5, 1], snapToSequentialPoints: true, })
Preventing dragging during scroll
By default, dragging is prevented if the content inside the drawer is scrollable
and not at the top or bottom. To allow dragging even during scrolling, set
preventDragOnScroll to false:
const service = useMachine(drawer.machine, { id: useId(), preventDragOnScroll: false, })
Non-modal drawer
Set modal to false to allow interaction behind the drawer.
const service = useMachine(drawer.machine, { id: useId(), modal: false, })
Swipe area
Use getSwipeAreaProps to render a region outside the drawer that users can
swipe to open it (for example, an edge swipe gesture on mobile). The swipe area
is automatically disabled while the drawer is already open.
<div {...api.getSwipeAreaProps()}> {/* invisible hit area along the screen edge */} </div>
You can customize the swipe area's direction and disabled state:
// Disable the swipe area api.getSwipeAreaProps({ disabled: true }) // Override the open direction (defaults to opposite of drawer's swipeDirection) api.getSwipeAreaProps({ swipeDirection: "up" })
Multiple triggers
A single drawer instance can be shared across multiple trigger elements. Pass a
value to getTriggerProps to identify each trigger.
const service = useMachine(drawer.machine, { id: useId(), onTriggerValueChange({ value }) { // value is the id of the trigger that activated the drawer console.log("active trigger:", value) }, }) const api = drawer.connect(service, normalizeProps) return ( <> <button {...api.getTriggerProps({ value: "settings" })}>Settings</button> <button {...api.getTriggerProps({ value: "profile" })}>Profile</button> {/* single shared drawer */} <div {...api.getBackdropProps()} /> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}>{/* ... */}</div> </div> </> )
When the drawer is open and a different trigger is activated, it switches without closing. Focus returns to the correct trigger on close.
Content draggable
By default the drawer content itself can be dragged to dismiss or snap. To
restrict dragging to only the grabber, pass draggable: false to
getContentProps.
<div {...api.getContentProps({ draggable: false })}> <div {...api.getGrabberProps()}> <div {...api.getGrabberIndicatorProps()} /> </div> {/* Content here is not draggable */} </div>
Using drawer stack
When your app shows multiple drawers that stack on top of each other (with
indent and background effects), use createStack and connectStack to
coordinate them.
import * as drawer from "@zag-js/drawer" // Create a shared stack (once, at the app level) const stack = drawer.createStack()
Pass the stack to each drawer machine:
const service = useMachine(drawer.machine, { id: useId(), stack, })
Subscribe to the stack snapshot and connect it to render indent layers:
const snapshot = useSyncExternalStore(stack.subscribe, stack.getSnapshot) const stackApi = drawer.connectStack(snapshot, normalizeProps) return ( <div {...stackApi.getIndentProps()}> <div {...stackApi.getIndentBackgroundProps()} /> {/* drawer instances */} </div> )
The indent element exposes data-active / data-inactive attributes and the
CSS variables --drawer-swipe-progress and --drawer-frontmost-height for
styling.
Closing with Escape
Set closeOnEscape to false if the drawer should not close on Esc.
const service = useMachine(drawer.machine, { id: useId(), closeOnEscape: false, })
Reading drawer state
The api provides methods to read the drawer's current position:
api.getOpenPercentage()— returns a value between0(fully closed) and1(fully open at the current snap point).api.getSnapPointIndex()— returns the index of the active snap point in thesnapPointsarray, or-1if none matches.api.getContentSize()— returns the drawer content size in pixels (along the swipe axis), ornullif not yet measured.
const percentage = api.getOpenPercentage() const index = api.getSnapPointIndex() const size = api.getContentSize()
Styling guide
Each part includes a data-part attribute you can target in CSS.
Open and closed state
The drawer exposes data-state attributes you can use for open/closed styles:
[data-part="content"][data-state="open|closed"] { /* styles for the content in different states */ } [data-part="trigger"][data-state="open|closed"] { /* styles for the trigger in different states */ }
Swipe and drag state
Target active swipe or drag interactions:
[data-part="content"][data-swiping] { /* during any swipe or drag interaction */ } [data-part="content"][data-dragging] { /* during drag only (not swipe-to-open) */ } [data-part="content"][data-expanded] { /* when at the fully expanded snap point */ } [data-part="content"][data-swipe-direction] { /* physical swipe direction: "up" | "down" | "left" | "right" */ } [data-part="backdrop"][data-swiping] { /* backdrop during swipe or drag */ } [data-part="swipeArea"][data-disabled] { /* when swipe area is disabled */ } [data-part="swipeArea"][data-swipe-direction] { /* physical open direction */ }
Animating the drawer
The content element uses
transform: translate3d(var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0)
by default. Reference these variables in your keyframes:
@keyframes slideIn { from { transform: translate3d(0, 100%, 0); } to { transform: translate3d( var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0 ); } } @keyframes slideOut { from { transform: translate3d( var(--drawer-translate-x, 0px), var(--drawer-translate-y, 0px), 0 ); } to { transform: translate3d(0, 100%, 0); } } [data-scope="drawer"][data-part="content"][data-state="open"] { animation: slideIn 500ms cubic-bezier(0.32, 0.72, 0, 1); } [data-scope="drawer"][data-part="content"][data-state="closed"] { animation: slideOut 500ms cubic-bezier(0.32, 0.72, 0, 1); }
Animation CSS variables
The following CSS variables are set on the content element:
| Variable | Description |
|---|---|
--drawer-translate-x | Horizontal translate value |
--drawer-translate-y | Vertical translate value |
--drawer-snap-point-offset-x | Snap point offset on the x-axis |
--drawer-snap-point-offset-y | Snap point offset on the y-axis |
--drawer-swipe-movement-x | Current swipe movement on x-axis |
--drawer-swipe-movement-y | Current swipe movement on y-axis |
--drawer-swipe-strength | Numeric swipe strength |
The following CSS variables are set on the backdrop element:
| Variable | Description |
|---|---|
--drawer-swipe-progress | 0-1 progress value, useful for backdrop opacity |
--drawer-swipe-strength | Numeric swipe strength |
Methods and Properties
The drawer's api exposes the following methods and properties:
Machine Context
The drawer machine exposes the following context properties:
idsElementIds | undefinedThe ids of the elements in the drawer. Useful for composition.trapFocusboolean | undefinedWhether to trap focus inside the sheet when it's opened.preventScrollboolean | undefinedWhether to prevent scrolling behind the sheet when it's openedmodalboolean | undefinedWhether to prevent pointer interaction outside the element and hide all content below it.initialFocusEl(() => MaybeElement) | undefinedElement to receive focus when the sheet is opened.finalFocusEl(() => MaybeElement) | undefinedElement to receive focus when the sheet is closed.restoreFocusboolean | undefinedWhether to restore focus to the element that had focus before the sheet was opened.role"dialog" | "alertdialog" | undefinedThe sheet's roletriggerValuestring | null | undefinedThe value of the trigger that currently controls the drawer.defaultTriggerValuestring | null | undefinedThe default trigger value (uncontrolled).onTriggerValueChange((details: TriggerValueChangeDetails) => void) | undefinedCallback when the active trigger value changes.openboolean | undefinedWhether the drawer is open.defaultOpenboolean | undefinedThe initial open state of the drawer.onOpenChange((details: OpenChangeDetails) => void) | undefinedFunction called when the open state changes.closeOnInteractOutsideboolean | undefinedWhether to close the drawer when the outside is clicked.closeOnEscapeboolean | undefinedWhether to close the drawer when the escape key is pressed.snapPointsSnapPoint[] | undefinedThe snap points of the drawer. Array of numbers or strings representing the snap points.swipeDirectionSwipeDirection | undefinedThe direction in which the drawer can be swiped.snapToSequentialPointsboolean | undefinedWhether the drawer should snap to sequential points when swiping.swipeVelocityThresholdnumber | undefinedThe threshold velocity (in pixels/s) for closing the drawer.closeThresholdnumber | undefinedThe threshold distance for dismissing the drawer.preventDragOnScrollboolean | undefinedWhether to prevent dragging on scrollable elements. When enabled, the sheet will not start dragging if the user is interacting with a scrollable element.snapPointSnapPoint | null | undefinedThe currently active snap point.defaultSnapPointSnapPoint | null | undefinedThe default snap point of the drawer.onSnapPointChange((details: SnapPointChangeDetails) => void) | undefinedCallback fired when the snap point changes.stackDrawerStack | undefinedOptional external store for coordinating app-level drawer stack visuals (e.g. indent and background layers).dir"ltr" | "rtl" | undefinedThe document's text/writing direction.idstringThe unique identifier of the machine.getRootNode(() => ShadowRoot | Document | Node) | undefinedA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.
Machine API
The drawer api exposes the following methods:
openbooleanWhether the drawer is open.draggingbooleanWhether the drawer is currently being dragged.triggerValuestring | nullThe value of the active trigger.setTriggerValue(value: string | null) => voidSet the active trigger value.setOpen(open: boolean) => voidFunction to open or close the menu.snapPointsSnapPoint[]The snap points of the drawer.swipeDirectionSwipeDirectionThe swipe direction of the drawer.snapPointSnapPoint | nullThe currently active snap point.setSnapPoint(snapPoint: SnapPoint | null) => voidFunction to set the active snap point.getOpenPercentage() => numberGet the current open percentage of the drawer.getSnapPointIndex() => numberGet the index of the currently active snap point.getContentSize() => number | nullGet the current main-axis size of the drawer content.