Skip to content

Drawer

A drawer component for React.

Code
import {
Badge,
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
RadioGroup,
RadioGroupItem,
toast,
} from "@falcon/ui-kit";
import { useState, useSyncExternalStore } from "react";
const deliveryTimes = [
{
value: "asap",
id: "delivery-asap",
label: "Next available truck",
description: "Today - dispatch assigns a driver now",
badge: "Fastest",
},
{
value: "7-00",
id: "delivery-7-00",
label: "7:00 AM - 9:00 AM",
description: "Loading starts at 6:30 AM",
},
{
value: "9-00",
id: "delivery-9-00",
label: "9:00 AM - 11:00 AM",
description: "Good if you're spraying after lunch",
},
{
value: "1-00",
id: "delivery-1-00",
label: "1:00 PM - 3:00 PM",
description: "Most popular - high demand",
},
{
value: "3-00",
id: "delivery-3-00",
label: "3:00 PM - 5:00 PM",
description: "Last window before the warehouse closes",
},
];
export function Example() {
const [open, setOpen] = useState(false);
const [deliveryTime, setDeliveryTime] = useState("asap");
const isMobile = useIsMobile();
function handleConfirm() {
const selected = deliveryTimes.find((time) => time.value === deliveryTime);
if (!selected) {
return;
}
setOpen(false);
toast("Delivery time confirmed", {
description: selected.label,
});
}
return (
<Drawer
open={open}
onOpenChange={setOpen}
showSwipeHandle={isMobile}
swipeDirection={isMobile ? "down" : "right"}
>
<DrawerTrigger render={<Button variant="secondary" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Pick a delivery time</DrawerTitle>
<DrawerDescription>
We&apos;ll prepare your order as soon as possible.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:overflow-y-auto tw:p-4">
<RadioGroup
value={deliveryTime}
onValueChange={setDeliveryTime}
className="tw:gap-2"
>
{deliveryTimes.map((time) => (
<FieldLabel key={time.value} htmlFor={time.id}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle className="tw:flex tw:items-center tw:gap-2">
{time.label}
{time.badge ? (
<Badge variant="secondary">{time.badge}</Badge>
) : null}
</FieldTitle>
<FieldDescription>{time.description}</FieldDescription>
</FieldContent>
<RadioGroupItem value={time.value} id={time.id} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<DrawerFooter>
<Button onClick={handleConfirm} className="tw:h-[34px]">
Confirm Delivery Time
</Button>
<DrawerClose render={<Button variant="outline" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
const MOBILE_QUERY = "(max-width: 767px)";
function subscribeToIsMobile(onChange: () => void) {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function useIsMobile() {
return useSyncExternalStore(
subscribeToIsMobile,
() => window.matchMedia(MOBILE_QUERY).matches,
() => false,
);
}

Use the following composition to build a Drawer:

Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooter

DrawerContent composes the portal, overlay, viewport, and popup. For lower-level control, DrawerSwipeHandle is also exported.

A vertical drawer sizes itself to its content and is capped at calc(100dvh - 6rem) by default. A side drawer spans 75% of the viewport width, or 24rem on larger screens.

To customize the height of a vertical drawer, use the tw:h-* and tw:max-h-* utilities on DrawerContent.

<DrawerContent className="tw:h-[50vh]">

To customize the width of a side drawer, use the tw:w-* and tw:max-w-* utilities on DrawerContent.

<DrawerContent className="tw:w-96">

When the same component renders in multiple directions, scope an override to one axis using the tw:data-[swipe-axis=*] variants.

<DrawerContent className="tw:data-[swipe-axis=y]:max-h-[50vh] tw:data-[swipe-axis=x]:w-96">

To make a region of the drawer scrollable, make the scroll container a flex item. Avoid tw:h-full, which does not resolve inside a content-sized drawer.

<DrawerContent>
<DrawerHeader>...</DrawerHeader>
<div className="tw:flex-1 tw:overflow-y-auto tw:p-4">
{/* Scrollable content */}
</div>
<DrawerFooter>...</DrawerFooter>
</DrawerContent>

The drawer exposes CSS variables for style-level customization. Set the sizing variables on DrawerContent. Set the overlay variable on [data-slot=drawer-overlay] in your CSS.

Variable Default Description
--drawer-inset 0px Floats the drawer from the viewport edges.
--drawer-bleed-background var(--color-popover) Fills the gap behind the drawer on swipe overshoot.
--drawer-overlay-min-opacity 0 Minimum overlay opacity. Defaults to 0.5 when snap points are active.

The drawer also sets data attributes you can target with variants such as tw:data-[swipe-direction=down]: on DrawerContent, or tw:group-data-[swipe-axis=y]/drawer-popup: on its descendants.

Attribute Values Set when
data-swipe-direction up, right, down, left Always.
data-swipe-axis x, y Always.
data-snap-points Present The drawer has snap points.
data-expanded Present The drawer is at the full snap point.
data-swiping Present A swipe is in progress.
data-nested-drawer-open Present A nested drawer is open on top.

Use the swipeDirection prop to set the side of the drawer.

Available options are up, right, down, and left.

Code
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@falcon/ui-kit";
export function Example() {
return (
<Drawer swipeDirection="left">
<DrawerTrigger render={<Button variant="secondary" />}>
Open Left Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Order Summary</DrawerTitle>
<DrawerDescription>
Review line items before submitting.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:size-full tw:rounded-2xl tw:bg-muted" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

Use showSwipeHandle on Drawer to render a swipe handle.

Code
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@falcon/ui-kit";
export function Example() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="secondary" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>Drawer with a swipe handle.</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:rounded-2xl tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:h-80 tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

Open drawers from inside another drawer. Parent drawers stay mounted and stack behind the frontmost drawer.

Code
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@falcon/ui-kit";
import { useSyncExternalStore } from "react";
export function Example() {
const isMobile = useIsMobile();
const swipeDirection = isMobile ? "down" : "right";
return (
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
<DrawerTrigger render={<Button variant="secondary" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>
Open another drawer from the same direction.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:aspect-video tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
<DrawerTrigger render={<Button variant="outline" />}>
Open Nested Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Nested Drawer</DrawerTitle>
<DrawerDescription>
The parent drawer stays mounted behind this one.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:aspect-video tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer
showSwipeHandle={isMobile}
swipeDirection={swipeDirection}
>
<DrawerTrigger render={<Button variant="outline" />}>
Open Third Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Third Drawer</DrawerTitle>
<DrawerDescription>
Two drawers are stacked behind this one.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:aspect-video tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer
showSwipeHandle={isMobile}
swipeDirection={swipeDirection}
>
<DrawerTrigger render={<Button variant="outline" />}>
Open Fourth Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Fourth Drawer</DrawerTitle>
<DrawerDescription>
This is the frontmost drawer in the stack.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:aspect-video tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
const MOBILE_QUERY = "(max-width: 767px)";
function subscribeToIsMobile(onChange: () => void) {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function useIsMobile() {
return useSyncExternalStore(
subscribeToIsMobile,
() => window.matchMedia(MOBILE_QUERY).matches,
() => false,
);
}

Set modal={false} to allow interaction with the rest of the page while the drawer is open. Combine with disablePointerDismissal to prevent the drawer from closing on outside presses. Use modal="trap-focus" to keep focus inside the drawer while leaving scroll and pointer interaction unrestricted.

Code
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@falcon/ui-kit";
export function Example() {
return (
<Drawer modal={false} disablePointerDismissal swipeDirection="right">
<DrawerTrigger render={<Button variant="outline" />}>
Non Modal
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Non Modal Drawer</DrawerTitle>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:rounded-2xl tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:h-80 tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

Use snapPoints to snap a drawer to preset heights. Numbers between 0 and 1 represent fractions of the viewport. Numbers greater than 1 are treated as pixel values. String values support px and rem units. Snap points apply to vertical drawers.

Track the active snap point with the controlled snapPoint and onSnapPointChange props. At the full snap point, the drawer gets a data-expanded attribute you can style with the tw:data-expanded: variant.

Code
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@falcon/ui-kit";
const SNAP_POINTS = ["10rem", 1];
export function Example() {
return (
<Drawer snapPoints={SNAP_POINTS} showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
Open Snap Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Snap points</DrawerTitle>
<DrawerDescription>
Drag the drawer to snap between a compact peek and a near
full-height view.
</DrawerDescription>
</DrawerHeader>
<div className="tw:flex-1 tw:p-4">
<div className="tw:rounded-2xl tw:bg-muted tw:group-data-[swipe-axis=x]/drawer-popup:size-full tw:group-data-[swipe-axis=y]/drawer-popup:h-80 tw:group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

You can combine the Dialog and Drawer components to create a responsive dialog. This renders a Dialog component on desktop and a Drawer on mobile.

Code
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Input,
Label,
} from "@falcon/ui-kit";
import { useCallback, useState, useSyncExternalStore } from "react";
export function Example() {
const [open, setOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline" />}>
Edit Profile
</DialogTrigger>
<DialogContent className="tw:sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you&apos;re
done.
</DialogDescription>
</DialogHeader>
<ProfileForm />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger render={<Button variant="outline" />}>
Edit Profile
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="tw:text-left">
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>
Make changes to your profile here. Click save when you&apos;re done.
</DrawerDescription>
</DrawerHeader>
<ProfileForm className="tw:p-4" />
</DrawerContent>
</Drawer>
);
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
return (
<form
className={["tw:grid tw:items-start tw:gap-6", className]
.filter(Boolean)
.join(" ")}
>
<div className="tw:grid tw:gap-3">
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" defaultValue="mlarsen@example.com" />
</div>
<div className="tw:grid tw:gap-3">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@mlarsen" />
</div>
<Button type="submit">Save changes</Button>
</form>
);
}
function useMediaQuery(query: string) {
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false,
);
}

Groups a drawer’s parts.

actionsRef?: React.RefObject<DrawerRootActions | null>

Ref to imperative close and unmount actions.

children?: React.ReactNode | PayloadChildRenderFunction<unknown>

The content of the drawer.

defaultOpen?: boolean

Whether the drawer is initially open. Use open to control it.

defaultSnapPoint?: DrawerSnapPoint | null

Initial snap point for an uncontrolled drawer.

defaultTriggerId?: string | null

ID of the trigger associated with an initially open drawer.

disablePointerDismissal?: boolean

Whether to prevent the drawer from closing on outside presses.

handle?: DrawerPrimitive.Handle<unknown>

Associates the drawer with detached triggers.

modal?: boolean | "trap-focus" = true

Determines if the drawer enters a modal state when open. true traps focus, locks page scroll, and disables pointer interactions outside the drawer. false allows interaction with the rest of the document. 'trap-focus' traps focus without locking scroll or disabling outside pointer interactions.

onOpenChange?: ((open: boolean, eventDetails: DrawerPrimitive.Root.ChangeEventDetails) => void)

Called when the drawer opens or closes.

onOpenChangeComplete?: ((open: boolean) => void)

Called after opening or closing animations finish.

onSnapPointChange?: ((snapPoint: DrawerSnapPoint | null, eventDetails: DrawerPrimitive.Root.SnapPointChangeEventDetails) => void)

Called when the active snap point changes.

open?: boolean

Whether the drawer is open.

showSwipeHandle?: boolean = false

Whether DrawerContent renders a DrawerSwipeHandle.

snapPoint?: DrawerSnapPoint | null

Controlled active snap point.

snapPoints?: DrawerSnapPoint[]

Ordered positions where a vertical drawer settles.

snapToSequentialPoints?: boolean

Whether to disable velocity-based skipping between snap points.

swipeDirection?: SwipeDirection = "down"

Edge from which the drawer opens and toward which it dismisses.

triggerId?: string | null

ID of the trigger associated with a controlled drawer.

Closes the associated drawer. Renders a <button> element and accepts standard <button> props. Use render to replace the element.

className?: string | ((state: DrawerCloseState) => string | undefined)

CSS class applied to the element.

nativeButton?: boolean

Whether render returns a native button.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DrawerCloseState>

Replaces the rendered element or composes it with another component.

style?: React.CSSProperties | ((state: DrawerCloseState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns one from state.

Includes the portal, viewport, and popup, plus a backdrop when the drawer is modal. Renders a <div> element and accepts standard <div> props. Use render to replace the element. Use it with a DrawerTitle and DrawerDescription so the drawer has an accessible name and description.

className?: string

CSS class applied to the drawer.

finalFocus?: boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | HTMLElement | null | void)

Determines the element to focus when the drawer closes.

initialFocus?: boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | HTMLElement | null | void)

Determines the element to focus when the drawer opens.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DrawerPopupState>

Replaces the rendered drawer or composes it with another component.

style?: React.CSSProperties | ((state: DrawerPopupState) => React.CSSProperties | undefined)

Style applied to the drawer, or a function that returns one from state.

Provides the drawer’s accessible description. Renders a <p> element and accepts standard <p> props. Use render to replace the element.

className?: string

CSS class applied to the element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DrawerDescriptionState>

Replaces the rendered description or composes it with another component.

style?: React.CSSProperties | ((state: DrawerDescriptionState) => React.CSSProperties | undefined)

Style applied to the description, or a function that returns one from state.

Contains drawer actions. Renders a <div> element and accepts standard <div> props.

Groups DrawerTitle and DrawerDescription. Renders a <div> element and accepts standard <div> props.

Provides a pointer affordance for swiping the drawer. Renders a <div> element and accepts standard <div> props. DrawerContent renders one automatically when the root’s showSwipeHandle is set; render it directly only for custom placement, leaving showSwipeHandle off to avoid a duplicate handle.

Provides the drawer’s accessible name. Renders an <h2> element and accepts standard <h2> props. Use render to replace the element.

className?: string

CSS class applied to the element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DrawerTitleState>

Replaces the rendered title or composes it with another component.

style?: React.CSSProperties | ((state: DrawerTitleState) => React.CSSProperties | undefined)

Style applied to the title, or a function that returns one from state.

Opens the associated drawer. Renders a <button> element and accepts standard <button> props. Use render to replace the element.

className?: string | ((state: DrawerTriggerState) => string | undefined)

CSS class applied to the element.

handle?: DrawerPrimitive.Handle<unknown>

Associates the trigger with a detached drawer.

id?: string

ID of the trigger, used with triggerId in controlled mode.

nativeButton?: boolean

Whether render returns a native button.

payload?: unknown

Payload passed to a detached drawer.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DrawerTriggerState>

Replaces the rendered element or composes it with another component.

style?: React.CSSProperties | ((state: DrawerTriggerState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns one from state.