Skip to content

Drawer

  • 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:

ComponentUse it forFor example
DialogSomething that needs a decision or input before the user carries on; the page is blocked meanwhileConfirm a delete, edit a record in a form
DrawerA panel that slides in from an edge and blocks the page; the first choice on a phoneShare options, a mobile nav, a filter panel
PopoverExtra content or controls next to a button, while the rest of the page stays usableFilter panel, share options, details card
MenuA list of actions or settings opened from a button“More actions ⋯”, a View menu
TooltipA short text label on hover or focus; nothing in it can be clickedName of an icon button, a keyboard shortcut
HoverCardA preview of where a link goes, on hover or focus; nothing essential in itA profile behind an @mention, a repository’s summary
ToastA brief confirmation after an action, which goes away by itself“Saved”, “Archived — Undo”
AlertA 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.

Share report.pdf

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>
);
}
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.

sideComes in fromSizeFor example
bottom (default)the bottomfull width, as tall as its content; a grab bar on topactions, a picker
topthe topfull width, as tall as its contenta search or notice
leftthe leftfull height, --drawer-size wide (22rem)navigation
rightthe rightfull height, --drawer-size widefilters, 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>
);
}
Drawer content
<Drawer.Popup side="bottom">Drawer content</Drawer.Popup>

Drag the drawer back toward its edge, with a finger, a pen or a mouse:

ReleaseResult
past a quarter of the drawer’s sizecloses
a fast flick, however shortcloses
anything elseslides 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.

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.

New note

While it has text, swiping or Esc won’t close.

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>
);
}
  • Name it with Drawer.Title. Without one, pass aria-label to Drawer.Popup.
  • Swiping is never the only way to close: Esc, the backdrop and a Drawer.Close button 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-motion turns the slide off.

Takes Dialog.Root’s props.

Dialog.Root props
PropTypeDefaultDescription
openbooleanControlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultOpenbooleanfalseInitial state when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled 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.
closeOnBackdropClickbooleantrueClose when the backdrop is clicked. Turn it off where a stray click would lose work; Esc and Dialog.Close still close.
childrenReactNodeThe other parts. Root renders no element of its own.
Drawer.Popup props
PropTypeDefaultDescription
side'bottom' | 'top' | 'left' | 'right''bottom'The edge it comes in from, and the direction a swipe closes it. Sets data-side.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything 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 uses viewport-fit=cover.
  • While it’s dragged, the distance is --drawer-swipe on the element and it has data-swiping. Use them to style the pull in your own stylesheet.
  • Restyle with the --drawer-* tokens, such as --drawer-size, --drawer-radius or --drawer-backdrop.
  • Types: import type { DrawerPopupProps, DrawerSide } from '@arun-dev/headless/drawer', or ComponentProps<typeof Drawer.Root>.