Skip to main content
Copy for LLM

Drawer

The <x-wirekit::drawer> 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

Drawer Panel

Discard your changes?

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:model syncs drawer open/close state with a Livewire property
  • wire:ignore.self prevents Livewire DOM morphing issues
  • 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' => false under components.drawer in config/wirekit.php for all of them, discard-question replaces 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::personalize('drawer.header', ['close' => 'your-classes']).

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::drawer.close dismiss>. 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 inert while the drawer is open, so neither a pointer nor a screen reader that ignores aria-modal reaches 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 preventDefault() 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-show is dispatched on window and every drawer on the page compares the event's name against its own. A drawer with no name matches 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:click on the trigger. The Alpine open handler dispatches the open event; a competing wire:click will 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

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store