Skip to content

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, browser Escape handling
  • ↔️ 4 Placements: left, right (default), top, bottom
  • 📐 4 Sizes: sm, md (default), lg, full
  • 🔘 Dismissable — optional close (×) button in the header
  • 🌫️ Backdrop Stylesopaque (default), blur, or transparent
  • 🧩 Flexible slotsheader, default body, and footer
  • 🎞️ Smooth animations — slide-in/out transitions with backdrop fade
  • Accessible: role="dialog", aria-modal, aria-labelledby from label prop

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

PreviewCode
RTL

Sizes

PreviewCode
RTL

Use the header slot to replace the default title bar and the footer slot for action buttons.

PreviewCode
RTL

Backdrop Styles (backdrop)

Use backdrop to match dialog behavior:

  • opaque (default): dim overlay
  • blur: dim + blur
  • transparent: no overlay
PreviewCode
RTL

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

AttributeTypeDefaultDescription
openbooleanfalseControls 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)
labelstringAccessible title shown in the header bar
dismissablebooleantrueShows a close (×) button in the header
backdrop'opaque' | 'blur' | 'transparent''opaque'Backdrop style, matching bit-dialog
persistentbooleanfalsePrevents backdrop click from requesting close

Slots

SlotDescription
(default)Drawer body content
headerReplace the default header bar (overrides the label title)
footerFooter area, typically used for action buttons

Events

EventDetailDescription
openvoidFired when the drawer opens
closevoidFired 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

PropertyDescription
--drawer-backdropBackdrop color (default: rgba(0,0,0,.5))
--drawer-bgPanel background color
--drawer-sizeOverride the panel width or height
--drawer-shadowPanel box shadow

Accessibility

The drawer component follows the WAI-ARIA Dialog Pattern best practices.

bit-drawer

Keyboard Navigation

  • Tab / Shift+Tab move focus between focusable elements inside the panel.
  • Escape closes the drawer (when dismissable is set).

Screen Readers

  • The panel uses role="dialog" with aria-modal="true" to signal that content outside is inert.
  • Provide a label attribute to give screen readers a descriptive panel title.
  • The close button has aria-label="Close drawer" when dismissable is set.

Focus Management

  • Focus moves into the panel on open and returns to the trigger element on close.
  • Dialog — modal dialog for confirmations and focused tasks