Drawer
The <x-wirekit:: component creates accessible side panels that slide in from any edge of the screen. Drawers share the same overlay behavior as modals (focus trap, scroll lock, events) but use slide transitions.
Usage
Width & Layout
Drawer width (for left/right) or height (for top/bottom) is controlled by the size prop. One token drives both axes, so a top drawer at md is as tall as a right drawer at md is wide:
| Size | Width (left / right) |
Height (top / bottom) |
Token |
|---|---|---|---|
sm |
20rem (320px) | 20rem (320px) | --size-wk-drawer-sm |
md (default) |
28rem (448px) | 28rem (448px) | --size-wk-drawer-md |
lg |
40rem (640px) | 40rem (640px) | --size-wk-drawer-lg |
sm, md and lg are the whole set. Any other value renders at md and says so through strict validation — it throws in a console or test run, logs in a development request, and stays quiet in production.
Every size above is wider than a phone, so the panel is clamped against the viewport — calc(100dvw - var(--size-wk-drawer-inset)) on left/right, calc(100dvh - var(--size-wk-drawer-inset)) on top/bottom. The strip of backdrop that leaves is what keeps the drawer reading as a sheet over the page; retune it with --size-wk-drawer-inset.
<x-wirekit::drawer name="filters" size="sm" position="right">…</x-wirekit::drawer>
Positions
<!-- Right (default) -->
<x-wirekit::drawer name="panel" position="right">
<!-- Left -->
<x-wirekit::drawer name="nav" position="left">
<!-- Top -->
<x-wirekit::drawer name="notification" position="top">
<!-- Bottom -->
<x-wirekit::drawer name="details" position="bottom">
On a page whose viewport meta tag sets viewport-fit to cover, the panel keeps its header, body and footer clear of a notch, a camera island and a home indicator on the edges of the screen it touches, and its surface still reaches those edges.
With Livewire wire:model
<x-wirekit::drawer name="filters" wire:model="showFilters">
<x-wirekit::drawer.header>Filters</x-wirekit::drawer.header>
<x-wirekit::drawer.body>
<!-- Filter controls -->
</x-wirekit::drawer.body>
</x-wirekit::drawer>
When the user closes the drawer (ESC, backdrop, close button), the Livewire property is automatically set to false.
Livewire Integration
wire:modelsyncs drawer open/close state with a Livewire propertywire:ignore.prevents Livewire DOM morphing issuesself wire:navigate(SPA mode) automatically closes open drawers
Behavior
- Focus trap (bundled) prevents keyboard navigation outside the drawer
- Scroll lock hides body scrollbar while drawer is open
- Slide transition direction matches the drawer position
- No room in place: the drawer's own element draws no box where you write it, so it adds no gap to a row of buttons it stands in.
- Teleport renders drawer at
<body>level for correct stacking. Put a<form>inside the drawer rather than around it: a form around the drawer no longer contains the fields in it, and a plain submit leaves them out - Shared overlay logic with Modal — identical focus trap, scroll lock, and event handling
- Asks before discarding: once the reader changed a field in the drawer, Escape, a click beside it and the close button first ask "Discard your changes?" in the reader's language, in an alert dialog over the drawer, as a modal does. Stay, which has the focus and which Escape answers too, keeps the drawer and what was typed; Discard closes the drawer the way the reader started to. A Cancel button and a close your page makes do not ask.
:confirm-discard="false"turns it off for one drawer and'confirm-discard' => falseundercomponents.indrawer config/for all of them,wirekit. php discard-questionreplaces the question
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
required | Unique drawer identifier |
position |
string |
'right' |
Slide direction: left, right, top, bottom |
size |
string |
'md' |
Panel size: sm, md, lg. Width for left/right, height for top/bottom |
dismissible |
bool |
true |
Whether ESC and backdrop click close the drawer |
confirmDiscard |
bool |
true |
Ask before the reader closes the drawer by ESC, a backdrop click or the close button while its fields hold changes |
discardQuestion |
string|null |
null |
The question asked, in place of "Discard your changes?" in the reader's language |
lockScroll |
bool |
true |
Lock the page's scroll while the drawer is open. Set to false for a drawer that lives inside a page region, such as a preview, where the page around it has to keep scrolling |
describedby |
string|null |
null |
Element ID referenced by the dialog's aria-describedby (wire to <x-wirekit::drawer.body id="...">) |
label |
string|null |
null |
An explicit accessible name, for a drawer composed without <x-wirekit::drawer.header>. Without it, and without an aria-label you pass, such a drawer would announce as a bare "dialog": the built-in aria-labelledby points at an id only the header binds. The component reports that case through strict validation, as a modal does. WCAG 2.1 4.1.2 (Level A). |
scope |
string|null |
null |
Scoped personalization key |
Sub-Components
| Component | Purpose |
|---|---|
drawer.header |
Title area at the top with bottom border + auto close-X (dismissible only) |
drawer.body |
Scrollable content area (uses flex-1) |
drawer.footer |
Action buttons area with top border. Right-aligned, and the buttons wrap onto another line when they do not fit, so none is pushed off the panel on a phone |
drawer.close |
Wraps any element to make it close the drawer. With dismiss it is a Cancel: the drawer closes as a dismissal and announces it, see below |
Auto Close Button
Like modal.header, drawer.header renders a close-X in the top-right corner by default, gated at runtime with x-show="dismissible" so non-dismissible drawers stay clean. Opt out with :close="false" when you want to control the layout yourself:
<x-wirekit::drawer.header :close="false">
<div class="flex items-center gap-2">
<x-wirekit::icon name="settings" size="md" />
Settings
</div>
</x-wirekit::drawer.header>
Personalize the button's classes via WireKit::.
Unlike Modal, Drawer has no drawer.trigger sub-component. Drawers are typically triggered from navbars or other locations far from the drawer itself. Use events or wire:model to open drawers programmatically.
Events
Opening
<!-- Alpine -->
<button x-on:click="$dispatch('wirekit-drawer-show', { name: 'settings' })">
<!-- Livewire (server-side) -->
$this->dispatch('wirekit-drawer-show', name: 'settings');
<!-- Vanilla JS -->
window.dispatchEvent(new CustomEvent('wirekit-drawer-show', { detail: { name: 'settings' } }));
Closing
<!-- Alpine -->
<button x-on:click="$dispatch('wirekit-drawer-close', { name: 'settings' })">
<!-- Livewire (server-side) -->
$this->dispatch('wirekit-drawer-close', name: 'settings');
When the reader dismisses it
A dismissible drawer closes on its own when the reader clicks beside it, presses Escape or uses
the close button in the header. It then sends wirekit:drawer-dismissed on the window, with the
drawer's name and the way it was dismissed (via: backdrop, escape or close-button), so a
page with state to clean up hears about a close it did not ask for:
{{-- 1. Clear the filters the reader has not applied whenever the reader dismisses this drawer --}}
<div x-on:wirekit:drawer-dismissed.window="$event.detail.name === 'filters' && $wire.resetFilters()">
<x-wirekit::drawer name="filters">…</x-wirekit::drawer>
</div>
A Cancel of your own is announced too when it sits in <x-wirekit::. It
closes the drawer as a dismissal, so the focus goes back to the control that opened it, asks
nothing about changed fields, because pressing Cancel is the answer, and sends wirekit:drawer-dismissed with
via: 'cancel':
{{-- 1. Cancel: a dismissal, announced with via 'cancel' --}}
<x-wirekit::drawer.close dismiss>
<x-wirekit::button intent="neutral" surface="outline">Cancel</x-wirekit::button>
</x-wirekit::drawer.close>
It is sent for those four and no other close. A drawer closed by wirekit-drawer-close, by
a wire:model set to false, by a drawer.close control of your own without dismiss or by a navigation
stays silent, so a page that closed its own drawer never runs its cleanup twice.
Accessibility
- Dialog:
role="dialog",aria-modal="true",aria-labelledby - Focus trap active — Tab cycles within the drawer only
- Focus returns to the previously focused element after close
- The page behind is
inertwhile the drawer is open, so neither a pointer nor a screen reader that ignoresaria-modalreaches it. A toast region stays reachable, and a drawer with:lock-scroll="false"leaves the page alone - The body is a keyboard-reachable scroll region (
tabindex="0"with a visible focus ring), so a drawer holding only text can still be scrolled from the keyboard — while a drawer that holds a control still opens on its first one, not on the body - Close button:
aria-label="Close" - Backdrop:
aria-hidden="true"(decorative)
Keyboard Interaction
| Key | Action |
|---|---|
Tab / Shift+Tab |
Cycle focus inside the drawer (focus is trapped) |
Escape |
Close the drawer (when dismissible), after asking when the reader changed a field in it. When a menu, a list or a tooltip inside the drawer is open, the first Escape closes it and leaves the drawer open, and the next one closes the drawer |
| Click overlay | Close the drawer (when dismissible) |
A control of your own inside the drawer that closes something on Escape keeps the drawer open by calling prevent on that key press, as the WireKit menus and lists do.
Pitfalls
- Don't leave the drawer without a
name. The trigger addresses it by name:wirekit-drawer-showis dispatched onwindowand every drawer on the page compares the event'snameagainst its own. A drawer with nonamematches nothing, so its trigger does nothing — and it is the trigger that looks broken. The trigger itself may sit anywhere in the document, inside the drawer's markup or far from it. - Don't bind
wire:clickon the trigger. The Alpine open handler dispatches the open event; a competingwire:clickwill run before the open and may navigate away. - Don't set
:dismissible="false"for non-modal flows. That is the single switch behind both Escape and the overlay click, so turning it off leaves a focus-trapped drawer with no keyboard exit — WCAG 2.1.2 (No Keyboard Trap). Keep the default unless the flow supplies its own explicit close control.
Design Tokens
| Token | Used for |
|---|---|
--font-wk-sans |
Drawer font family |
--font-wk-heading-weight |
Header font weight |
--text-wk-md / --text-wk-lg |
Body / heading font size |
--color-wk-text |
Body text |
--color-wk-text-muted |
Header subtitle text |
--color-wk-bg-elevated |
Drawer panel background |
--color-wk-bg-muted |
Footer background |
--color-wk-border / --color-wk-border-subtle |
Panel + footer divider border |
--color-wk-overlay |
Backdrop scrim color |
--color-wk-ring |
Close-button focus ring |
--ring-wk-width |
Focus ring width |
--border-wk-width |
Border width |
--radius-wk-sm |
Close-button border radius |
--shadow-wk-lg |
Panel drop shadow |
--size-wk-drawer-sm / --size-wk-drawer-md / --size-wk-drawer-lg |
Panel width per size prop |
--size-wk-drawer-inset |
The strip of backdrop left showing beside an open drawer. Every size above is wider than a phone, so the panel is clamped against the viewport; this is how much of the page stays visible, and it is what keeps the drawer reading as a sheet over the page rather than as a second page. Lower it for a dense console, raise it for more page |
--size-wk-sm |
Close-button hit target |
--gap-wk-md |
Footer-button gap |
--padding-wk-x-lg |
The header's, the body's and the footer's padding on all four sides, so the title and the content start at the same distance from the panel's edge |
--padding-wk-x-sm |
Pulls the close button to the header's edge |
--z-wk-drawer |
Stacking context |
Further Reading
- Overlay Events — the canonical event vocabulary for showing, closing and reacting to every overlay, with payload shapes and Livewire-side dispatch examples.
- WAI-ARIA Dialog Pattern — drawers are a dialog variant
- MDN:
role="dialog" - MDN:
aria-modal - focus-trap — focus management library (bundled)