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, lg, full
- 🔘 Dismissible — 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 type { OverlayCloseDetail, OverlayCloseReason, OverlayOpenDetail, SwipeAxis } from '@vielzeug/craftit/controls';
import { createId, define, on, html, prop, ref, signal, watch, onMounted } from '@vielzeug/craftit';
import { createOverlayControl, createSwipeControl } from '@vielzeug/craftit/controls';
import '../../content/icon/icon';
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';
type DrawerDragHandlePlacement = 'outside' | 'inset';
type DrawerSwipeConfig = {
axis: SwipeAxis;
closingDistance: (distance: number) => number;
translate: (distance: number) => string;
};
const drawerSwipeConfig: Record<DrawerPlacement, DrawerSwipeConfig> = {
bottom: {
axis: 'y',
closingDistance: (distance) => Math.max(0, distance),
translate: (distance) => `translateY(${distance}px)`,
},
left: {
axis: 'x',
closingDistance: (distance) => Math.max(0, -distance),
translate: (distance) => `translateX(${distance}px)`,
},
right: {
axis: 'x',
closingDistance: (distance) => Math.max(0, distance),
translate: (distance) => `translateX(${distance}px)`,
},
top: {
axis: 'y',
closingDistance: (distance) => Math.max(0, -distance),
translate: (distance) => `translateY(${distance}px)`,
},
};
/** 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: OverlayCloseDetail & { placement: DrawerPlacement };
open: OverlayOpenDetail & { placement: DrawerPlacement };
};
export type BitDrawerProps = {
/** Backdrop style — 'opaque' (default), 'blur', or 'transparent' */
backdrop?: DrawerBackdrop;
/** Show the close (×) button in the header (default: true) */
dismissible?: boolean;
/** Drag handle position used for swipe-to-close gestures */
'drag-handle-placement'?: DrawerDragHandlePlacement;
/**
* 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} dismissible - Show the close (×) button (default: true)
* @attr {string} drag-handle-placement - 'outside' (default) | 'inset'
* @attr {string} backdrop - Backdrop style: 'opaque' (default) | 'blur' | 'transparent'
* @attr {boolean} persistent - Prevent backdrop-click from closing (default: false)
*
* @fires open - When the drawer opens with detail: { placement, reason }
* @fires close - When the drawer closes with detail: { placement, reason }
*
* @slot header - Drawer header content
* @slot - Main body content
* @slot footer - Drawer footer content
*
* @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
*
* @part drag-handle - Drawer drag handle.
* @part panel - Panel container.
* @part header - Header container.
* @part close-btn - Close button.
* @part body - Body content container.
* @part footer - Footer container.
* @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 = define<BitDrawerProps, BitDrawerEvents>('bit-drawer', {
props: {
backdrop: undefined,
dismissible: true,
'drag-handle-placement': prop.oneOf(['outside', 'inset'] as const, 'outside'),
'initial-focus': undefined,
label: undefined,
open: false,
persistent: false,
placement: prop.oneOf(['left', 'right', 'top', 'bottom'] as const, 'right'),
'return-focus': true,
size: undefined,
title: undefined,
},
setup(props, { emit, host, slots }) {
const drawerLabelId = createId('drawer-label');
const dialogRef = ref<HTMLDialogElement>();
const panelRef = ref<HTMLDivElement>();
const isOpen = signal(false);
let closeReason: OverlayCloseReason = 'programmatic';
// Drag-to-close state
let isSwipeClosing = false;
let swipeCloseTimer: ReturnType<typeof setTimeout> | undefined;
const getHeaderText = () => props.label.value ?? props.title.value ?? '';
const hasHeaderTitle = () => slots.has('header').value || !!getHeaderText();
// Header is visible when there is slot content, a text label, or a close button.
const hasHeader = () => hasHeaderTitle() || props.dismissible.value;
const hasFooter = () => slots.has('footer').value;
const getPlacement = (): DrawerPlacement => props.placement.value || 'right';
const getSwipeConfig = (): DrawerSwipeConfig => drawerSwipeConfig[getPlacement()];
const getSnapThreshold = (panel: HTMLElement, axis: SwipeAxis) => {
const panelSize = axis === 'x' ? panel.offsetWidth : panel.offsetHeight;
return Math.min(96, Math.max(36, panelSize * 0.18));
};
const shouldCommitSwipeClose = (distance: number, threshold: number) => {
return getSwipeConfig().closingDistance(distance) >= threshold;
};
const finalizeSwipeClose = (panel: HTMLElement) => {
if (!isSwipeClosing) return;
if (swipeCloseTimer) {
clearTimeout(swipeCloseTimer);
swipeCloseTimer = undefined;
}
const dialog = dialogRef.value;
if (!dialog) {
isSwipeClosing = false;
return;
}
dialog.style.transition = 'none';
panel.style.opacity = '0';
panel.style.visibility = 'hidden';
void dialog.offsetWidth;
requestClose('swipe');
};
const startSwipeClose = (panel: HTMLElement, swipe: DrawerSwipeConfig, committedDistance: number) => {
if (isSwipeClosing) return;
isSwipeClosing = true;
const panelSize = swipe.axis === 'x' ? panel.offsetWidth : panel.offsetHeight;
// Preserve overshoot so closing continues from the dragged position instead of snapping back.
const exitDistance =
committedDistance >= 0 ? Math.max(committedDistance, panelSize) : Math.min(committedDistance, -panelSize);
const exitTransform = swipe.translate(exitDistance);
// Commit the current drag position first so the magnetic snap continues
// from the finger instead of jumping back to rest.
panel.style.transition = 'transform 180ms cubic-bezier(0.2, 0.9, 0.2, 1), opacity 180ms ease-out';
void panel.offsetWidth;
const onExitTransitionEnd = (ev: TransitionEvent) => {
if (ev.target !== panel || ev.propertyName !== 'transform') return;
panel.removeEventListener('transitionend', onExitTransitionEnd);
finalizeSwipeClose(panel);
};
panel.addEventListener('transitionend', onExitTransitionEnd);
const durationMs = parseFloat(getComputedStyle(panel).transitionDuration) * 1000;
swipeCloseTimer = setTimeout(() => {
panel.removeEventListener('transitionend', onExitTransitionEnd);
finalizeSwipeClose(panel);
}, durationMs + 50);
panel.style.transform = exitTransform;
panel.style.opacity = '0.2';
};
const resetPanelDragStyles = (panel: HTMLElement) => {
if (swipeCloseTimer) {
clearTimeout(swipeCloseTimer);
swipeCloseTimer = undefined;
}
// Re-enable transitions so the snap-back or exit animates.
panel.style.transition = '';
panel.style.transform = '';
panel.style.opacity = '';
panel.style.visibility = '';
isSwipeClosing = false;
};
const swipe = createSwipeControl({
axis: () => getSwipeConfig().axis,
disabled: () => isSwipeClosing,
onCancel: ({ distance, threshold }) => {
const panel = panelRef.value;
if (!panel) return;
// If the release event crosses the threshold (without an intervening
// move event), commit close instead of snapping back to rest first.
if (shouldCommitSwipeClose(distance, threshold)) {
startSwipeClose(panel, getSwipeConfig(), distance);
return;
}
resetPanelDragStyles(panel);
},
onCommit: ({ distance }) => {
const panel = panelRef.value;
if (!panel) return;
startSwipeClose(panel, getSwipeConfig(), distance);
},
onMove: ({ distance, threshold }) => {
const panel = panelRef.value;
if (!panel) return;
const swipeConfig = getSwipeConfig();
const closingDistance = swipeConfig.closingDistance(distance);
const progress = Math.min(closingDistance / threshold, 1);
// Kill CSS transitions so the panel tracks the finger instantly.
panel.style.transition = 'none';
panel.style.transform = swipeConfig.translate(distance);
panel.style.opacity = String(1 - progress * 0.4);
},
shouldCommit: ({ distance, threshold }) => shouldCommitSwipeClose(distance, threshold),
threshold: () => {
const panel = panelRef.value;
if (!panel) return 48;
return getSnapThreshold(panel, getSwipeConfig().axis);
},
});
// ────────────────────────────────────────────────────────────────
// Overlay State Management
// ────────────────────────────────────────────────────────────────
const { applyInitialFocus, captureReturnFocus, restoreFocus } = useOverlay(
host.el,
dialogRef,
() => panelRef.value,
props,
);
const dispatchCloseRequest = (reason: Exclude<OverlayCloseReason, 'programmatic'>): boolean => {
return host.el.dispatchEvent(
new CustomEvent('close-request', {
bubbles: true,
cancelable: true,
composed: true,
detail: { placement: props.placement.value ?? 'right', reason },
}),
);
};
const requestClose = (reason: Exclude<OverlayCloseReason, 'programmatic'>) => {
const dialog = dialogRef.value;
if (!dialog) return;
const closeAllowed = dispatchCloseRequest(reason);
if (!closeAllowed) return;
closeReason = reason;
overlay.close({ reason, restoreFocus: false });
};
const handleCloseButtonClick = () => {
const dialog = dialogRef.value;
if (!dialog) return;
requestClose('trigger');
};
const overlay = createOverlayControl({
getBoundaryElement: () => host.el,
getPanelElement: () => panelRef.value,
isOpen: () => isOpen.value,
onOpen: (reason) => emit('open', { placement: props.placement.value ?? 'right', reason }),
setOpen: (next, { reason }) => {
const dialog = dialogRef.value;
if (!dialog) return;
if (next) {
if (dialog.open) return;
captureReturnFocus();
// Clear any inline drag styles from a previous swipe-close so the CSS
// entry animation starts from the correct base state.
const panel = panelRef.value;
if (panel) resetPanelDragStyles(panel);
dialog.showModal();
lockBackground(host.el);
applyInitialFocus();
isOpen.value = true;
return;
}
if (dialog.open) {
closeReason = reason as OverlayCloseReason;
dialog.close();
return;
}
isOpen.value = false;
},
});
// ────────────────────────────────────────────────────────────────
// Lifecycle: Setup Drawer Integration
// ────────────────────────────────────────────────────────────────
onMounted(() => {
const dialog = dialogRef.value;
if (!dialog) return;
// Expose imperative API
const el = host.el as DrawerElement;
el.show = () => {
overlay.open({ reason: 'programmatic' });
};
el.hide = () => {
overlay.close({ reason: 'programmatic', restoreFocus: false });
};
// ────────────────────────────────────────────────────────────
// Event Handlers: Native Close, Escape, Backdrop Click
// ────────────────────────────────────────────────────────────
const handleNativeClose = () => {
const panelEl = panelRef.value;
// For swipe-close, keep the panel hidden and off-screen until the next
// open cycle. Resetting inline styles during native close can still
// produce a visible frame at the rest position on some browsers.
if (panelEl && closeReason !== 'swipe') resetPanelDragStyles(panelEl);
// Restore any inline dialog transition override set during swipe-close.
dialog.style.transition = '';
unlockBackground();
host.el.removeAttribute('open');
isOpen.value = false;
restoreFocus();
emit('close', { placement: props.placement.value ?? 'right', reason: closeReason });
closeReason = 'programmatic';
};
const handleCancel = (e: Event) => {
e.preventDefault();
if (props.persistent.value) return;
requestClose('escape');
};
const handleBackdropClick = (e: MouseEvent) => {
if (props.persistent.value) return;
if (swipe.isActive() || isSwipeClosing) return;
if (e.target !== dialog) return; // Click inside panel
requestClose('outside-click');
};
// Sync open prop → native dialog
watch(
props.open,
(isOpen) => {
if (isOpen) {
overlay.open({ reason: 'programmatic' });
return;
}
overlay.close({ reason: 'programmatic', restoreFocus: false });
},
{ immediate: true },
);
on(dialog, 'close', handleNativeClose);
on(dialog, 'cancel', handleCancel);
on(dialog, 'click', handleBackdropClick);
// Drag-to-close handlers — scoped to the handle element only so interactions
// with panel content don't accidentally start a drag.
const panel = panelRef.value;
const dragHandleEl = panel?.querySelector<HTMLElement>('[part="drag-handle"]');
if (dragHandleEl) {
on(dragHandleEl, 'pointerdown', swipe.handlePointerDown);
on(dragHandleEl, 'pointermove', swipe.handlePointerMove);
on(dragHandleEl, 'pointerup', swipe.handlePointerUp);
on(dragHandleEl, 'pointercancel', swipe.handlePointerCancel);
}
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="drag-handle" part="drag-handle" aria-label="Drag to close" role="button"></div>
<div class="header" part="header" ?hidden=${() => !hasHeader()}>
<span class="header-title" id="${drawerLabelId}" ?hidden=${() => !hasHeaderTitle()}>
<slot name="header">${() => getHeaderText()}</slot>
</span>
<button
class="close-btn"
part="close-btn"
type="button"
aria-label="Close"
?hidden=${() => !props.dismissible.value}
@click=${handleCloseButtonClick}>
<bit-icon name="x" size="16" stroke-width="2.5" aria-hidden="true"></bit-icon>
</button>
</div>
<div class="body" part="body">
<slot></slot>
</div>
<div class="footer" part="footer" ?hidden=${() => !hasFooter()}>
<slot name="footer"></slot>
</div>
</div>
</dialog>
`;
},
styles: [elevationMixin, forcedColorsMixin, coarsePointerMixin, reducedMotionMixin, styles],
});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" dismissible>
<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.
Drag Handle Placement
Use drag-handle-placement to control whether the swipe handle sits outside the panel edge or inset inside it.
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" dismissible>
<p>You have no new notifications.</p>
</bit-drawer>
<script type="module">
import '@vielzeug/buildit';
const drawer = document.getElementById('my-drawer');
drawer.addEventListener('open', (e) => console.log('drawer opened because:', e.detail.reason));
drawer.addEventListener('close', (e) => console.log('drawer closed because:', e.detail.reason));
drawer.addEventListener('close-request', (e) => {
if (e.detail.reason === 'outside-click') {
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 |
drag-handle-placement | 'outside' | 'inset' | 'outside' | Position of the swipe drag handle |
dismissible | 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 | { placement: 'left' | 'right' | 'top' | 'bottom', reason: 'programmatic' } | Fired when the drawer opens |
close | { placement: 'left' | 'right' | 'top' | 'bottom', reason: 'programmatic' | 'trigger' | 'escape' | 'outside-click' } | Fired when the drawer closes |
close-request | { placement: 'left' | 'right' | 'top' | 'bottom', reason: 'trigger' | 'escape' | 'outside-click' } | 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 (whendismissibleis 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"whendismissibleis 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