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.

Leave without saving?

What you typed for this appointment will be lost.

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

Which one
WhenUse
A record opened from a list, which stays in use beside itDrawer
The content has to stay whole and in view: a calendar, a mapDrawer, inline
A few fields against the button that asked for themPopover
A question that has to be answered before anything elseDialog
The application’s navigationnot 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

Drawer geometry, in pixels
PartValue
Width480 or 768, as drawn; the screen’s, when that is less
Height, cornersthe whole height, square
Header64, 24 at the sides, a divider inset by the same
Title, Semibold18 / 24
Body, footer24
Handle12 across, astride the inner edge
A resize leaves320 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.

Drawer, by keyboard
KeyDoes
Esc, inside the panelAsks the caller to close. A Select or a DatePicker open inside takes it first.
TabFrom 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 handleMove the inner edge by 16.
Home, End, on the handleThe least and the most.
Double click, on the handleBack to the starting width.

Using it

TSX
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>
Drawer props
PropTypeDefault
open, onCloseboolean, () => voidrequired
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'
titlestring—
header, aria-labelReactNode in the title’s place, and the name it then needs—
headerActions, actionsReactNode—
expanded, onExpandedChangeboolean, (expanded) => voidfalse; no button without the handler
resizablebooleanfalse
width, defaultWidth, onWidthChangenumber, number, (width) => voidthe size’s
minWidth, maxWidthnumber320, what leaves the content 320
initialFocusRefObject<HTMLElement>the panel
closeLabel, expandLabel, collapseLabel, resizeLabelstringEnglish
classNamestring—