Drawer
When to use
Section titled “When to use”- On a phone, actions or options for what’s on screen: share, sort, filter
- Navigation that doesn’t fit in the header, opened from a Menu button
- A form or details that need more room than a Popover, alongside the page’s context
Not for a short decision in the middle of the screen — use a Dialog.
Choosing between messages and popups:
| Component | Use it for | For example |
|---|---|---|
| Dialog | Something that needs a decision or input before the user carries on; the page is blocked meanwhile | Confirm a delete, edit a record in a form |
| Drawer | A panel that slides in from an edge and blocks the page; the first choice on a phone | Share options, a mobile nav, a filter panel |
| Popover | Extra content or controls next to a button, while the rest of the page stays usable | Filter panel, share options, details card |
| Menu | A list of actions or settings opened from a button | “More actions ⋯”, a View menu |
| Tooltip | A short text label on hover or focus; nothing in it can be clicked | Name of an icon button, a keyboard shortcut |
| HoverCard | A preview of where a link goes, on hover or focus; nothing essential in it | A profile behind an @mention, a repository’s summary |
| Toast | A brief confirmation after an action, which goes away by itself | “Saved”, “Archived — Undo” |
| Alert | A message that stays in the page until the situation changes | “Your trial ends in 3 days”, a form error summary |
Drawer is a Dialog attached to one edge: the same native <dialog>
opened with showModal(), with the same top layer, inert page, focus trap, Esc and backdrop
click. It adds a side, a slide in and out, and swipe to close.
import { useState } from 'react';import { Button, Drawer, Stack } from '@arun-dev/ui';
const actions = ['Copy link', 'Email', 'Message', 'Download PDF'];
export default function DrawerBasics() { const [picked, setPicked] = useState<string | null>(null);
return ( <Stack gap="xs" align="start"> <Drawer.Root> {/* At the bottom by default: on a phone it is where the thumb is. Swipe it down, press Esc or the backdrop to close it. */} <Drawer.Trigger render={<Button />}>Share…</Drawer.Trigger> <Drawer.Popup> <Drawer.Title>Share report.pdf</Drawer.Title> <Stack gap="2xs"> {actions.map((action) => ( <Drawer.Close key={action} render={<Button />} onClick={() => setPicked(action)}> {action} </Drawer.Close> ))} </Stack> </Drawer.Popup> </Drawer.Root> {picked && <span>Picked: {picked}</span>} </Stack> );}Anatomy
Section titled “Anatomy”import { Drawer } from '@arun-dev/ui';
<Drawer.Root> <Drawer.Trigger /> <Drawer.Popup side="bottom"> <Drawer.Title /> <Drawer.Close /> </Drawer.Popup></Drawer.Root>;Root, Trigger, Title and Close are Dialog’s own parts and work the same way.
Popup is a Dialog popup with a side. As with Dialog, render a
Button to style Trigger and Close.
side | Comes in from | Size | For example |
|---|---|---|---|
bottom (default) | the bottom | full width, as tall as its content; a grab bar on top | actions, a picker |
top | the top | full width, as tall as its content | a search or notice |
left | the left | full height, --drawer-size wide (22rem) | navigation |
right | the right | full height, --drawer-size wide | filters, a form |
It never covers the whole screen: a strip of backdrop stays visible beside or above it,
--drawer-viewport-gap, so it reads as a panel over the page and the backdrop can be pressed.
The sides are physical, so in a right-to-left layout choose right for navigation yourself.
import { Button, Drawer, Link, Stack } from '@arun-dev/ui';
const pages = ['Overview', 'Projects', 'Team', 'Billing', 'Settings'];
export default function DrawerNavigation() { return ( <Drawer.Root> <Drawer.Trigger render={<Button />}>Menu</Drawer.Trigger> {/* From the left, for navigation: swipe it back to the left to close it. */} <Drawer.Popup side="left"> <Drawer.Title>Acme workspace</Drawer.Title> <nav aria-label="Workspace"> <Stack gap="xs" render={<ul role="list" />}> {pages.map((page) => ( <li key={page}> <Link href="#" aria-current={page === 'Projects' ? 'page' : undefined}> {page} </Link> </li> ))} </Stack> </nav> </Drawer.Popup> </Drawer.Root> );}Playground
Section titled “Playground”<Drawer.Popup side="bottom">Drawer content</Drawer.Popup>Swipe to close
Section titled “Swipe to close”Drag the drawer back toward its edge, with a finger, a pen or a mouse:
| Release | Result |
|---|---|
| past a quarter of the drawer’s size | closes |
| a fast flick, however short | closes |
| anything else | slides back open |
- The drawer follows the finger while it moves. Toward opening, it stops at the open position.
- A press that starts in a text field, or moves across the drawer’s axis, isn’t a swipe.
- Scrolling content inside scrolls first. A bottom drawer’s list scrolled halfway down scrolls back up, and only a swipe that starts with the list at its top closes the drawer.
- The click a swipe ends with is dropped, so letting go over a button doesn’t press it.
Closing
Section titled “Closing”Every close is a request through onOpenChange(false), as for Dialog: Drawer.Close, Esc, a
backdrop click, a <form method="dialog">, and now a swipe. A controlled drawer that refuses
it slides back open.
import { useState } from 'react';import { Button, Drawer, Field, Stack, Textarea } from '@arun-dev/ui';
export default function DrawerUnsaved() { const [open, setOpen] = useState(false); const [note, setNote] = useState('');
return ( <Drawer.Root open={open} // Every close — a swipe, Esc, the backdrop — arrives here. With a note half-written, // it is refused, and the drawer slides back. onOpenChange={(next) => { if (!next && note.trim() !== '') return; setOpen(next); }} > <Drawer.Trigger render={<Button />}>Add a note…</Drawer.Trigger> <Drawer.Popup side="right"> <Drawer.Title>New note</Drawer.Title> <Stack gap="md"> <Field.Root> <Field.Label>Note</Field.Label> <Field.Control render={<Textarea rows={4} />} value={note} onChange={(event) => setNote(event.target.value)} /> <Field.Description>While it has text, swiping or Esc won’t close.</Field.Description> </Field.Root> <Stack direction="row" gap="2xs" justify="end"> <Button onClick={() => { setNote(''); setOpen(false); }} > Discard </Button> <Button variant="primary" onClick={() => { setNote(''); setOpen(false); }} > Save </Button> </Stack> </Stack> </Drawer.Popup> </Drawer.Root> );}Accessibility
Section titled “Accessibility”- Name it with
Drawer.Title. Without one, passaria-labeltoDrawer.Popup. - Swiping is never the only way to close: Esc, the backdrop and a
Drawer.Closebutton all work, so a keyboard or switch user isn’t stuck. Include a Close button for screen reader users on touch devices. - Focus moves inside when it opens and returns to the Trigger when it closes. The page behind is inert and does not scroll.
prefers-reduced-motionturns the slide off.
Drawer.Root
Section titled “Drawer.Root”Takes Dialog.Root’s props.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | Controlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultOpen | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called on every request to open or close: Trigger, Close, Esc, a backdrop click or a <form method="dialog">. A controlled dialog moves only if you accept it. | |
closeOnBackdropClick | boolean | true | Close when the backdrop is clicked. Turn it off where a stray click would lose work; Esc and Dialog.Close still close. |
children | ReactNode | The other parts. Root renders no element of its own. |
Drawer.Popup
Section titled “Drawer.Popup”| Prop | Type | Default | Description |
|---|---|---|---|
side | 'bottom' | 'top' | 'left' | 'right' | 'bottom' | The edge it comes in from, and the direction a swipe closes it. Sets data-side. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
- A bottom or top drawer keeps clear of the phone’s home indicator and notch with
env(safe-area-inset-*)padding, when the page usesviewport-fit=cover. - While it’s dragged, the distance is
--drawer-swipeon the element and it hasdata-swiping. Use them to style the pull in your own stylesheet. - Restyle with the
--drawer-*tokens, such as--drawer-size,--drawer-radiusor--drawer-backdrop. - Types:
import type { DrawerPopupProps, DrawerSide } from '@arun-dev/headless/drawer', orComponentProps<typeof Drawer.Root>.