Drawer
A panel that slides from an edge and can be dragged away, with snap points and a scaled background.
Basic
Drag the panel or its handle down to close; a fast flick or a drag past a quarter of its height closes, anything less springs back.
Code
<Drawer title="Move goal" description="Set your daily activity goal.">
<Button slot="trigger">Open drawer</Button>
<div class="goal">
<Button icon label="Decrease" onclick={() => state.goal -= 10}><Icon name="minus" /></Button>
<strong>{state.goal}</strong> calories/day
<Button icon label="Increase" onclick={() => state.goal += 10}><Icon name="plus" /></Button>
</div>
<DrawerFooter>
<Button variant="primary">Submit</Button>
<DrawerClose><Button>Cancel</Button></DrawerClose>
</DrawerFooter>
</Drawer>Usage
import { Drawer, DrawerBody, DrawerFooter, DrawerClose } from "@elements/ui/drawer";
<Drawer title="Move goal" description="Set your daily activity goal.">
<Button slot="trigger">Open drawer</Button>
<DrawerBody>...</DrawerBody>
<DrawerFooter>
<Button variant="primary" onclick={() => submit(goal)}>Submit</Button>
<DrawerClose><Button>Cancel</Button></DrawerClose>
</DrawerFooter>
</Drawer>
<!-- snap points: snapPoint is two-way -->
<Drawer snapPoints={[0.3, 0.6, 1]} snapPoint={state.snap}>...</Drawer>Examples
With an input
repositionInputs (on by default) keeps a bottom drawer above a phone's on-screen keyboard while a text field inside has focus. Off, the browser scrolls the field into view its own way.
Code
<Drawer title="Rename file" description="The drawer rises above the on-screen keyboard while the field has focus.">
<Button slot="trigger">Rename</Button>
<div class="field">
<label for="file-name">Name</label>
<Input attrs={{ id: "file-name" }} value="report.xlsx" />
</div>
<DrawerFooter>
<DrawerClose><Button variant="primary">Save</Button></DrawerClose>
</DrawerFooter>
</Drawer>
<Drawer repositionInputs={false} title="Browser default">...</Drawer>
<!-- fixed: shrink to end at the keyboard instead of moving up -->
<Drawer fixed title="Notes">...</Drawer>
<!-- noBodyStyles: no scroll lock styles on the page -->
<Drawer noBodyStyles title="Body left alone">...</Drawer>Direction
direction picks the edge; the drag follows it.
Code
<Drawer direction="right" title="Edit profile">...</Drawer>
<Drawer direction="left" title="Navigation">...</Drawer>
<Drawer direction="top" title="Notifications">...</Drawer>Snap points
The drawer rests at 30%, 60% or the full height. Drag between them, flick to skip, or click the handle to step up; the backdrop sits at half (the default floor with snap points) and darkens toward the last point.
Code
<Drawer snapPoints={[0.3, 0.6, 1]} snapPoint={state.snap}
onSnapPointChange={(p) => log(p)} title="Nearby">
<Button slot="trigger">Open with snap points</Button>
<DrawerBody>...</DrawerBody>
</Drawer>Snap point options
fadeFromIndex picks the point where the backdrop starts to fade in (by default the last). snapToSequentialPoints makes a fast flick move one point instead of skipping. handleCycles={false} stops a handle click from stepping through the points. A point can also be a rem or px string, or a number above 1 for px.
Code
<!-- the backdrop starts fading in at the second point; the class sets
--drawer-overlay-min-opacity to 0, so it is clear below that -->
<Drawer snapPoints={[0.3, 0.6, 1]} fadeFromIndex={1} title="Fade later" class="clear">...</Drawer>
<!-- a fast flick moves one point at a time -->
<Drawer snapPoints={[0.3, 0.6, 1]} snapToSequentialPoints title="One step">...</Drawer>
<!-- a handle click does not step through the points -->
<Drawer snapPoints={[0.3, 0.6, 1]} handleCycles={false} title="Still handle">...</Drawer>
<!-- points in rem, or px as a number above 1 -->
<Drawer snapPoints={["20rem", 500, 1]} title="Sized points">...</Drawer>Scrolling content
DrawerBody scrolls. Scrolled down, a downward drag scrolls the list; at its top, it drags the drawer.
Code
<Drawer title="Activity">
<Button slot="trigger">Open scrolling drawer</Button>
<DrawerBody>
<ul>...30 rows...</ul>
</DrawerBody>
</Drawer>Non-dismissible
dismissible={false}: dragging, Escape and a press outside do nothing. The page closes it.
Code
<Drawer open={state.locked} dismissible={false} title="Finish setup">
<Button slot="trigger">Open non-dismissible</Button>
...
<Button onclick={() => state.locked = false}>Done</Button>
</Drawer>Preventable dismissal
onEscapeKeyDown and onPointerDownOutside run before a dismiss; preventDefault() keeps the drawer open. Here both are logged and kept open, and Done closes it.
Code
<Drawer title="Unsaved draft"
onEscapeKeyDown={(e) => { e.preventDefault(); log("escape"); }}
onPointerDownOutside={(e) => { e.preventDefault(); log("outside"); }}>
<Button slot="trigger">Open draft</Button>
...
<DrawerFooter><DrawerClose><Button>Done</Button></DrawerClose></DrawerFooter>
</Drawer>Handle only
handleOnly: only the handle drags, so text in the content can be selected with the mouse.
Code
<Drawer handleOnly title="Release notes">
<Button slot="trigger">Open handle-only</Button>
<DrawerBody>...text you can select...</DrawerBody>
</Drawer>Non-modal with snap points
modal={false}: no backdrop, the page stays usable. With dismissOnOutside={false} the drawer stays open while you use the page; without it, focus leaving the drawer closes it.
Code
<Drawer modal={false} dismissOnOutside={false} snapPoints={["148px", "355px", 1]}
title="Map results">
...
</Drawer>
<!-- focus leaving it closes it, with reason focus-out -->
<Drawer modal={false} direction="right" title="Filters">...</Drawer>Nested
A drawer opened from a drawer. The outer one steps back; Escape closes the top one.
Code
<Drawer title="Settings">
<Button slot="trigger">Open settings</Button>
<Drawer title="Advanced">
<Button slot="trigger">Advanced</Button>
...
</Drawer>
</Drawer>Scaled background
scaleBackground scales the page (wrapped in DrawerWrapper) back while the drawer is open; it follows the drag.
Code
<!-- the page, once -->
<DrawerWrapper>...the whole page...</DrawerWrapper>
<Drawer scaleBackground title="Share">...</Drawer>Controlled
Bind open to state. onOpenChange reports the drawer's own requests; onOpenChangeComplete fires when the animation ends.
Code
<Button onclick={() => state.open = true}>Open from anywhere</Button>
<Drawer open={state.open} onOpenChange={(open) => log(open)}
onOpenChangeComplete={(open) => done(open)} title="Controlled">...</Drawer>Responsive
A Dialog from 48rem up, a Drawer below, sharing one open state.
Code
<Button id="edit-profile" onclick={() => state.open = true}>Edit profile</Button>
<Dialog e:if={state.desktop} open={state.open} finalFocus="#edit-profile" title="Edit profile">...</Dialog>
<Drawer e:else open={state.open} finalFocus="#edit-profile" title="Edit profile">...</Drawer>
<!-- state.desktop from matchMedia("(min-width: 48rem)"). finalFocus names the
button: when a resize swaps one for the other while open, the new one
never saw it pressed, and focus still goes back to it on close. -->Parts and no-drag regions
DrawerHeader, DrawerTitle and DrawerDescription wire aria. A range input and anything in [data-drawer-no-drag] never start a drag.
Code
<Drawer title="Volume">
<input type="range"> <!-- never starts a drag -->
<div data-drawer-no-drag>...</div> <!-- nor does this region -->
</Drawer>Trap focus
modal="trap-focus": Tab stays inside the drawer, but there is no backdrop or scroll lock, and the page takes clicks. A press outside calls onInteractOutside and closes it.
Code
<Drawer modal="trap-focus" title="Filters" onInteractOutside={(e) => count()}>
<Button slot="trigger">Filters</Button>
...
</Drawer>Inset and backdrop floor
--drawer-inset floats the panel off the viewport edges, with every corner rounded. --drawer-overlay-min-opacity is the backdrop's floor: 0.5 by default with snap points, set to 0 here so the backdrop is clear at the lowest point.
Code
<Drawer title="Floating" class="floating">...</Drawer>
<Drawer snapPoints={[0.3, 1]} title="Clear" class="clear">...</Drawer>
.floating { --drawer-inset: var(--space-4); }
.clear { --drawer-overlay-min-opacity: 0; }Dynamic content
One drawer moves between views. The panel's height follows its content smoothly, each view fades in, and the drawer starts at its first view each time it opens.
Code
<Drawer title={TITLES[state.view]} class="floating"
onOpenChangeComplete={(open) => open || (state.view = "options")}>
<Button slot="trigger">Manage wallet</Button>
<div class="views" oninsert={(e) => followHeight(e.target)}>
<div>
<div e:if={state.view === "options"} class="view">
<Button onclick={() => state.view = "key"}>View private key</Button>
<Button onclick={() => state.view = "phrase"}>View recovery phrase</Button>
<Button onclick={() => state.view = "remove"}>Remove wallet</Button>
</div>
<div e:elseif={state.view === "key"} class="view">...</div>
...
</div>
</div>
</Drawer>
// The views box tweens its height to what its content measures.
function followHeight(box) {
new ResizeObserver(() => {
box.style.height = `${box.firstElementChild.offsetHeight}px`;
}).observe(box.firstElementChild);
}
.views { overflow: clip; transition: height var(--duration-enter) var(--ease-enter); }
.view { animation: fade-in var(--duration-enter) var(--ease-enter); }Swipe to open
swipeArea renders an invisible strip on the drawer's edge. The drawer opens with the first movement from it and follows the finger; let go past half its width, or with a flick, and it stays open, otherwise it slides away again. onOpenChange reports swipe both ways. swipeAreaDirection moves the strip to the edge another swipe starts from. Turn them on here, since each strip covers an edge of the page.
Code
<Drawer direction="right" swipeArea title="Inbox"
onOpenChange={(open, reason) => log(reason)}>
...
</Drawer>
<!-- The area sits on the edge its swipe starts from: a pull down from the top. -->
<Drawer swipeArea swipeAreaDirection="down" title="Quick settings">
...
</Drawer>Multiple triggers
Buttons anywhere on the page open one controlled drawer and pass what it shows through page state. Focus returns to the button used.
Code
<Button onclick={() => edit(state, "Ada Lovelace")}>Edit Ada</Button>
<Button onclick={() => edit(state, "Grace Hopper")}>Edit Grace</Button>
<Drawer open={state.editing} title={state.person}>...</Drawer>
function edit(state, person) {
state.person = person; // the payload
state.editing = true; // focus returns to the button used
}Open from a menu
A menu item opens a controlled drawer. The item is gone by the time it closes, so focus goes back to the menu's button.
Code
<DropdownMenu>
<Button slot="trigger">Track</Button>
<DropdownMenuItem onSelect={() => state.sharing = true}>Share...</DropdownMenuItem>
</DropdownMenu>
<Drawer open={state.sharing} title="Share track">...</Drawer>Focus on close and focus events
finalFocus names where focus goes on close. onOpenAutoFocus and onCloseAutoFocus run before focus moves, and preventDefault() leaves focus to the handler. With no title, label names the drawer.
Code
<Drawer title="Add filter" finalFocus="[data-filter-query]">...</Drawer>
<Drawer title="Share" label="Share"
onOpenAutoFocus={(e) => { e.preventDefault(); copy.focus(); }}
onCloseAutoFocus={(e) => e.preventDefault()}>
...
</Drawer>Focus by interaction type
initialFocus and finalFocus can be functions of how the drawer opened or closed: keyboard, mouse, touch or pen.
Code
<Drawer title="Find a file"
initialFocus={(type) => type === "keyboard" ? "[data-search]" : true}
finalFocus={(type) => type === "keyboard" ? true : "[data-after]"}>
<Button slot="trigger">Find a file</Button>
<input aria-label="Folder" value="Documents">
<input aria-label="Search" data-search>
<DrawerFooter>
<DrawerClose><Button>Close</Button></DrawerClose>
</DrawerFooter>
</Drawer>Open on load
open={true} without a binding: the drawer is open when the page loads, then owns its state.
Code
<Drawer open={true} title="Welcome back">
<Button slot="trigger">Welcome</Button>
...
</Drawer>Escape reaches the page
onEscapeKeyDown can call allowPropagation(): Escape closes the drawer and still reaches the page's own key handlers.
Code
<Drawer title="Shortcuts" onEscapeKeyDown={(e) => e.allowPropagation()}>
<Button slot="trigger">Shortcuts</Button>
<p>Escape closes this drawer and still reaches the page's own key handler.</p>
</Drawer>