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, lg, full
  • 🔘 Dismissible — 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 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

PreviewCode
RTL

Sizes

PreviewCode
RTL

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

PreviewCode
RTL

Drag Handle Placement

Use drag-handle-placement to control whether the swipe handle sits outside the panel edge or inset inside it.

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" 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

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
drag-handle-placement'outside' | 'inset''outside'Position of the swipe drag handle
dismissiblebooleantrueShows 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
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

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 dismissible 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" when dismissible 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