Skip to main content
Urbicon UI

Drawer

Slide-in panel overlay from any viewport edge with focus trap, backdrop dismiss, and keyboard support.

Playground

Placement
<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:

  • A side panel that is part of the page layout (persistent on desktop, no backdrop) → Sidebar.
  • A floating panel anchored to a specific trigger element (date picker, action list) → Popover.
  • A centered modal for confirmations or short forms → Dialog.

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

Action buttons rendered in a sticky footer area.
<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

A realistic settings drawer with form controls.
<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

A mobile-style navigation drawer from the left.
<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 exposes dialog, backdrop, panel, header, title, body, and footer as slots. Here the backdrop loses its blur, the panel gets a square edge with a stronger border, and the body more breathing room — slide animation, focus trap, and stacking stay untouched.
<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. mdlg) 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 | null

05 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

19 props
19 props 1 required
Prop
Type
Default
Description

07 Types

Local type definitions used by this component.

3 types
Name
Kind
Category
Used by
Description

08 Installation

Import

import { Drawer } from '@urbicon-ui/blocks';