Drawer
A panel at the side of the page for a record, a form or filters, with the page beside it still in use.
Try it
Type a name, then close it: a panel never closes itself, so the page can ask first.
Beside the content
mode="inline" is a sibling of the content and not a layer over it: the caller puts the two in a flex row, the panel keeps its width and the content takes what is left. It is for content that has to stay whole — a calendar whose last column must not be under a panel. It is part of the page, so it is a card’s surface with a line where it meets the content, and no shadow. Expanded, either kind covers the page. Below 768 there is no room beside the content: the content is what shows, and an inline Drawer opens over it, as the overlay one does.
Drawer, popover or dialog
| When | Use |
|---|---|
| A record opened from a list, which stays in use beside it | Drawer |
| The content has to stay whole and in view: a calendar, a map | Drawer, inline |
| A few fields against the button that asked for them | Popover |
| A question that has to be answered before anything else | Dialog |
| The application’s navigation | not this: the start side is its own |
Not modal
The roadmap had this as a dialog element at the side, with the Dialog’s rules. Decided on 2026-09-21: the rules stay and the element does not. In the product this was drawn for, the calendar beside the panel stays live — another appointment is a press away — so from 768 up there is no scrim and nothing is inert. What stops a form from being lost is not the page being locked but the page being asked: Esc and the close button call onClose, and the caller either closes it or opens a Dialog that says what will be lost. A press outside does nothing, as on the Dialog.
Below 768 there is no page beside it: the panel is over the content, and at a phone’s width it is the screen. There it is modal (2026-09-25): the page outside is inert, the panel is aria-modal, Tab goes round inside it, and the focus goes back on close, so neither the keyboard nor a screen reader wanders into what it hides. Still no scrim, and a Dialog opened from it still answers.
Over the content it is popover="manual": the top layer, with no z-index, and none of the light dismiss an auto popover has. A Dialog opened from it opens over it.
Anatomy
| Part | Value |
|---|---|
| Width | 480 or 768, as drawn; the screen’s, when that is less |
| Height, corners | the whole height, square |
| Header | 64, 24 at the sides, a divider inset by the same |
| Title, Semibold | 18 / 24 |
| Body, footer | 24 |
| Handle | 12 across, astride the inner edge |
| A resize leaves | 320 to the panel, and 320 to the content |
The drawn Side Drawer is the Popover’s shell at the height of the page: the title at the start, icon buttons at the end, the footer’s buttons at the end. header takes the title’s place for the drawn View and Edit, whose header is the appointment’s state. The sections that open and close in that drawing are the Accordion, as in the panel above.
Accessibility
Over the content it is a dialog, aria-modal only below 768; beside it, a named region. The focus goes to initialFocus or to the panel on the way in, and on the way out back to what had it — unless the reader has already gone somewhere else, where it is left. The handle is a separator with the width as its value, so a resize is not the pointer’s alone.
| Key | Does |
|---|---|
| Esc, inside the panel | Asks the caller to close. A Select or a DatePicker open inside takes it first. |
| Tab | From 768 up, goes through the panel and on into the page: nothing is inert. Below it, goes round inside the panel: the page is inert. |
| Left, Right, on the handle | Move the inner edge by 16. |
| Home, End, on the handle | The least and the most. |
| Double click, on the handle | Back to the starting width. |
Using it
import { Drawer, Dialog, Button } from 'alpenglow';
// Over the content.
<Drawer
open={open}
onClose={() => (dirty ? setAsking(true) : setOpen(false))}
title="New appointment"
initialFocus={patient}
actions={<><Button variant="outline" tone="neutral" onClick={…}>Cancel</Button><Button onClick={save}>Save</Button></>}
>
…
</Drawer>
// Beside it: a sibling of the content, which makes room.
<div style={{ display: 'flex', height: '100%' }}>
<Calendar style={{ flex: 1, minWidth: 0 }} />
<Drawer
mode="inline"
resizable
open={slot !== null}
onClose={() => setSlot(null)}
expanded={expanded}
onExpandedChange={setExpanded}
title={slot?.patient}
>
…
</Drawer>
</div>| Prop | Type | Default |
|---|---|---|
open, onClose | boolean, () => void | required |
mode | 'overlay' | 'inline'; below 768 (DRAWER_NARROW, exported) inline opens over the content as overlay does | 'overlay' |
side | 'end' | 'start' | 'end' |
size | 'md' | 'lg' | 'md' |
title | string | — |
header, aria-label | React | — |
headerActions, actions | React | — |
expanded, onExpandedChange | boolean, (expanded) => void | false; no button without the handler |
resizable | boolean | false |
width, defaultWidth, onWidthChange | number, number, (width) => void | the size’s |
minWidth, maxWidth | number | 320, what leaves the content 320 |
initialFocus | Ref | the panel |
closeLabel, expandLabel, collapseLabel, resizeLabel | string | English |
className | string | — |