Skip to content

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

Filtros

Se aplican al cerrar el panel.

Elige el rango de fechas y el estado del pedido.

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

Se aplican al cerrar el panel.

Elige el rango de fechas y el estado del pedido.

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.

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’s open and the DOM’s.
  • Name and description — title as aria-labelledby, description merged into aria-describedby, closeLabel as 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, the onOpen / onClose pair read off the platform, and why the native open attribute 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 backdrop prop, its eight steps and why every one of them is a literal. A Drawer takes it on exactly the same terms.

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.

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.

Filtros

Se aplican al cerrar el panel.

Elige el rango de fechas y el estado del pedido.

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.

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.

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.

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.

md — 26rem

El escalón por defecto, escrito como data-size="md".

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 start and end it is the width of the panel itself.
  • On block-start and block-end the 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.

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".

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.

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.

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.

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.

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.

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 title and described by its description. There is no alertdialog variant: a drawer does not interrupt, it holds a task, so the native role is the only one it ever takes and the component writes no role at all.
  • Focus moves in, and back out, on its own. showModal() focuses the first focusable element inside (an autoFocus elsewhere 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 in states.css draws 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 CanvasText border, 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

Filtros

Se aplican al cerrar.

Rango de fechas, estado y almacén.

A task that lives beside the page: a set of filters the user works through and applies.

Don't

¿Eliminar el pedido?

No se puede deshacer.

A yes-or-no question stretched down an edge. A decision belongs centred, in a Modal with variant="alert".

Do

Compartir

Elige dónde enviarlo.

Copiar enlace, correo, exportar.

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.

Copiar enlace, correo, exportar.

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.