Drawer
A sliding panel with swipe-to-dismiss, modal focus management, and an optional snap point.
@data-slot/drawerSource ↗bun add @data-slot/drawernpm install @data-slot/drawerpnpm add @data-slot/drawerAnatomy
Only drawer-popup is required inside the root. It is the dialog surface; drawer-content is an optional inner content or scroll container. Add drawer-title and drawer-description to supply its accessible name and description. The optional portal moves to document.body while open.
<div data-slot="drawer" data-swipe-direction="down">
<button type="button" data-slot="drawer-trigger">Open drawer</button>
<div data-slot="drawer-portal">
<div data-slot="drawer-backdrop" hidden></div>
<div data-slot="drawer-viewport" hidden>
<div data-slot="drawer-popup" hidden>
<div data-slot="drawer-content">
<h2 data-slot="drawer-title">Title</h2>
<p data-slot="drawer-description">Description</p>
<button type="button" data-slot="drawer-close">Close</button>
</div>
</div>
</div>
</div>
</div>
Set the popup’s size, position, and transitions in CSS. data-swipe-direction controls the dismissal gesture; pair it with a layout and transform for that edge. Include --drawer-swipe-amount-x or --drawer-swipe-amount-y in the transform so the panel follows the pointer.
Examples
Open a side panel
Use data-swipe-direction="right" for a panel anchored to the right edge. By default the drawer traps focus, makes the rest of the page inert, and locks scrolling. Swipe right, click the backdrop, press Escape, or use the close button to dismiss it.
Show code
<div id="drawer-basic-drawer-css" data-slot="drawer" data-swipe-direction="right">
<button type="button" data-slot="drawer-trigger" class="drawer-demo-trigger">Open drawer</button>
<div data-slot="drawer-portal">
<div data-slot="drawer-backdrop" class="drawer-demo-backdrop" hidden></div>
<div data-slot="drawer-viewport" class="drawer-demo-viewport" hidden>
<div data-slot="drawer-popup" class="drawer-demo-popup" hidden>
<div data-slot="drawer-content" class="drawer-demo-content">
<h2 data-slot="drawer-title" class="drawer-demo-title">Drawer</h2>
<p data-slot="drawer-description" class="drawer-demo-description">This drawer slides in from the right. Swipe right, click outside, or press Escape to close it.</p>
<button type="button" data-slot="drawer-close" class="drawer-demo-close">Close drawer</button>
</div>
</div>
</div>
</div>
</div>
<style>
.drawer-demo-trigger {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
padding: 0.5rem 1rem;
background: var(--surface);
color: var(--text);
}
.drawer-demo-backdrop {
position: fixed;
inset: 0;
z-index: 110;
background: rgb(0 0 0 / 45%);
opacity: calc(1 - var(--drawer-swipe-progress, 0));
transition: opacity 0.3s ease;
}
.drawer-demo-viewport {
position: fixed;
inset: 0;
z-index: 111;
display: flex;
justify-content: end;
width: 100%;
height: 100dvh;
pointer-events: none;
}
.drawer-demo-popup {
display: flex;
flex-direction: column;
width: min(100%, 24rem);
height: 100%;
pointer-events: auto;
border-left: 1px solid var(--border);
background: var(--bg);
color: var(--text);
box-shadow: 0 0 50px rgb(0 0 0 / 20%);
transform: translateX(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-amount-x, 0px)));
transition: transform 0.3s ease;
}
.drawer-demo-popup[data-starting-style],
.drawer-demo-popup[data-ending-style] {
transform: translateX(100%);
}
.drawer-demo-backdrop[data-starting-style],
.drawer-demo-backdrop[data-ending-style] {
opacity: 0;
}
.drawer-demo-popup[data-swiping],
.drawer-demo-backdrop[data-swiping] {
transition: none;
}
.drawer-demo-content {
display: grid;
min-height: 0;
align-content: start;
gap: 1rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1.5rem;
}
.drawer-demo-title {
margin: 0;
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: 700;
}
.drawer-demo-description {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--muted);
}
.drawer-demo-close {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
background: transparent;
}
.drawer-demo-close:hover {
background: var(--code-bg);
}
.drawer-demo-backdrop[hidden],
.drawer-demo-viewport[hidden],
.drawer-demo-popup[hidden] {
display: none;
}
/* Bottom sheet: keep its content inside the visible snap-point height. */
.drawer-sheet-viewport {
align-items: end;
justify-content: center;
overflow: hidden;
}
.drawer-sheet-popup {
width: 100%;
max-width: 32rem;
height: 80dvh;
border: 1px solid var(--border);
transform: translateY(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-amount-y, 0px)));
}
.drawer-sheet-popup[data-starting-style],
.drawer-sheet-popup[data-ending-style] {
transform: translateY(100%);
}
.drawer-sheet-handle {
flex-shrink: 0;
width: 2.5rem;
height: 0.25rem;
margin: 0.75rem auto 0;
border-radius: 9999px;
background: var(--border);
}
.drawer-sheet-content {
max-height: calc(100% - var(--drawer-snap-point-offset, 0px) - 1rem);
}
@media (prefers-reduced-motion: reduce) {
.drawer-demo-popup,
.drawer-demo-backdrop {
transition: none;
}
}
</style><div id="drawer-basic-drawer-tailwind" data-slot="drawer" data-swipe-direction="right">
<button type="button" data-slot="drawer-trigger" class="cursor-pointer border border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-text">Open drawer</button>
<div data-slot="drawer-portal">
<div data-slot="drawer-backdrop" class="fixed inset-0 z-[110] bg-black/45 opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-[ease] motion-reduce:transition-none data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[swiping]:transition-none hidden:hidden" hidden></div>
<div data-slot="drawer-viewport" class="pointer-events-none fixed inset-0 z-[111] flex h-dvh w-full justify-end hidden:hidden" hidden>
<div data-slot="drawer-popup" class="pointer-events-auto flex h-full w-full max-w-sm flex-col border-l border-[var(--border)] bg-[var(--bg)] text-[var(--text)] shadow-[0_0_50px_rgb(0_0_0/20%)] transition-transform duration-300 ease-[ease] motion-reduce:transition-none [transform:translateX(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-amount-x,0px)))] data-[starting-style]:[transform:translateX(100%)] data-[ending-style]:[transform:translateX(100%)] data-[swiping]:transition-none hidden:hidden" hidden>
<div data-slot="drawer-content" class="grid min-h-0 content-start gap-4 overflow-y-auto overscroll-contain p-6">
<h2 data-slot="drawer-title" class="m-0 text-lg leading-7 font-bold">Drawer</h2>
<p data-slot="drawer-description" class="m-0 text-sm leading-[1.5] text-[var(--muted)]">This drawer slides in from the right. Swipe right, click outside, or press Escape to close it.</p>
<button type="button" data-slot="drawer-close" class="cursor-pointer border border-[var(--border)] bg-transparent px-3 py-2 hover:bg-[var(--code-bg)]">Close drawer</button>
</div>
</div>
</div>
</div>
</div>
import { create } from "@data-slot/drawer";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());Set a bottom sheet’s open height
data-snap-point="0.6" shows 60% of the viewport, capped at the popup’s CSS height. This sheet has a full height of 80dvh; its transform combines --drawer-snap-point-offset with the vertical swipe movement. Its scrollable content stays within the visible portion of the sheet.
Show code
<div id="drawer-extra-drawer-sheet-css" data-slot="drawer" data-swipe-direction="down" data-snap-point="0.6">
<button type="button" data-slot="drawer-trigger" class="drawer-demo-trigger">Open bottom sheet</button>
<div data-slot="drawer-portal">
<div data-slot="drawer-backdrop" class="drawer-demo-backdrop" hidden></div>
<div data-slot="drawer-viewport" class="drawer-demo-viewport drawer-sheet-viewport" hidden>
<div data-slot="drawer-popup" class="drawer-demo-popup drawer-sheet-popup" hidden>
<div class="drawer-sheet-handle" aria-hidden="true"></div>
<div data-slot="drawer-content" class="drawer-demo-content drawer-sheet-content">
<h2 data-slot="drawer-title" class="drawer-demo-title">A little room to focus</h2>
<p data-slot="drawer-description" class="drawer-demo-description">This sheet opens to 60% of the viewport. Drag it down to dismiss, or release a short drag to return to its open position.</p>
<button type="button" data-slot="drawer-close" class="drawer-demo-close">Got it</button>
</div>
</div>
</div>
</div>
</div>
<style>
.drawer-demo-trigger {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
padding: 0.5rem 1rem;
background: var(--surface);
color: var(--text);
}
.drawer-demo-backdrop {
position: fixed;
inset: 0;
z-index: 110;
background: rgb(0 0 0 / 45%);
opacity: calc(1 - var(--drawer-swipe-progress, 0));
transition: opacity 0.3s ease;
}
.drawer-demo-viewport {
position: fixed;
inset: 0;
z-index: 111;
display: flex;
justify-content: end;
width: 100%;
height: 100dvh;
pointer-events: none;
}
.drawer-demo-popup {
display: flex;
flex-direction: column;
width: min(100%, 24rem);
height: 100%;
pointer-events: auto;
border-left: 1px solid var(--border);
background: var(--bg);
color: var(--text);
box-shadow: 0 0 50px rgb(0 0 0 / 20%);
transform: translateX(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-amount-x, 0px)));
transition: transform 0.3s ease;
}
.drawer-demo-popup[data-starting-style],
.drawer-demo-popup[data-ending-style] {
transform: translateX(100%);
}
.drawer-demo-backdrop[data-starting-style],
.drawer-demo-backdrop[data-ending-style] {
opacity: 0;
}
.drawer-demo-popup[data-swiping],
.drawer-demo-backdrop[data-swiping] {
transition: none;
}
.drawer-demo-content {
display: grid;
min-height: 0;
align-content: start;
gap: 1rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1.5rem;
}
.drawer-demo-title {
margin: 0;
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: 700;
}
.drawer-demo-description {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--muted);
}
.drawer-demo-close {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
background: transparent;
}
.drawer-demo-close:hover {
background: var(--code-bg);
}
.drawer-demo-backdrop[hidden],
.drawer-demo-viewport[hidden],
.drawer-demo-popup[hidden] {
display: none;
}
/* Bottom sheet: keep its content inside the visible snap-point height. */
.drawer-sheet-viewport {
align-items: end;
justify-content: center;
overflow: hidden;
}
.drawer-sheet-popup {
width: 100%;
max-width: 32rem;
height: 80dvh;
border: 1px solid var(--border);
transform: translateY(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-amount-y, 0px)));
}
.drawer-sheet-popup[data-starting-style],
.drawer-sheet-popup[data-ending-style] {
transform: translateY(100%);
}
.drawer-sheet-handle {
flex-shrink: 0;
width: 2.5rem;
height: 0.25rem;
margin: 0.75rem auto 0;
border-radius: 9999px;
background: var(--border);
}
.drawer-sheet-content {
max-height: calc(100% - var(--drawer-snap-point-offset, 0px) - 1rem);
}
@media (prefers-reduced-motion: reduce) {
.drawer-demo-popup,
.drawer-demo-backdrop {
transition: none;
}
}
</style><div id="drawer-extra-drawer-sheet-tailwind" data-slot="drawer" data-swipe-direction="down" data-snap-point="0.6">
<button type="button" data-slot="drawer-trigger" class="cursor-pointer border border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-text">Open bottom sheet</button>
<div data-slot="drawer-portal">
<div data-slot="drawer-backdrop" class="fixed inset-0 z-[110] bg-black/45 opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-[ease] motion-reduce:transition-none data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[swiping]:transition-none hidden:hidden" hidden></div>
<div data-slot="drawer-viewport" class="pointer-events-none fixed inset-0 z-[111] flex h-dvh w-full items-end justify-center overflow-hidden hidden:hidden" hidden>
<div data-slot="drawer-popup" class="pointer-events-auto flex h-[80dvh] w-full max-w-lg flex-col border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] shadow-[0_0_50px_rgb(0_0_0/20%)] transition-transform duration-300 ease-[ease] motion-reduce:transition-none [transform:translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-amount-y,0px)))] data-[starting-style]:[transform:translateY(100%)] data-[ending-style]:[transform:translateY(100%)] data-[swiping]:transition-none hidden:hidden" hidden>
<div class="mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-[var(--border)]" aria-hidden="true"></div>
<div data-slot="drawer-content" class="grid min-h-0 max-h-[calc(100%_-_var(--drawer-snap-point-offset,0px)_-_1rem)] content-start gap-4 overflow-y-auto overscroll-contain p-6">
<h2 data-slot="drawer-title" class="m-0 text-lg leading-7 font-bold">A little room to focus</h2>
<p data-slot="drawer-description" class="m-0 text-sm leading-[1.5] text-[var(--muted)]">This sheet opens to 60% of the viewport. Drag it down to dismiss, or release a short drag to return to its open position.</p>
<button type="button" data-slot="drawer-close" class="cursor-pointer border border-[var(--border)] bg-transparent px-3 py-2 hover:bg-[var(--code-bg)]">Got it</button>
</div>
</div>
</div>
</div>
</div>
import { create } from "@data-slot/drawer";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());A drawer supports one open position. Dragging either returns to that position or dismisses it; it does not cycle through multiple snap points. Snap points also accept pixel numbers greater than 1, or strings such as "320px" and "24rem". Use setSnapPoint() or a drawer:set event to change the position after initialization; changing the data attribute alone does not update it.
API reference
Initialization
create(scope?)
Find and bind every [data-slot="drawer"] in scope (the document by default).
import { create } from "@data-slot/drawer";
const drawers = create();
createDrawer(root, options?)
Create one drawer controller. JavaScript options take precedence over data attributes.
import { createDrawer } from "@data-slot/drawer";
const drawer = createDrawer(element, {
modal: true,
swipeDirection: "down",
snapPoint: 0.6,
});
drawer.open();
drawer.setSnapPoint("320px");
Slots
Runtime Slots
drawer- Root element that manages the open state and receives drawer events.drawer-trigger- Optional button that opens or toggles the drawer; can target a root from outside it withdata-drawer-target.drawer-portal- Optional wrapper moved to the portal destination while the drawer is open.drawer-backdrop- Optional backdrop that receives open and swipe state for styling.drawer-viewport- Optional interaction and measurement wrapper around the popup, typically styled as a fixed viewport.drawer-popup- Required dialog surface that receives focus management, swipe gestures, and snap-point positioning.drawer-title- Optional title used for the popup’saria-labelledby.drawer-description- Optional description used for the popup’saria-describedby.drawer-close- Optional button that closes the drawer.drawer-provider- Optional ancestor wrapper that coordinates the page indentation state for related drawers.drawer-indent- Optional page surface inside a provider; receives active state and drawer size/swipe variables for a receding-page effect.drawer-indent-background- Optional background behind that page surface; receives the same provider state and variables.drawer-virtual-keyboard-provider- Optional wrapper that enables the drawer viewport to adapt to the on-screen keyboard.
Style-only Slots
drawer-content- Optional inner wrapper for content layout or scrolling inside the popup.drawer-handle- Optional visual drag handle. Swipe behavior belongs to the popup and does not require this slot.
Detached Triggers
Detached triggers can live outside the root when the root has an ID:
<button
data-slot="drawer-trigger"
data-drawer-target="filters-drawer"
data-payload='{"source":"toolbar"}'
>
Filters
</button>
data-payload accepts JSON or a plain string. The parsed value is forwarded in change event details.
Use native <button> elements for triggers and close controls. Native disabled, data-disabled, and aria-disabled="true" block their activation.
Composition Slots
The package also recognizes drawer-provider, drawer-indent, drawer-indent-background, and drawer-virtual-keyboard-provider. They are ordinary DOM wrappers rather than React context providers.
Wrap related drawers in a provider. Put the page surface that should visually recede inside drawer-indent and its background behind that surface. A nested drawer remains a complete drawer root inside the parent popup.
<div data-slot="drawer-provider">
<div data-slot="drawer-indent-background"></div>
<main data-slot="drawer-indent">...</main>
<div id="parent" data-slot="drawer">
...
<div data-slot="drawer-popup">
<div id="child" data-slot="drawer">
<button data-slot="drawer-trigger">Open details</button>
...
</div>
</div>
</div>
</div>
Wrap the viewport inside drawer-virtual-keyboard-provider when form controls in that drawer need the viewport to follow the on-screen keyboard:
<div data-slot="drawer">
<div data-slot="drawer-virtual-keyboard-provider">
<div data-slot="drawer-viewport">
<div data-slot="drawer-popup">...</div>
</div>
</div>
</div>
Data Attributes
Root attributes and their JavaScript equivalents are listed in Options.
Popup-specific attributes:
| Attribute | Type | Default | Description |
|---|---|---|---|
data-initial-focus |
boolean or selector | popup | Use true for the first focusable element, a selector for an explicit target, or false to skip automatic focus. |
data-final-focus |
boolean or selector | true |
Choose the focus target after close, or set false to skip focus restoration. |
Portal-specific attributes:
| Attribute | Type | Default | Description |
|---|---|---|---|
data-container |
selector | body |
Element that receives the portal. |
data-keep-mounted |
boolean | false |
Keep portal content mounted while closed. |
Options
| Option | Data attribute | Type | Default | Description |
|---|---|---|---|---|
open / defaultOpen |
data-default-open |
boolean |
false |
Initial open state. open and the attribute are initialization values, not controlled state. |
modal |
data-modal |
boolean | "trap-focus" |
true |
true makes outside content inert and locks scroll; "trap-focus" traps focus without making outside content inert. |
disablePointerDismissal |
data-disable-pointer-dismissal |
boolean |
false |
Ignore outside pointer presses. |
closeOnEscape |
data-close-on-escape |
boolean |
true |
Close when Escape is pressed. |
swipeDirection |
data-swipe-direction |
"down" | "up" | "left" | "right" |
"down" |
Direction used to dismiss the drawer. |
snapPoint / defaultSnapPoint |
data-snap-point / data-default-snap-point |
DrawerSnapPoint | null |
null |
Initial single open position. snapPoint takes precedence over the default. null uses the full CSS size. |
triggerId / defaultTriggerId |
data-trigger-id / data-default-trigger-id |
string | null |
— | Initial detached trigger identifier. |
initialFocus |
popup data-initial-focus |
boolean | string | HTMLElement |
popup | Choose focus when the drawer opens. |
finalFocus |
popup data-final-focus |
boolean | string | HTMLElement |
trigger or previous focus | Choose focus when the drawer closes. |
keepMounted |
portal data-keep-mounted |
boolean |
false |
Keep portal content mounted while closed. |
container |
portal data-container |
string | HTMLElement |
document.body |
Portal destination. |
onOpenChange |
— | (open, details) => void |
— | Called before committing an open-state change. Call details.cancel() to stop it. |
onSnapPointChange |
— | (snapPoint, details) => void |
— | Called before replacing the single snap point. Call details.cancel() to stop it. |
onOpenChangeComplete |
— | (open) => void |
— | Called when the opening or closing transition completes. |
A snap point is a viewport fraction (0 to 1), a pixel number greater than 1, or a string in px or rem. It is capped at the popup’s CSS size. Only one open position is supported; arrays and sequential snap points are not supported. A swipe returns to that position or dismisses the drawer. Use setSnapPoint() or drawer:set to replace it at runtime; the value persists across close/open cycles. Options and data attributes are read at initialization.
Controller
| Method or property | Description |
|---|---|
open(triggerId?) |
Open the drawer, optionally associating an identified trigger. |
close() |
Close the drawer. |
toggle() |
Toggle the open state. |
setSnapPoint(value) |
Replace the single open position; null restores the full CSS size. |
snapPoint |
Current snap point (readonly). |
unmount() |
Unmount closed portal content. |
isOpen |
Current open state (readonly). |
triggerId |
Trigger associated with the current open cycle (readonly). |
destroy() |
Remove listeners and restore DOM state. |
Events
Serializable data attributes and DOM events make the component usable directly from Astro markup and across framework boundaries. JavaScript consumers can use the matching callback options.
Outbound Events
| Event | Detail | Cancelable | Description |
|---|---|---|---|
drawer:beforechange |
{ open, reason, trigger, payload, originalEvent?, cancel(), preventUnmountOnClose() } |
yes | Fires before the open state changes. Call event.preventDefault() or detail.cancel() to cancel it. |
drawer:change |
{ open, reason, trigger, payload, originalEvent?, cancel(), preventUnmountOnClose() } |
no | Fires after the open state changes. The methods have no effect after the change commits. |
drawer:change-complete |
{ open } |
no | Fires after the opening or closing transition completes. |
drawer:beforesnapchange |
{ snapPoint, reason, originalEvent?, cancel() } |
yes | Fires before replacing the single snap point. Cancel with event.preventDefault() or detail.cancel(). |
drawer:snapchange |
{ snapPoint, reason, originalEvent?, cancel() } |
no | Fires after the snap point changes. |
Reasons are trigger-press, close-press, outside-press, escape-key, focus-out, imperative-action, swipe, or none.
Open-state requests made inside change listeners or onOpenChange run after the current change finishes. If a callback makes several requests, the latest state and trigger win. Requesting the same state and trigger as the current change does not repeat its callbacks; use details.cancel() to cancel that change.
preventUnmountOnClose() on drawer:beforechange keeps the portal mounted for the current close. This is useful when an application needs to coordinate its own exit animation.
root.addEventListener("drawer:beforechange", (event) => {
if (!event.detail.open && formIsDirty) event.preventDefault();
});
Inbound Events
| Event | Detail | Description |
|---|---|---|
drawer:set |
{ open?, snapPoint?, triggerId? } |
Set one or more state values. |
drawer:open |
— | Open the drawer. |
drawer:close |
— | Close the drawer. |
drawer:toggle |
— | Toggle the drawer. |
drawer:unmount |
— | Unmount closed portal content. |
root.dispatchEvent(new CustomEvent("drawer:set", {
detail: { open: true, snapPoint: 0.6, triggerId: "toolbar" },
}));
Styling
Root, portal, backdrop, viewport, and popup expose data-state="open|closed", data-open, data-closed, and data-swipe-direction. The active trigger gets data-popup-open. During gestures the viewport, backdrop, and popup expose data-swiping. Nested popups additionally expose data-nested, data-nested-drawer-open, and data-nested-swiping; indentation parts expose data-active or data-inactive. A keyboard-aware viewport exposes data-keyboard-open while the on-screen keyboard overlaps it.
Popup and backdrop expose data-starting-style and data-ending-style for entry and exit transitions. The runtime waits for exit transitions before hiding or restoring the portal.
CSS variables include:
--drawer-heightand--drawer-width--drawer-snap-point-offset(signed offset for the configured direction)--drawer-swipe-amount-xand--drawer-swipe-amount-y--drawer-swipe-progressand release--drawer-swipe-strength(0.1to1)--nested-drawersand--drawer-front-height--drawer-keyboard-insetondrawer-viewport
[data-slot="drawer-backdrop"] {
opacity: calc(1 - var(--drawer-swipe-progress, 0));
}
[data-slot="drawer-popup"] {
transform: translateY(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-amount-y, 0px)));
}
Accessibility
The runtime sets role="dialog", aria-labelledby, and aria-describedby on the popup, adds aria-modal="true" in fully modal mode, and maintains aria-expanded, aria-controls, and aria-haspopup="dialog" on triggers. Modal and "trap-focus" drawers trap focus and restore it after close; fully modal drawers also make outside content inert and lock body scroll. Escape, close controls, allowed outside presses, and a swipe in the configured direction dismiss the drawer.
Behavior
Base UI Adaptation
The DOM parts and interaction model follow Base UI Drawer where they translate to static HTML. React-only facilities such as JSX render props, React context, controlled open values, and React animation internals are represented by ordinary wrappers, data attributes, native pointer gestures, controller methods, callbacks, and cancelable custom events. Initial values in HTML are read once; dispatch drawer:set or call the controller to update live state.