Drawer
Slide-out panel for supplementary content and actions
Usage
import { Drawer, Button } from "@lenso/ui";"use client";
// Adapted from HeroUI v3.2.6 drawer-basic (Apache-2.0).import { Button, Drawer } from "@lenso/ui";
export function Basic() { return ( <Drawer.Provider> <Drawer swipeDirection="right"> <Drawer.Trigger render={<Button variant="secondary" />}>Open Drawer</Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Header> <Drawer.Title>Drawer Title</Drawer.Title> </Drawer.Header> <Drawer.Body> <p> This is a bottom drawer built with React Aria's Modal component. It slides up from the bottom of the screen with a smooth CSS transition. </p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Cancel</Drawer.Close> <Drawer.Close render={<Button />}>Confirm</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Anatomy
import { Drawer, Button } from "@lenso/ui";
export default () => ( <Drawer> <Button>Open Drawer</Button> <Drawer.Backdrop> <Drawer.Content> <Drawer.Dialog> <Drawer.Handle /> {/* Optional: Drag handle */} <Drawer.CloseTrigger /> {/* Optional: Close button */} <Drawer.Header> <Drawer.Heading /> </Drawer.Header> <Drawer.Body /> <Drawer.Footer /> </Drawer.Dialog> </Drawer.Content> </Drawer.Backdrop> </Drawer>);Examples
Placement
"use client";
// Adapted from HeroUI v3.2.6 drawer-placements (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Button, CloseIcon, Drawer } from "@lenso/ui";import { styles } from "./styles";
export function Placements() { const placements = ["bottom", "top", "left", "right"] as const; return ( <div {...stylex.props(styles.row)}> {placements.map((placement) => ( <Drawer.Provider key={placement}> <Drawer swipeDirection={ placement === "bottom" ? "down" : placement === "top" ? "up" : placement } > <Drawer.Trigger render={<Button variant="secondary" />}> {placement.charAt(0).toUpperCase() + placement.slice(1)} </Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> {placement === "bottom" && <Drawer.Handle />} <Drawer.Header> <Drawer.Title> {placement.charAt(0).toUpperCase() + placement.slice(1)} Drawer </Drawer.Title> </Drawer.Header> <Drawer.Body> <p> This drawer slides in from the <strong>{placement}</strong> edge of the screen. </p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Cancel</Drawer.Close> <Drawer.Close render={<Button />}>Done</Drawer.Close> </Drawer.Footer> {placement === "top" && <Drawer.Handle />} </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> ))} </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Non-Dismissable
Set isDismissable={false} on Drawer.Backdrop to prevent closing by clicking outside or dragging. The user must interact with the drawer's action buttons.
"use client";
// Adapted from HeroUI v3.2.6 drawer-non-dismissable (Apache-2.0).import { Button, Drawer } from "@lenso/ui";
export function NonDismissable() { return ( <Drawer.Provider> <Drawer swipeDirection="down" onOpenChange={(_open, details) => { if (details.reason === "outside-press" || details.reason === "swipe") details.cancel(); }} > <Drawer.Trigger render={<Button variant="secondary" />}>Important Action</Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Header> <Drawer.Title>Confirm Action</Drawer.Title> </Drawer.Header> <Drawer.Body> <p> This drawer cannot be dismissed by clicking outside or dragging. You must use one of the buttons below. </p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Cancel</Drawer.Close> <Drawer.Close render={<Button />}>Confirm</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Scrollable Content
The Drawer.Body automatically handles overflow with native scrolling. Drag-to-dismiss is excluded from the body area to avoid scroll conflicts.
"use client";
// Adapted from HeroUI v3.2.6 drawer-scrollable-content (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Button, CloseIcon, Drawer } from "@lenso/ui";import { styles } from "./styles";
export function ScrollableContent() { return ( <Drawer.Provider> <Drawer swipeDirection="down"> <Drawer.Trigger render={<Button variant="secondary" />}>Terms & Conditions</Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Handle /> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> <Drawer.Header> <Drawer.Title>Terms & Conditions</Drawer.Title> </Drawer.Header> <Drawer.Body> {Array.from({ length: 20 }, (_, i) => ( <p key={i} {...stylex.props(styles.paragraph)}> Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. </p> ))} </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Decline</Drawer.Close> <Drawer.Close render={<Button />}>Accept</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Controlled State
"use client";
// Adapted from HeroUI v3.2.6 drawer-controlled (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Button, CloseIcon, Drawer, useOverlayState } from "@lenso/ui";import React from "react";import { styles } from "./styles";
export function Controlled() { const [isOpen, setIsOpen] = React.useState(false); const state = useOverlayState(); return ( <div {...stylex.props(styles.controlled)}> <div {...stylex.props(styles.section)}> <h3 {...stylex.props(styles.heading)}>With React.useState()</h3> <p {...stylex.props(styles.explanation)}> Control the drawer using React's <code>useState</code> hook for simple state management. </p> <Drawer.Provider> <Drawer open={isOpen} onOpenChange={setIsOpen} swipeDirection="right"> <div {...stylex.props(styles.stateCard)}> <p {...stylex.props(styles.status)}> Status:{" "} <span {...stylex.props(styles.statusValue)}>{isOpen ? "open" : "closed"}</span> </p> <div {...stylex.props(styles.actions)}> <Drawer.Trigger render={<Button size="sm" variant="secondary" />}> Open Drawer </Drawer.Trigger> <Button size="sm" variant="tertiary" onClick={() => setIsOpen(!isOpen)}> Toggle </Button> </div> </div> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> <Drawer.Header> <Drawer.Title>Controlled with useState()</Drawer.Title> </Drawer.Header> <Drawer.Body> <p> This drawer is controlled by React's <code>useState</code> hook. Pass{" "} <code>isOpen</code> and <code>onOpenChange</code> props to manage the drawer state externally. </p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Close</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> </div> <div {...stylex.props(styles.section)}> <h3 {...stylex.props(styles.heading)}>With useOverlayState()</h3> <p {...stylex.props(styles.explanation)}> Use the <code>useOverlayState</code> hook for a cleaner API with convenient methods like{" "} <code>open()</code>, <code>close()</code>, and <code>toggle()</code>. </p> <Drawer.Provider> <Drawer open={state.isOpen} onOpenChange={state.setOpen} swipeDirection="right"> <div {...stylex.props(styles.stateCard)}> <p {...stylex.props(styles.status)}> Status:{" "} <span {...stylex.props(styles.statusValue)}> {state.isOpen ? "open" : "closed"} </span> </p> <div {...stylex.props(styles.actions)}> <Drawer.Trigger render={<Button size="sm" variant="secondary" />} onClick={state.open} > Open Drawer </Drawer.Trigger> <Button size="sm" variant="tertiary" onClick={state.toggle}> Toggle </Button> </div> </div> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> <Drawer.Header> <Drawer.Title>Controlled with useOverlayState()</Drawer.Title> </Drawer.Header> <Drawer.Body> <p> The <code>useOverlayState</code> hook provides dedicated methods for common operations. No need to manually create callbacks—just use{" "} <code>state.open()</code>, <code>state.close()</code>, or{" "} <code>state.toggle()</code>. </p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Close</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> </div> </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
With Form
"use client";
// Adapted from HeroUI v3.2.6 drawer-with-form (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Button, CloseIcon, Drawer, Input, Label, TextField } from "@lenso/ui";import { styles } from "./styles";
export function WithForm() { return ( <Drawer.Provider> <Drawer swipeDirection="right"> <Drawer.Trigger render={<Button variant="secondary" />}>Edit Profile</Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> <Drawer.Header> <Drawer.Title>Edit Profile</Drawer.Title> </Drawer.Header> <Drawer.Body> <form {...stylex.props(styles.form)}> <TextField fullWidth name="name"> <Label>Name</Label> <Input type="text" placeholder="Enter your name" variant="secondary" /> </TextField> <TextField fullWidth name="email"> <Label>Email</Label> <Input type="email" placeholder="Enter your email" variant="secondary" /> </TextField> <TextField fullWidth name="bio"> <Label>Bio</Label> <Input placeholder="Tell us about yourself" variant="secondary" /> </TextField> </form> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Cancel</Drawer.Close> <Drawer.Close render={<Button />}>Save Changes</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Navigation Drawer
"use client";
// Adapted from HeroUI v3.2.6 drawer-navigation (Apache-2.0).import type { ComponentType, SVGProps } from "react";import { Bars, Bell, Envelope, Gear, House, Magnifier, Person } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, CloseIcon, Drawer } from "@lenso/ui";import { styles } from "./styles";
export function Navigation() { const navItems: { icon: ComponentType<SVGProps<SVGSVGElement>>; label: string }[] = [ { icon: House, label: "Home" }, { icon: Magnifier, label: "Search" }, { icon: Bell, label: "Notifications" }, { icon: Envelope, label: "Messages" }, { icon: Person, label: "Profile" }, { icon: Gear, label: "Settings" }, ]; return ( <Drawer.Provider> <Drawer swipeDirection="left"> <Drawer.Trigger render={<Button variant="secondary" />}> <Button.Icon> <Bars /> </Button.Icon> Menu </Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> <Drawer.Header> <Drawer.Title>Navigation</Drawer.Title> </Drawer.Header> <Drawer.Body> <nav {...stylex.props(styles.navigation)}> {navItems.map((item) => ( <button key={item.label} {...stylex.props(styles.navButton)} type="button"> <item.icon {...stylex.props(styles.icon)} /> {item.label} </button> ))} </nav> </Drawer.Body> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Backdrop Variants
"use client";
// Adapted from HeroUI v3.2.6 drawer-backdrop-variants (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Button, CloseIcon, Drawer } from "@lenso/ui";import { styles } from "./styles";
export function BackdropVariants() { const variants = ["opaque", "blur", "transparent"] as const; return ( <div {...stylex.props(styles.row)}> {variants.map((variant) => ( <Drawer.Provider key={variant}> <Drawer swipeDirection="down"> <Drawer.Trigger render={<Button variant="secondary" />}> {variant.charAt(0).toUpperCase() + variant.slice(1)} </Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop variant={variant} /> <Drawer.Viewport> <Drawer.Popup> <Drawer.Content> <Drawer.Handle /> <Drawer.Close aria-label="Close drawer" render={ <Button isIconOnly size="sm" variant="tertiary" xstyle={styles.cornerClose} /> } > <CloseIcon /> </Drawer.Close> <Drawer.Header> <Drawer.Title> Backdrop: {variant.charAt(0).toUpperCase() + variant.slice(1)} </Drawer.Title> </Drawer.Header> <Drawer.Body> <p> This drawer uses the <code>{variant}</code> backdrop variant. </p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button xstyle={styles.fullWidth} />}> Close </Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> ))} </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Customization
Tailwind CSS
"use client";
// Adapted from HeroUI v3.2.6 drawer-custom-styles (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Button, Drawer } from "@lenso/ui";import { styles } from "./styles";
export function CustomStyles() { return ( <Drawer.Provider> <Drawer swipeDirection="right"> <Drawer.Trigger render={<Button variant="secondary" />}>Open filters</Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop variant="blur" /> <Drawer.Viewport> <Drawer.Popup xstyle={styles.filters}> <Drawer.Content> <Drawer.Header> <Drawer.Title>Filters</Drawer.Title> </Drawer.Header> <Drawer.Body> <p {...stylex.props(styles.muted)}>Narrow results by status, owner, or date.</p> </Drawer.Body> <Drawer.Footer> <Drawer.Close render={<Button variant="secondary" />}>Cancel</Drawer.Close> <Drawer.Close render={<Button />}>Apply</Drawer.Close> </Drawer.Footer> </Drawer.Content> </Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer> </Drawer.Provider> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Global CSS
To customize the Drawer component classes, you can use the @layer components directive.
@layer components { .drawer__backdrop { @apply bg-gradient-to-br from-black/50 to-black/70; }
.drawer__dialog { @apply rounded-2xl border border-white/10 shadow-2xl; }
.drawer__header { @apply text-center; }
.drawer__close-trigger { @apply rounded-full bg-white/10 hover:bg-white/20; }}Styling Reference
HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.
CSS Classes
The Drawer component uses these CSS classes (View source styles):
Base Classes [!toc]
.drawer__trigger- Trigger element that opens the drawer.drawer__backdrop- Overlay backdrop behind the drawer.drawer__content- Positioning wrapper for the drawer panel.drawer__dialog- The drawer panel itself.drawer__header- Header section for titles.drawer__heading- Main title text.drawer__body- Main scrollable content area.drawer__footer- Footer section for actions.drawer__handle- Visual drag handle indicator.drawer__close-trigger- Close button element
Backdrop Variants [!toc]
.drawer__backdrop--opaque- Opaque colored backdrop (default).drawer__backdrop--blur- Blurred backdrop with glass effect.drawer__backdrop--transparent- Transparent backdrop (no overlay)
Placement Variants [!toc]
.drawer__content--bottom- Slides up from the bottom edge (default).drawer__content--top- Slides down from the top edge.drawer__content--left- Slides in from the left edge.drawer__content--right- Slides in from the right edge
Dialog Variants [!toc]
.drawer__dialog--top- Slides down from the top edge.drawer__dialog--bottom- Slides up from the bottom edge.drawer__dialog--left- Slides in from the left edge.drawer__dialog--right- Slides in from the right edge
Interactive States
The component supports these interactive states:
- Focus:
:focus-visibleor[data-focus-visible="true"]- Applied to trigger and close button - Hover:
:hoveror[data-hovered="true"]- Applied to close button on hover - Active:
:activeor[data-pressed="true"]- Applied to trigger and close button when pressed - Entering:
[data-entering]- Applied during drawer opening animation - Exiting:
[data-exiting]- Applied during drawer closing animation - Placement:
[data-placement="*"]- Applied based on drawer position (top, bottom, left, right)
API Reference
Drawer
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Trigger and backdrop elements |
state | UseOverlayStateReturn | - | Controlled overlay state |
Drawer.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Custom trigger content |
className | string | - | CSS classes |
Drawer.Backdrop
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "opaque" | "blur" | "transparent" | "opaque" | Backdrop overlay style |
isDismissable | boolean | true | Close on backdrop click |
isKeyboardDismissDisabled | boolean | false | Disable ESC key to close |
isOpen | boolean | - | Controlled open state |
onOpenChange | (isOpen: boolean) => void | - | Open state change handler |
className | string | (values) => string | - | Backdrop CSS classes |
Drawer.Content
| Prop | Type | Default | Description |
|---|---|---|---|
placement | "top" | "bottom" | "left" | "right" | "bottom" | Edge the drawer slides from |
className | string | (values) => string | - | Content CSS classes |
Drawer.Dialog
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Dialog content |
className | string | - | CSS classes |
role | string | "dialog" | ARIA role |
aria-label | string | - | Accessibility label |
aria-labelledby | string | - | ID of label element |
Drawer.Header
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Header content |
className | string | - | CSS classes |
Drawer.Heading
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Title text |
className | string | - | CSS classes |
Drawer.Body
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Body content |
className | string | - | CSS classes |
Drawer.Footer
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Footer content |
className | string | - | CSS classes |
Drawer.Handle
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | CSS classes |
Drawer.CloseTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Custom close button |
className | string | (values) => string | - | CSS classes |
useOverlayState Hook
import { useOverlayState } from "@lenso/ui";
const state = useOverlayState({ defaultOpen: false, onOpenChange: (isOpen) => console.log(isOpen),});
state.isOpen; // Current statestate.open(); // Open drawerstate.close(); // Close drawerstate.toggle(); // Toggle statestate.setOpen(); // Set state directlyAccessibility
Implements WAI-ARIA Dialog pattern:
- Focus trap: Focus locked within drawer when open
- Keyboard:
ESCcloses (when dismissable),Tabcycles elements - Screen readers: Proper ARIA attributes via React Aria
- Scroll lock: Body scroll disabled when open
- Drag to dismiss: Supports pointer-based drag gestures on handle, header, and footer areas