Skip to content
Lenso UI

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.

"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.

Learn more.

@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-visible or [data-focus-visible="true"] - Applied to trigger and close button
  • Hover: :hover or [data-hovered="true"] - Applied to close button on hover
  • Active: :active or [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

PropTypeDefaultDescription
childrenReactNode-Trigger and backdrop elements
stateUseOverlayStateReturn-Controlled overlay state

Drawer.Trigger

PropTypeDefaultDescription
childrenReactNode-Custom trigger content
classNamestring-CSS classes

Drawer.Backdrop

PropTypeDefaultDescription
variant"opaque" | "blur" | "transparent""opaque"Backdrop overlay style
isDismissablebooleantrueClose on backdrop click
isKeyboardDismissDisabledbooleanfalseDisable ESC key to close
isOpenboolean-Controlled open state
onOpenChange(isOpen: boolean) => void-Open state change handler
classNamestring | (values) => string-Backdrop CSS classes

Drawer.Content

PropTypeDefaultDescription
placement"top" | "bottom" | "left" | "right""bottom"Edge the drawer slides from
classNamestring | (values) => string-Content CSS classes

Drawer.Dialog

PropTypeDefaultDescription
childrenReactNode-Dialog content
classNamestring-CSS classes
rolestring"dialog"ARIA role
aria-labelstring-Accessibility label
aria-labelledbystring-ID of label element

Drawer.Header

PropTypeDefaultDescription
childrenReactNode-Header content
classNamestring-CSS classes

Drawer.Heading

PropTypeDefaultDescription
childrenReactNode-Title text
classNamestring-CSS classes

Drawer.Body

PropTypeDefaultDescription
childrenReactNode-Body content
classNamestring-CSS classes

Drawer.Footer

PropTypeDefaultDescription
childrenReactNode-Footer content
classNamestring-CSS classes

Drawer.Handle

PropTypeDefaultDescription
classNamestring-CSS classes

Drawer.CloseTrigger

PropTypeDefaultDescription
childrenReactNode-Custom close button
classNamestring | (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 directly

Accessibility

Implements WAI-ARIA Dialog pattern:

  • Focus trap: Focus locked within drawer when open
  • Keyboard: ESC closes (when dismissable), Tab cycles 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