Drawer
Slide-in panel overlay from any viewport edge.
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
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
Frosted glass sheet
class, wash the backdrop in colour, and neutralise the header and footer hairlines with slotClasses. The contain radius tier, slide animation, focus trap and scroll lock all stay. Raw colours because glass has no token equivalent.<Drawer
bind:open
title="Filters"
placement="right"
size="sm"
class="border-white/20 bg-white/10 text-white backdrop-blur-xl"
slotClasses={{
backdrop: 'bg-linear-to-br from-fuchsia-600/50 via-purple-600/50 to-indigo-700/50',
header: 'border-white/15',
title: 'text-white',
closeButton: 'text-white/70 hover:bg-white/10',
footer: 'border-white/15'
}}
>
…
</Drawer>This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
04 Stacking & Nested Drawers
Several Drawers can be open at once: a wizard that opens a preview, which opens a calculation
trace. Each renders through its own native <dialog>, so the browser stacks them in the top layer for you.
Stack order is LIFO
The most recently opened Drawer renders on top. Escape closes that topmost panel and leaves the ones beneath it open. Each Drawer keeps its own focus trap, so keyboard focus stays inside the top panel.
Backdrop and body scroll
Every open Drawer adds its own backdrop. The body-scroll lock is reference-counted: the page stays locked until the last Drawer closes, and closing the top panel hands keyboard control back to the one underneath.
Keep the depth to two or three
A wizard, then a preview, then a trace reads well; past three layers the panels crowd each other, especially on mobile. For a deeper flow, reach for a master-detail pattern (a list beside one replaceable detail panel) instead of nesting.
Vary the size on narrow screens
Each Drawer caps at 100dvw / 100dvh, so on a 320px viewport a stacked Drawer fills
the screen and identical sizes merge into one panel. Give stacked Drawers different sizes (md then lg) so the layering stays legible.
Close every overlay at once
Drawer, Dialog, and the mobile Sidebar register with a shared overlayStack singleton on open. Reach for it when app-level
cleanup spans an unknown overlay depth: logout, a route change, or an expired session. Desktop
Sidebars stay out of the stack, since they are persistent layout rather than modal overlays.
overlayStack
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 (closeOnEscape) and so does a click
on the backdrop (closeOnBackdropClick). Both are on
by default.
Reduced Motion
The slide and fade run on the overlay duration tokens (--blocks-overlay-enter-duration / --blocks-overlay-exit-duration). Under prefers-reduced-motion they collapse to 1ms, so the drawer
opens and closes in place.
06 API Reference
19 propsProp | 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';