Drawer
The panel that slides in from an edge. It is the same mechanism as Modal — a real <dialog> opened with showModal(), so the top layer, the scrim, the focus trap, the inert page, Escape and focus restoration are all the platform's — laid against one of four logical edges instead of centred in the viewport. One attribute picks the edge; the corners that face the page round, and the ones fused to the edge stay square.
- Stable
- React 19
- Native <dialog> + showModal()
- 4 logical edges
Press it. The panel is a real showModal() against the viewport's leading edge — the top layer, the scrim, the focus trap and Escape included. Every example on this page starts from a button, because a panel you cannot open is not this component.
filtros: sin aplicar
Code
import { useState } from "react";import { Button, Drawer } from "wayra-ui";
export function FiltersDemo() { const [open, setOpen] = useState(false); const [applied, setApplied] = useState(false);
function apply() { setApplied(true); setOpen(false); }
// `Limpiar` resets and leaves the panel open — the panel owns no actions and // never closes itself from one, so which button closes it is the consumer's // decision and not the component's. return ( <div className="hu-demo-col"> <Button onClick={() => setOpen(true)}>Filtros</Button>
<Drawer open={open} onClose={() => setOpen(false)} title="Filtros" description="Se aplican al cerrar el panel." closeLabel="Cerrar" footer={ <> <Button variant="outline" color="neutral" onClick={() => setApplied(false)} > Limpiar </Button> <Button onClick={apply}>Aplicar</Button> </> } > Elige el rango de fechas y el estado del pedido. </Drawer>
<p className="hu-readout"> filtros: {applied ? "aplicados" : "sin aplicar"} </p> </div> );}A trigger you own, and a controlled panel. There is no imperative handle and no portal: the dialog stays exactly where you wrote it in the React tree and the browser lifts it to the top layer on its own.
Open the Code panel under any example on this page and you get that example’s whole source — the trigger, the state and the panel — because every one of them is a mounted component and not a picture of one.
Everything Modal documents, here too
Section titled “Everything Modal documents, here too”Drawer and Modal share styles/families/modality.css and the useModality hook, so
these are one behaviour with one implementation. Rather than repeat them, the
Modal page is the reference for:
- Why it is native — what
showModal()supplies, and why the library adds only the reconciliation between React’sopenand the DOM’s. - Name and description —
titleasaria-labelledby,descriptionmerged intoaria-describedby,closeLabelas both the button’s name and the switch that renders it. - Dismissible and the refusal
—
dismissible={false}, the refusal shake,data-deny, the reduced-motion swap. - Controlled and uncontrolled
—
open,defaultOpen, theonOpen/onClosepair read off the platform, and why the nativeopenattribute is not a prop. - The scroll lock — the space toggle, the conditional gutter, and locking a container other than the root. Both surfaces raise the same flag.
- The frosted scrim — the
backdropprop, its eight steps and why every one of them is a literal. A Drawer takes it on exactly the same terms.
The root is the dialog, and its grid
Section titled “The root is the dialog, and its grid”There is no wrapper box. The element you get back is the <dialog>, and it
is also the grid: three rows, auto minmax(0, 1fr) auto.
<dialog class="wayra-drawer" data-side="end" data-size="lg" aria-labelledby="t"> <header class="wayra-drawer-header"> <h2 class="wayra-drawer-title" id="t">…</h2> <p class="wayra-drawer-desc">…</p> <button type="button" class="wayra-drawer-close" aria-label="Cerrar">…</button> </header> <div class="wayra-drawer-body">…</div> <footer class="wayra-drawer-footer">…</footer></dialog>The body is always rendered, even when children is empty: it is the row
that scrolls, and the grid places it by class rather than by whichever siblings
happened to show up. The header appears only when one of title, description
or closeLabel has something in it; the footer only when footer does.
The four sides
Section titled “The four sides”side is logical, so start and end follow the writing direction and the
stylesheet has no mirrored twin rule.
Four buttons, four edges, one mounted panel: side is the only thing they
change before it opens.
side="start" · closed
One panel, one side prop, four edges. Open block-end for the phone sheet — it is the only side that draws a drag handle, and the only one a pull closes.
Code
import { useState } from "react";import type { DrawerSide } from "wayra-ui";import { Button, Drawer } from "wayra-ui";
// Module scope, not inline: a fresh array on every render is the one mistake// the component's performance rules exist to prevent.const SIDES: readonly DrawerSide[] = [ "start", "end", "block-start", "block-end",];
export function SidesDemo() { // One panel, four edges. `side` outlives the close on purpose: clearing it // with `open` would re-dock the panel to `start` while it is still playing // its exit, and the sheet would snap across the viewport on the way out. const [side, setSide] = useState<DrawerSide>("start"); const [open, setOpen] = useState(false);
function show(next: DrawerSide) { setSide(next); setOpen(true); }
return ( <div className="hu-demo-col"> <div className="hu-demo-row"> {SIDES.map((option) => ( <Button key={option} variant="outline" color="neutral" onClick={() => show(option)} > {option} </Button> ))} </div>
<Drawer open={open} onClose={() => setOpen(false)} side={side} title="Filtros" description="Se aplican al cerrar el panel." closeLabel="Cerrar" footer={ <> <Button variant="outline" color="neutral" onClick={() => setOpen(false)} > Limpiar </Button> <Button onClick={() => setOpen(false)}>Aplicar</Button> </> } > Elige el rango de fechas y el estado del pedido. </Drawer>
<p className="hu-readout"> side="{side}" · {open ? "open" : "closed"} </p> </div> );}<Drawer>…</Drawer> {/* start — the default, written as data-side="start" */}<Drawer side="end">…</Drawer><Drawer side="block-start">…</Drawer><Drawer side="block-end">…</Drawer>| Side | Shape | Squared corners |
|---|---|---|
start |
full-height panel on the leading edge | the two leading ones |
end |
full-height panel on the trailing edge | the two trailing ones |
block-start |
full-width sheet dropping from the top | the two top ones |
block-end |
full-width sheet rising from the bottom, with a drag handle; a pull down closes it | the two bottom ones |
start is the default and is written like the other three, the way every
defaulted attribute in the library is; hand-written markup may leave
data-side off and gets start. block-start is the classic window
sheet, attached under the title bar and squared where it meets it: it is
answered, not pulled away, so it carries no handle and takes no pull.
Edge-fused corners
Section titled “Edge-fused corners”A panel rounds only what faces the page. The two corners against the viewport
edge stay square, which is what makes it read as fused to that edge rather
than floating over it — and it is why the four sides are four sets of
border-*-radius rather than one radius and a transform.
The drag handle
Section titled “The drag handle”block-end draws a grip from the root’s own ::before, not the header’s,
so a sheet with no header still shows it. It says the sheet can be pulled
down, and it is where most pulls start; block-start has no equivalent and
draws nothing. Open block-end in the picker above and it is the bar across
the top edge.
Pull to dismiss
Section titled “Pull to dismiss”The bottom sheet closes the way a phone’s does: take it by the handle and pull
it down. There is no prop for it. The gesture is part of side="block-end",
it follows dismissible, and it is the same one
Modal’s phone sheet takes.
Where it starts. A pull starts on the handle, the header, the footer or any
gutter of the sheet — anything that is not a control. A press on a button, a
link, a field, a label, anything with a tabindex, a popover above the sheet,
or the body (the row that scrolls) keeps its own meaning and never moves
the sheet. Only the primary button or pointer counts.
When it becomes a pull. Not on the press: only after 10px of mostly
vertical travel. A mostly sideways press is let go for good, and so is one
that is selecting text — a selection wins over the sheet. The sheet carries
touch-action: none, so on a touch screen the browser neither pans nor zooms
under the pull; the body is a scroller of its own and still pans.
While it is held. The sheet follows the hand 1:1, counted from the press,
so the point you took stays under the finger. It is a pointer capture, so the
pull goes on when the pointer leaves the sheet. Upwards there is nowhere to
go, so it gives as a rubber band: the further you pull, the less it follows,
and it never reaches its own height. The offset is written straight onto the
element — --_wayra-modality-drag and data-dragging — with no React render
and with every transition off.
When it is let go. The speed of the last 100ms of the pull decides, as well as the distance. That speed is projected to where a throw of it would come to rest, the way a page scroll decelerates; if that point is past half the height of the sheet, or the release is a flick down, the sheet closes. Anything else springs back to rest on the settle curve, starting from where it was let go, never from a jump.
How it closes. Through the dialog’s own close(), like Escape and the
scrim, so onClose fires exactly once. The sheet does not play the entrance
back — that would lift it towards rest before it faded. It goes on down
from where it was let go until it is off the edge, opaque all the way, and the
scrim fades as on any close. The settle curve starts at 2.25 times its average
speed, so the exit lasts 2.25 times the rest of the path over the release
speed: the sheet leaves at the speed of the hand, between 120ms and the plain
220ms exit. The sheet carries data-exit="drag" until it opens again.
With dismissible={false} the sheet gives both ways as a rubber band and
springs back on release, and a pull that would have closed it plays the
refusal shake, the same data-deny that Escape and the scrim get. A short pull
just springs back.
Under reduced motion the pull still follows the hand — the hand is the motion. What the release starts is short: the close fades in 160ms where the sheet was let go, and the spring back takes the same 160ms.
size="md"
Docked to start, where the step is the width of the panel itself.
Code
import { useState } from "react";import { Button, Drawer } from "wayra-ui";
type Step = "sm" | "md" | "lg" | "xl";
// Module scope, not inline: a fresh object on every render is the one mistake// the component's performance rules exist to prevent. Four steps off the// modality ladder and no `xs`, the same ladder Modal walks.const STEPS: readonly Step[] = ["sm", "md", "lg", "xl"];
const COPY: Record<Step, { title: string; body: string }> = { sm: { title: "sm — 21rem", body: "El escalón corto: una columna de filtros y poco más.", }, md: { title: "md — 26rem", body: 'El escalón por defecto, escrito como data-size="md".', }, lg: { title: "lg — 34rem", body: "El escalón largo, para un formulario de una columna con espacio de sobra.", }, xl: { title: "xl — 46rem", body: "El escalón ancho, para tablas y vistas de detalle que viven junto a la página.", },};
export function SizesDemo() { // `step` outlives the close on purpose: resetting it with `open` would resize // the panel while it is still playing its exit. const [step, setStep] = useState<Step>("md"); const [open, setOpen] = useState(false);
function show(next: Step) { setStep(next); setOpen(true); }
const copy = COPY[step];
// Docked to `start`, where the step is the width of the panel itself. On the // two block edges the panel spans the viewport and the same step becomes the // width of the centred content column instead. return ( <div className="hu-demo-col"> <div className="hu-demo-row"> {STEPS.map((option) => ( <Button key={option} variant="outline" color="neutral" onClick={() => show(option)} > Drawer {option} </Button> ))} </div>
<Drawer open={open} onClose={() => setOpen(false)} size={step} title={copy.title} closeLabel="Cerrar" footer={<Button onClick={() => setOpen(false)}>Aplicar</Button>} > {copy.body} </Drawer>
<p className="hu-readout">size="{step}"</p> </div> );}<Drawer size="sm">…</Drawer><Drawer>…</Drawer> {/* md — the default, written as data-size="md" */}<Drawer size="lg">…</Drawer><Drawer size="xl">…</Drawer>| Step | Width knob | At the default 0.22rem unit |
|---|---|---|
sm |
84 × --wayra-size-field |
18.48rem |
md |
104 × --wayra-size-field |
22.88rem |
lg |
136 × --wayra-size-field |
29.92rem |
xl |
184 × --wayra-size-field |
40.48rem |
This is the modality ladder, the same one Modal walks: a dialog is a reading column, not a control, so it does not walk the field ladder. What the step means changes with the side:
- On
startandendit is the width of the panel itself. - On
block-startandblock-endthe panel spans the viewport, so the step becomes the width of a centred content column inside it — the header, the body and the footer all take it. Text in a bottom sheet never runs the width of a desktop screen.
md is the default, written as data-size="md"; a missing data-size on
hand-written markup reads as md too.
What a Drawer deliberately has not got
Section titled “What a Drawer deliberately has not got”There is no variant, no icon, no color and no mobileSheet, and each
absence has a reason rather than a backlog entry:
| Missing | Why |
|---|---|
variant="alert" |
An alert is a question, and a question belongs centred in the viewport. That is Modal. |
icon |
The alert badge is the only place the library draws one on a modality surface, and there is no alert here. |
color |
With no icon there is nothing for a role to tint — the surface itself never takes a colour on either component — so the panel is always neutral. |
mobileSheet |
A panel already reaches an edge. A narrow viewport needs no second shape; if you want the sheet, ask for it with side="block-end". |
Scrolling
Section titled “Scrolling”The dialog is the grid, so the header and the footer stay put and only the body
scrolls. That middle row is a scroll container with min-block-size: 0 and
overscroll-behavior: contain, which is also what carries the panel through
200% text: a long list lengthens the scroll instead of pushing the actions off
the screen.
The same scroll container clips whatever is drawn in flow past its edge. A Tooltip never enters the top layer, so a tip inside the body is cut at the body’s border: give it room on the side it opens toward.
Hand-written markup
Section titled “Hand-written markup”The class plus the data-* attributes is the public API, and the stylesheet is
written against it. Below there is no React at all — a plain <dialog>, one
data-side, and the showModal() any page can call:
<button type="button" class="wayra-button" onclick="panel.showModal()"> Filtros</button>
<dialog id="panel" class="wayra-drawer" data-side="end" aria-labelledby="panel-t"> <header class="wayra-drawer-header"> <h2 class="wayra-drawer-title" id="panel-t">Filtros</h2> <p class="wayra-drawer-desc">Se aplican al cerrar el panel.</p> <button type="button" class="wayra-drawer-close" aria-label="Cerrar" onclick="panel.close()"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"> <path d="M6 6l12 12"/><path d="M18 6L6 18"/> </svg> </button> </header> <div class="wayra-drawer-body">Elige el rango de fechas y el estado del pedido.</div> <footer class="wayra-drawer-footer"> <button type="button" class="wayra-button">Aplicar</button> </footer></dialog>That markup paints the same panel, docks to the same edge, opens into the same
top layer and gets the same trap, scrim and Escape — because all of it was the
platform’s to begin with. What hand-written markup does not get is the
reconciliation between React’s open and the DOM’s, and the refusal to close.
Flat, not glass
Section titled “Flat, not glass”The surface is opaque base-100 and carries no shadow: a drawer has no border
of its own, so the scrim is the whole of its separation from the page. It
was raised by a lit top edge and a layered drop shadow until 2026-08-29, and
translucency was withdrawn earlier still. The scrim is one dark wash in both
schemes, because a light wash over a dark page reads as a flash instead of as
depth.
backdrop-filter came back on 2026-08-30, by request and in one place only:
the backdrop prop, which frosts the
scrim and is off unless asked for. The panel itself still carries none.
Public CSS knobs
Section titled “Public CSS knobs”| Property | Default | Scope |
|---|---|---|
--wayra-modality-padding |
1.35rem |
family — Modal and Drawer |
--wayra-modality-radius |
calc(var(--wayra-radius-box) * 1.8) |
family |
--wayra-modality-close-size |
max(24px, 1.75rem) |
family |
--wayra-modality-open |
initial — written by the library |
family — the scroll-lock toggle |
--wayra-drawer-width |
the size step, 22.88rem at md |
Drawer |
The first three --wayra-modality-* knobs are family API the way
--_wayra-field-* is: one padding, one corner and one close-button size for
both surfaces, declared once.
--wayra-modality-open runs the other way: the library writes it and you
read it. It is a space toggle, empty while a Modal or a Drawer is open and
guaranteed-invalid otherwise, and it is what lets you lock a scroll container
that is not the root or move chrome of your own — the Modal
page covers both. Do not set it yourself:
an unlayered declaration outranks the library’s and pins the lock on or off for
good.
Modal’s own two — --wayra-modal-width and
--wayra-modal-max-height — are on its
page. Everything else, the travel
magnitude and its sign included, is private.
API reference
Section titled “API reference”DrawerProps extends ComponentProps<"dialog"> minus open and title, so
id, role, style, ref, className, every aria-* and every data-*
land on the dialog itself. The spread runs before the component’s own
attributes, so a colliding data-side loses to the prop.
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
— | The body: the one row that scrolls. Give it a tab stop if it overflows. |
side |
"start" | "end" | "block-start" | "block-end" |
"start" |
Which edge the panel is attached to, logically. Travels as data-side. |
size |
DrawerSize — "sm" | "md" | "lg" | "xl" |
"md" |
Step off the modality ladder: the panel’s width on the inline sides, the content column’s width on the block ones. Travels as data-size. Exported under that name. No xs, unlike Modal: a panel that narrow is a strip. |
backdrop |
ModalityBackdrop |
"none" |
Blurs the scrim. Travels as data-backdrop. See The frosted scrim. |
dismissible |
boolean |
true |
Whether Escape, a backdrop click and, on block-end, a pull down close it. false refuses all three and shakes instead. |
title |
ReactNode |
— | The <h2>, wired as the accessible name through aria-labelledby. |
description |
ReactNode |
— | The line under it. Its id joins a consumer aria-describedby. |
footer |
ReactNode |
— | The action row. The panel owns no actions and never closes itself from one. |
closeLabel |
string |
— | Accessible name of the close button, and the switch that renders it. Empty renders nothing. |
open |
boolean |
— | Controlled. Pair it with onClose. |
defaultOpen |
boolean |
false |
Uncontrolled first render. |
onClose |
() => void |
— | Fires once whenever the panel has closed, whoever closed it. Takes the name of the native close handler, which the props drop. |
onOpen |
() => void |
— | Fires once whenever the panel has opened, defaultOpen’s first render included. |
unstyled |
boolean |
false |
Drops the root’s class and every part’s; keeps the data-* and the whole platform behaviour. |
DrawerSide is exported alongside the props type.
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Escape |
Closes it. The browser’s close, arriving as the cancel event — there is no document-level key listener, so a popup inside the panel consumes its own Escape first. With dismissible={false} the key is refused and the panel shakes, unless the press closed an open menu or list inside it. |
Tab / Shift+Tab |
Cycles inside the panel. The trap is inert applied by showModal(); nothing is emulated, so it knows about content the library never sees. |
| Click outside | Closes it, or refuses. Only a point outside the border box counts as the backdrop — which matters here, because the two block sides leave gutters of their own beside the centred column, and a click there is content — and only when the press began there too: text selected in the panel and released over the scrim closes nothing. |
| Pull down | block-end only, any pointer. Closes it past half its height or on a flick, refuses with dismissible={false}, springs back otherwise. A shortcut for the keys above, never the only way out. See Pull to dismiss. |
- On open it is announced as a dialog — the native role, named by its
titleand described by itsdescription. There is noalertdialogvariant: a drawer does not interrupt, it holds a task, so the native role is the only one it ever takes and the component writes noroleat all. - Focus moves in, and back out, on its own.
showModal()focuses the first focusable element inside (anautoFocuselsewhere wins instead), and closing hands focus back to whatever had it before — unmounting an open panel too, since the component closes it before React removes the node. - A refusal announces nothing, deliberately. The panel is still open and still holds the focus — that is the state. There is no live region for it; adding one would repeat what the user already sees.
- The close button clears the 24px target floor and declares its own
--_wayra-ring-color, so the one focus-ring rule instates.cssdraws it. - Reduced motion turns the slide into a 160ms fade in place, on every side, and swaps the shake for a still outline flash. Closing, with or without it, runs the entrance’s curve played backwards, so the panel leaves the way it came in — except after a pull, which carries on down from where it was let go, or fades there under reduced motion. The pull itself still follows the hand.
- Windows high contrast draws a
CanvasTextborder, because the panel carries no edge of its own and would sit on the page with nothing separating the two. - RTL mirrors the whole component from the logical properties alone — the side, the margins, the corners — with the entrance sign as the single physical exception.
Do and don’t
Section titled “Do and don’t”Do
A task that lives beside the page: a set of filters the user works through and applies.
Don't
A yes-or-no question stretched down an edge. A decision belongs centred, in a Modal with variant="alert".
Do
A real <dialog>. showModal() gives it the top layer, the scrim, the trap, Escape and the focus handed back.
Don't
Compartir
Elige dónde enviarlo.
The same classes on a <div>. It paints identically and is not modal at all: no top layer, no scrim, no focus trap, no Escape, no focus restoration.