Drawer
Slide-in panel overlay from any viewport edge with focus trap, backdrop dismiss, and keyboard support.
Playground
<script lang="ts">
import { Drawer } from '@urbicon-ui/blocks';
let open = $state(false);
</script>
<Drawer
bind:open
accentEdge
intent="primary"
>
<p>This is the drawer content. Try changing the placement and size controls.</p>
</Drawer>01 When to use
Drawer is a <dialog> — always modal, with a backdrop and a
focus trap. Use it for a transient panel that opens on user action, pulls focus, and closes when
the action is done. The four placement values (left / right / top / bottom) cover side
sheets, top notification bars, and mobile bottom-sheets.
Pick a different overlay if you need:
02 Examples
Placements
<Button variant="outlined" onclick={() => (leftOpen = true)}>Left</Button>
<Button variant="outlined" onclick={() => (rightOpen = true)}>Right</Button>
<Button variant="outlined" onclick={() => (topOpen = true)}>Top</Button>
<Button variant="outlined" onclick={() => (bottomOpen = true)}>Bottom</Button>
<Drawer bind:open={leftOpen} title="Left Drawer" placement="left">
<p>This drawer slides in from the left edge.</p>
</Drawer>
<Drawer bind:open={rightOpen} title="Right Drawer" placement="right">
<p>This drawer slides in from the right edge.</p>
</Drawer>
<Drawer bind:open={topOpen} title="Top Drawer" placement="top">
<p>This drawer slides in from the top edge.</p>
</Drawer>
<Drawer bind:open={bottomOpen} title="Bottom Drawer" placement="bottom">
<p>This drawer slides in from the bottom edge.</p>
</Drawer>With Footer
<Button onclick={() => (footerOpen = true)}>Open with Footer</Button>
<Drawer bind:open={footerOpen} title="Confirm Changes">
<p>Are you sure you want to apply these changes? This action cannot be undone.</p>
{#snippet footer()}
<Button variant="ghost" onclick={() => (footerOpen = false)}>Cancel</Button>
<Button intent="primary" onclick={() => (footerOpen = false)}>Confirm</Button>
{/snippet}
</Drawer>Settings Panel
<Button onclick={() => (settingsOpen = true)}>Open Settings</Button>
<Drawer bind:open={settingsOpen} title="Settings" placement="right" size="sm">
<div class="space-y-4">
<Input label="Display name" placeholder="Your name" />
<Input label="Email" placeholder="name@example.com" type="email" />
<Separator />
<Toggle label="Email notifications" checked />
<Toggle label="Dark mode" />
<Toggle label="Analytics" checked />
</div>
{#snippet footer()}
<Button variant="ghost" onclick={() => (settingsOpen = false)}>Cancel</Button>
<Button intent="primary" onclick={() => (settingsOpen = false)}>Save</Button>
{/snippet}
</Drawer>Navigation Menu
<Button onclick={() => (navOpen = true)}>Open Menu</Button>
<Drawer bind:open={navOpen} title="Menu" placement="left" size="sm">
<nav class="flex flex-col gap-1">
{#each ['Dashboard', 'Projects', 'Team', 'Settings', 'Help'] as item (item)}
<button
class="text-text-primary hover:bg-surface-hover rounded-modify px-3 py-2.5 text-left text-sm transition-colors"
onclick={() => (navOpen = false)}
>
{item}
</button>
{/each}
</nav>
</Drawer>03 Customization
slotClasses Override
<Drawer
bind:open
title="Filters"
placement="right"
slotClasses={{
backdrop: 'backdrop-blur-none bg-black/30',
panel: 'rounded-none border-l-2 border-border-default',
body: 'px-8'
}}
>
…
</Drawer>unstyled strips the panel chrome (surface, border,
shadow) while the native <dialog>, focus trap,
and placement transitions keep working — rebuild the sheet through slotClasses. A drawer treatment shared across the app
(e.g. a brand filter sheet) belongs in a BlocksProvider preset (presets.Drawer, applied via preset) — see Customization.
04 Stacking & Nested Drawers
Multiple Drawers can be open at the same time — for example a wizard that opens a preview,
which opens a calculation trace. Drawer is rendered with a native <dialog>, so the browser handles the top-layer
stacking order automatically.
Stack order is LIFO
The most recently opened Drawer renders on top. Pressing Escape closes the topmost drawer; the underlying ones stay open. Each Drawer manages its own focus-trap, so keyboard navigation stays inside the topmost panel.
Backdrop & body-scroll
Each open Drawer adds its own backdrop. The body-scroll lock is reference-counted — scroll stays locked until every Drawer is closed. Closing the topmost panel revives keyboard interaction with the panel underneath.
Recommended depth: 2–3
Two or three layers (e.g. wizard → preview → trace) work well in practice. Beyond that, the visual stack becomes cramped, especially on mobile. Consider a master-detail pattern (list + replaceable detail panel) instead of deep nesting.
Mobile caveat
Each Drawer caps its size at 100dvw / 100dvh, so a stacked Drawer on a 320 px viewport
becomes effectively full-width. Two or three identical-size Drawers stack visually as a
single panel — open them with different sizes (e.g. md → lg) so the user can see the layering on narrow screens.
Programmatic overlayStack
Drawer, Dialog, and (mobile) Sidebar all auto-register with a shared overlayStack singleton on open. Use this for app-level
cleanup that has to span unknown overlay depth — typically logout, route changes, or auth expiry.
The stack stays untouched on Desktop sidebars (they're persistent layout, not modal overlays).
import { overlayStack } from '@urbicon-ui/blocks';
// Close every open Drawer, Dialog, and mobile Sidebar (top-down)
overlayStack.closeAll();
// Inspect the stack
overlayStack.depth; // number
overlayStack.topId; // string | null05 Accessibility
Focus Trap
When open, focus is trapped inside the drawer panel. Tab cycles through focusable elements. On close, focus returns to the element that triggered the drawer.
ARIA
Uses native <dialog> with aria-labelledby linked to the title and aria-describedby linked to the body content. The
close button has an aria-label.
Keyboard
Escape closes the drawer (configurable via closeOnEscape). Backdrop click dismiss is also
configurable.
Reduced Motion
Slide and fade transitions respect prefers-reduced-motion. The drawer appears and
disappears instantly without animation.
06 API Reference
Prop | Type | Default | Description | |
|---|---|---|---|---|
children required | Snippet | — | Content rendered inside the drawer body. | |
accentEdge | DrawerVariants['accentEdge'] | false | Tint the panel's docked (viewport-facing) edge with a 2px accent border in
the intent colour — border-right for left, border-left for
right, border-bottom for top, border-top for bottom. Off by
default, keeping symmetry with Dialog; opt in for a coloured seam that ties
the drawer to a semantic purpose (e.g. a danger confirm drawer). | |
class | string | — | Additional CSS classes applied to the drawer panel. | |
closeOnBackdropClick | boolean | true | Whether clicking the backdrop closes the drawer. | |
closeOnEscape | boolean | true | Whether pressing Escape closes the drawer.
Escape is dismissed one layer at a time: a control INSIDE the drawer that
handles Escape itself — an open Select/Combobox/Menu panel, a
clearable Input with text in it — consumes the key, and the drawer stays
up. The second Escape closes the drawer. This is about controls in the
content; it does not apply to a consumer onkeydown on the Drawer itself,
which cannot veto the dismiss (see utils/compose-handlers.ts) — use this
prop for that. | |
footer | Snippet | — | Action buttons rendered in the drawer footer. | |
hideCloseButton | boolean | false | Hides the built-in close button in the header. | |
intent | DrawerVariants['intent'] | 'neutral' | Semantic purpose marker (mirrors Dialog). By default the Drawer paints no
accent border — the value is exposed on the panel as data-intent="…" so
consumers can hook presets, CSS overrides, or icon/title color via their own
snippets. Set accentEdge to also tint the docked edge in this colour. | |
onClose | () => void | — | Fires when the drawer is dismissed via Escape, backdrop click, or close button. | |
open | boolean | — | Controls whether the drawer is visible. Supports bind:open. | |
placement | DrawerVariants['placement'] | 'right' | Edge of the viewport from which the drawer slides in. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Drawer: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
size | DrawerVariants['size'] | 'md' | Width (for left/right) or height (for top/bottom) of the drawer panel. | |
slotClasses | Partial<Record<DrawerSlots, string>> | — | Per-slot class overrides merged with variant styles. | |
title | string | — | Heading displayed in the drawer header. | |
transitionDuration | number | — | Override the enter/exit animation duration in milliseconds.
Defaults to the overlay token --blocks-overlay-enter-duration /
--blocks-overlay-exit-duration (200ms / 180ms). Respects
prefers-reduced-motion. | |
transitionEasing | (t: number) => number | — | Override the enter/exit easing function. Defaults to the overlay token easing (quintOut). | |
unstyled | boolean | — | Strip all default styles. Combine with slotClasses for fully custom appearance. | |
...HTMLDialogAttributes inherited | HTMLAttributes | — | HTML attributes (excluding: 'children' | 'open') |
07 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
DrawerProps | interface | props | 0 | — | |
DrawerVariants | type | variant | 0 | — | |
DrawerSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. |
08 Installation
Import
import { Drawer } from '@urbicon-ui/blocks';