Drawer
A slide-in panel that overlays page content from any edge of the screen. Built on the native <dialog> element for correct top-layer stacking, focus trapping, and Escape-to-close behavior.
Features
- 🔒 Native
<dialog>— top-layer stacking, built-in focus trap, browserEscapehandling - ↔️ 4 Placements: left, right (default), top, bottom
- 📐 4 Sizes: sm, md (default), lg, full
- 🔘 Dismissable — optional close (×) button in the header
- 🌫️ Backdrop Styles —
opaque(default),blur, ortransparent - 🧩 Flexible slots —
header, default body, andfooter - 🎞️ Smooth animations — slide-in/out transitions with backdrop fade
- ♿ Accessible:
role="dialog",aria-modal,aria-labelledbyfromlabelprop
Source Code
View Source Code
ts
import { computed, createId, defineComponent, fire, handle, html, onMount, ref, watch } from '@vielzeug/craftit';
import { closeIcon } from '../../icons';
import { coarsePointerMixin, elevationMixin, forcedColorsMixin, reducedMotionMixin } from '../../styles';
import { lockBackground, unlockBackground, useOverlay } from '../../utils';
import styles from './drawer.css?inline';
type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom';
type DrawerSize = 'sm' | 'lg' | 'full';
type DrawerBackdrop = 'opaque' | 'blur' | 'transparent';
/** Element interface exposing the imperative API for `bit-drawer`. */
export interface DrawerElement extends HTMLElement, Omit<BitDrawerProps, 'title'> {
/** Programmatically open the drawer. Equivalent to setting `open`. */
show(): void;
/** Programmatically close the drawer with the exit animation. */
hide(): void;
}
/** Drawer component properties */
export type BitDrawerEvents = {
close: { placement: DrawerPlacement };
open: { placement: DrawerPlacement };
};
export type BitDrawerProps = {
/** Backdrop style — 'opaque' (default), 'blur', or 'transparent' */
backdrop?: DrawerBackdrop;
/** Show the close (×) button in the header (default: true) */
dismissable?: boolean;
/**
* CSS selector for the element inside the drawer that should receive focus on open.
* Defaults to native dialog focus management (first focusable element).
* @example '#submit-btn' | 'input[name="email"]'
*/
'initial-focus'?: string;
/**
* Invisible accessible label for the dialog (`aria-label`).
* Use when the drawer has no visible title (e.g. image-only content).
* When omitted, `aria-labelledby` points to the visible header title instead.
*/
label?: string;
/** Controlled open state */
open?: boolean;
/** When true, backdrop clicks do not close the drawer (default: false) */
persistent?: boolean;
/** Side from which the drawer slides in */
placement?: DrawerPlacement;
/**
* When true (default), focus returns to the triggering element after the drawer closes.
* Set to false to manage focus manually.
*/
'return-focus'?: boolean;
/** Drawer width/height preset */
size?: DrawerSize;
/**
* Visible title text rendered inside the header.
* Used as the dialog's accessible label via `aria-labelledby` when `label` is not set.
*/
title?: string;
};
/**
* A panel that slides in from any edge of the screen, built on the native `<dialog>` element.
*
* @element bit-drawer
*
* @attr {boolean} open - Controlled open/close state
* @attr {string} placement - 'left' | 'right' (default) | 'top' | 'bottom'
* @attr {string} size - 'sm' | 'lg' | 'full'
* @attr {string} title - Visible header title text
* @attr {string} label - Invisible aria-label (for drawers without a visible title)
* @attr {boolean} dismissable - Show the close (×) button (default: true)
* @attr {string} backdrop - Backdrop style: 'opaque' (default) | 'blur' | 'transparent'
* @attr {boolean} persistent - Prevent backdrop-click from closing (default: false)
*
* @slot header - Drawer header content
* @slot - Main body content
* @slot footer - Drawer footer content
*
* @fires open - When the drawer opens
* @fires close - When the drawer closes
*
* @cssprop --drawer-backdrop - Backdrop background
* @cssprop --drawer-bg - Panel background color
* @cssprop --drawer-size - Panel width (horizontal) or height (vertical)
* @cssprop --drawer-shadow - Panel box-shadow
*
* @example
* ```html
* <!-- With visible title -->
* <bit-drawer open title="Settings" placement="right">
* <p>Settings content here.</p>
* </bit-drawer>
*
* <!-- With custom header slot -->
* <bit-drawer open placement="right">
* <span slot="header">Settings</span>
* <p>Settings content here.</p>
* </bit-drawer>
* ```
*/
export const DRAWER_TAG = defineComponent<BitDrawerProps, BitDrawerEvents>({
props: {
backdrop: { default: undefined },
dismissable: { default: true },
'initial-focus': { default: undefined },
label: { default: undefined },
open: { default: false },
persistent: { default: false },
placement: { default: 'right' },
'return-focus': { default: true },
size: { default: undefined },
title: { default: undefined },
},
setup({ emit, host, props, slots }) {
const drawerLabelId = createId('drawer-label');
const dialogRef = ref<HTMLDialogElement>();
const panelRef = ref<HTMLDivElement>();
// Header is visible when there is slot content, a title prop, or a close button.
const hasHeader = computed(() => slots.has('header').value || !!props.title.value || props.dismissable.value);
const hasFooter = computed(() => slots.has('footer').value);
const { applyInitialFocus, captureReturnFocus, restoreFocus } = useOverlay(
host,
dialogRef,
() => panelRef.value,
props,
);
const close = () => {
const dialog = dialogRef.value;
if (!dialog?.open) return;
dialog.close();
};
const requestClose = (trigger: 'backdrop' | 'button' | 'escape') => {
const allowed = fire.custom(host, 'close-request', {
cancelable: true,
detail: { placement: props.placement.value ?? 'right', trigger },
});
if (allowed) close();
};
const openDrawer = () => {
const dialog = dialogRef.value;
if (!dialog || dialog.open) return;
captureReturnFocus();
dialog.showModal();
lockBackground(host);
applyInitialFocus();
emit('open', { placement: props.placement.value ?? 'right' });
};
onMount(() => {
const dialog = dialogRef.value;
if (!dialog) return;
// Expose imperative API
const el = host as DrawerElement;
el.show = openDrawer;
el.hide = close;
const handleNativeClose = () => {
unlockBackground();
host.removeAttribute('open');
restoreFocus();
emit('close', { placement: props.placement.value ?? 'right' });
};
const handleCancel = (e: Event) => {
e.preventDefault();
if (!props.persistent.value) requestClose('escape');
};
const handleBackdropClick = (e: MouseEvent) => {
if (!props.persistent.value && e.target === dialog) requestClose('backdrop');
};
watch(
props.open,
(isOpen) => {
if (isOpen) openDrawer();
else close();
},
{ immediate: true },
);
handle(dialog, 'close', handleNativeClose);
handle(dialog, 'cancel', handleCancel);
handle(dialog, 'click', handleBackdropClick);
return () => {
if (dialog.open) {
unlockBackground();
dialog.close();
}
};
});
return html`
<dialog
ref=${dialogRef}
aria-modal="true"
:aria-label="${() => props.label.value ?? null}"
:aria-labelledby="${() => (!props.label.value ? drawerLabelId : null)}">
<div class="panel" part="panel" ref=${panelRef}>
<div class="header" part="header" ?hidden=${() => !hasHeader.value}>
<span class="header-title" id="${drawerLabelId}">
<slot name="header">${() => props.title.value ?? ''}</slot>
</span>
<button
class="close-btn"
part="close-btn"
type="button"
aria-label="Close"
?hidden=${() => !props.dismissable.value}
@click="${() => requestClose('button')}">
${closeIcon}
</button>
</div>
<div class="body" part="body">
<slot></slot>
</div>
<div class="footer" part="footer" ?hidden=${() => !hasFooter.value}>
<slot name="footer"></slot>
</div>
</div>
</dialog>
`;
},
styles: [elevationMixin, forcedColorsMixin, coarsePointerMixin, reducedMotionMixin, styles],
tag: 'bit-drawer',
});Basic Usage
Toggle the open attribute to show and hide the drawer.
html
<bit-button id="open-drawer-btn">Open drawer</bit-button>
<bit-drawer id="drawer" label="Settings" dismissable>
<p>Drawer body content goes here.</p>
<div slot="footer">
<bit-button variant="ghost" id="cancel-btn">Cancel</bit-button>
<bit-button color="primary" id="save-btn">Save changes</bit-button>
</div>
</bit-drawer>
<script type="module">
import '@vielzeug/buildit';
const drawer = document.getElementById('drawer');
document.getElementById('open-drawer-btn').addEventListener('click', () => {
drawer.setAttribute('open', '');
});
document.getElementById('cancel-btn').addEventListener('click', () => {
drawer.removeAttribute('open');
});
</script>Placements
Sizes
With Header and Footer Slots
Use the header slot to replace the default title bar and the footer slot for action buttons.
Backdrop Styles (backdrop)
Use backdrop to match dialog behavior:
opaque(default): dim overlayblur: dim + blurtransparent: no overlay
Listening to Events
html
<bit-drawer id="my-drawer" label="Notifications" dismissable>
<p>You have no new notifications.</p>
</bit-drawer>
<script type="module">
import '@vielzeug/buildit';
const drawer = document.getElementById('my-drawer');
drawer.addEventListener('open', () => console.log('drawer opened'));
drawer.addEventListener('close', () => console.log('drawer closed'));
drawer.addEventListener('close-request', (e) => {
if (e.detail.trigger === 'backdrop') {
console.log('close requested from backdrop click');
}
});
</script>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Controls visibility |
placement | 'left' | 'right' | 'top' | 'bottom' | 'right' | Edge the drawer slides in from |
size | 'sm' | 'md' | 'lg' | 'full' | 'md' | Panel width (or height for top/bottom placements) |
label | string | — | Accessible title shown in the header bar |
dismissable | boolean | true | Shows a close (×) button in the header |
backdrop | 'opaque' | 'blur' | 'transparent' | 'opaque' | Backdrop style, matching bit-dialog |
persistent | boolean | false | Prevents backdrop click from requesting close |
Slots
| Slot | Description |
|---|---|
| (default) | Drawer body content |
header | Replace the default header bar (overrides the label title) |
footer | Footer area, typically used for action buttons |
Events
| Event | Detail | Description |
|---|---|---|
open | void | Fired when the drawer opens |
close | void | Fired when the drawer closes |
close-request | { trigger: 'backdrop' | 'button' | 'escape', placement: 'left' | 'right' | 'top' | 'bottom' } | Fired before close and can be prevented |
CSS Custom Properties
| Property | Description |
|---|---|
--drawer-backdrop | Backdrop color (default: rgba(0,0,0,.5)) |
--drawer-bg | Panel background color |
--drawer-size | Override the panel width or height |
--drawer-shadow | Panel box shadow |
Accessibility
The drawer component follows the WAI-ARIA Dialog Pattern best practices.
bit-drawer
✅ Keyboard Navigation
Tab/Shift+Tabmove focus between focusable elements inside the panel.Escapecloses the drawer (whendismissableis set).
✅ Screen Readers
- The panel uses
role="dialog"witharia-modal="true"to signal that content outside is inert. - Provide a
labelattribute to give screen readers a descriptive panel title. - The close button has
aria-label="Close drawer"whendismissableis set.
✅ Focus Management
- Focus moves into the panel on open and returns to the trigger element on close.
Related Components
- Dialog — modal dialog for confirmations and focused tasks