Drawer
An edge-attached panel for longer content. Native presentation on every platform; shared intent, tokens, and naming.
Installation
npx felicityui add drawer --platform reactUsage
import { Button } from "@/components/ui/button"
import { Drawer, DrawerBody, DrawerFooter } from "@/components/ui/drawer"
<Drawer
open={open}
onOpenChange={setOpen}
title="Edit profile"
description="Make changes to your profile here."
>
<DrawerBody>{/* fields */}</DrawerBody>
<DrawerFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setOpen(false)}>Save changes</Button>
</DrawerFooter>
</Drawer>Composition
Use the following composition to build a Drawer.
Drawer
├── title
├── description
├── handle (showHandle)
└── children
├── DrawerBody
└── DrawerFooterReact uses a native <dialog> parked on an edge. SwiftUI uses .sheet with detents. Compose uses ModalBottomSheet. Those are different objects on purpose. There is no Vaul, Base UI, or shared overlay runtime.
Use Drawer when the work is longer than a two-button confirm. Use Dialog when it is not.
On the web, side parks the panel on an edge and className can set h-* / max-h-* or w-* / max-w-*. SwiftUI and Compose still present a system bottom sheet — do not clone a left web drawer onto iPhone.
When to use
- Edit a profile, apply filters, or review a list that should scroll.
- A bottom sheet or a side panel that still blocks the page.
- Content with a sticky footer of actions.
When not to use
- A short confirm or acknowledge. Use Dialog.
- App chrome navigation. Use the platform's navigation primitives (
NavigationSplitView,ModalNavigationDraweras a scaffold, not this overlay). - A tooltip or inline help.
Default
Bottom sheet for longer work. Native <dialog> on the web, .sheet on iOS, ModalBottomSheet on Android.
Position
side attaches the web panel to an edge. SwiftUI and Compose still present a bottom sheet — do not clone a left web drawer onto iPhone.
Swipe handle
The handle is a drag affordance on a bottom sheet. Drag down to dismiss on the web. Native sheets use the system indicator.
Scrollable content
DrawerBody scrolls while the title and DrawerFooter stay put. The scrollbar is hidden; the list still scrolls.
Non-modal
modal={false} uses dialog.show() on the web so the page stays interactive. Native sheets remain modal.
RTL
Pass dir="rtl" on the React drawer. SwiftUI and Compose follow the system layout direction.
API Reference
Drawer
| Prop | Type | Default | Description |
|---|---|---|---|
| open / isPresented | boolean / Binding<Bool> | — | Whether the drawer is shown. SwiftUI uses isPresented. |
| onOpenChange / onDismiss | (open: boolean) => void / () -> Unit | — | Called when the user dismisses. React uses onOpenChange; Compose uses onDismiss. |
| title | string | — | Accessible name. Required. |
| description / message / text | string | — | Optional supporting copy. Named description (React), message (SwiftUI), text (Compose). |
| side | "bottom" | "top" | "left" | "right" | bottom | Edge the panel attaches to on the web. SwiftUI and Compose present a bottom sheet for every value. |
| showHandle / showsDragIndicator | boolean | true when side is bottom | Drag handle. React defaults true for bottom. SwiftUI maps to presentationDragIndicator. |
| modal | boolean | true | When false, React uses dialog.show() with no backdrop and no scroll lock. Native sheets stay modal. |
| children / content | ReactNode / @ViewBuilder / @Composable | — | Body and footer. Compose Button here on React; Drawer does not import it. |
| confirmLabel / cancelLabel | string | null | Done / Cancel | Action labels on Compose. Omit cancel with null. |
| onConfirm | () -> Unit | — | Confirm handler on Compose. |
| className | string | — | Additional classes on the dialog element. |
| dir | "ltr" | "rtl" | — | Text direction on the React drawer. Native platforms follow the system layout direction. |
Accessibility
WCAG 2.2 AA
- Must be exposed as a dialog (or the platform sheet equivalent), not a custom overlay
div. - Title is the accessible name.
- Description, when present, is the accessible description.
- Focus moves into the drawer when it opens and returns to the invoker when it closes.
- Escape, the platform dismiss gesture, or a drag-to-dismiss on a bottom sheet closes a non-required drawer.
- Actions are real buttons with visible names.
Platform notes
React
Renders a native HTML <dialog> with showModal(), positioned on an edge. Not a portal library. Bottom sheets can be dragged down to dismiss.
Client Component:showModal()must run in the browser.- Compose actions with Button in children. Drawer does not import Button.
- side is bottom, top, left, or right. Default bottom.
- showHandle defaults to true for bottom. It is a visual affordance; drag the sheet to dismiss.
- modal={false} uses
dialog.show() so the page stays interactive and has no backdrop. - DrawerBody scrolls; DrawerFooter stays put. Hide the scrollbar; keep scrolling.
- Document scroll is locked while a modal drawer is open.
SwiftUI
SwiftUI .sheet with medium and large detents. Not a web-style side card. side is accepted for API parity; iOS presents a system sheet.
VoiceOverannounces the system sheet title.Dynamic Typeis the system sheet type size.- presentationDragIndicator maps to showHandle.
- Leading, trailing, and top still present as
.sheet. Do not clone a left web drawer onto iPhone.
Compose
Material3 ModalBottomSheet with FelicityUI colors. Scrim, back, and TalkBack stay platform-native. side is accepted for API parity; Android presents a bottom sheet.
TalkBackannounces the title.- Back and scrim call onDismiss.
- Start, end, and top still present as ModalBottomSheet. A persistent navigation drawer belongs in the app scaffold, not this overlay.