Modals, Toasts & Overlays

Lesson 8

Overlays are where most AI-generated UIs fail under real-world usage. A modal that doesn’t trap focus will let screen reader users wander behind it. A toast that fires twice from a double-click is annoying. A drawer that doesn’t close on ESC frustrates keyboard users. This lesson shows you the prompts that prevent all of that.

The minimum viable prompt for an accessible modal has five requirements: focus trap, ESC close, backdrop click close, aria-modal="true", and aria-labelledby pointing to the heading.

Build a modal dialog triggered by a button.
- Backdrop: rgba(0,0,0,0.4), clicking it closes the modal
- Content card: white, border-radius 12px, max-width 440px, centered
- Buttons: Cancel (ghost) and Confirm (primary)
- Accessibility: aria-modal="true", aria-labelledby pointing to the h2
- Close on ESC key
- Focus trap: Tab/Shift+Tab cycles only within the modal while open
- Restore focus to the trigger button on close
- Animate in: scale(0.96) → scale(1) + opacity 0 → 1, 220ms ease

The focus trap pattern is the trickiest part. The standard approach is to query all focusable elements inside the modal, then intercept Tab keydown to redirect from the last element back to the first (and Shift+Tab from first to last).

Toast notification system

Toasts need a stacking container, four visual types, auto-dismiss, and manual dismiss. The most important prompt detail is the slide-in direction and the stacking behavior.

Build a toast notification system:
- Fixed container: top-right, z-index 300, column flex with gap
- 4 types: success (green), error (red), warning (amber), info (blue)
- Each toast: white bg, 1px border, left 3px solid matching color, icon + title + message
- Slide in from right (translateX(100%) → translateX(0)), opacity 0 → 1
- Auto-dismiss after 3 seconds, manual dismiss button (×)
- Toasts stack — newer ones appear above older ones
- aria-live="polite" on the container for screen reader announcements

Key pattern — busy flag: If you have a button that triggers a toast on click, make sure rapid clicks don’t create a flood. A simple let isSending = false flag with a setTimeout reset is enough. In the demo the buttons create a new toast each click, which is intentional for testing — but in production you’d gate this.

Drawer / side panel

A drawer is essentially a modal that lives at the edge of the screen. Same accessibility requirements — focus trap, ESC, aria-modal — but the animation is horizontal instead of scale.

Build a slide-in drawer from the right side.
- Width: 320px (full width on mobile)
- Overlay backdrop: rgba(0,0,0,0.4), closes drawer on click
- Drawer: white bg, box-shadow on the left edge, transform translateX(100%) → translateX(0)
- Header: title on left, × close button on right
- Body: a settings form (text input, email input, two toggle switches)
- Close on: × button, ESC key, backdrop click
- Focus trap while open, restore focus on close
- Transition: 300ms ease

What the demo shows

The live demo has three sections. Click “Open Modal” to see the dialog with focus trap — try tabbing through it and pressing ESC. Click the four toast buttons to create stacking notifications and watch them slide in and auto-dismiss. Click “Open Drawer” to see the settings panel slide in from the right with its form and toggle switches. All three patterns are fully keyboard accessible.

Source Code script.js
/* Modals, Toasts & Overlays — script.js
   Implements modal dialog (focus trap + ESC), toast stack, and drawer.
*/

/* ── Utilities ── */

/** Get all focusable elements within a container */
function getFocusable(container) {
  return Array.from(
    container.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
    )
  );
}

/* ── Modal ── */
const modalBackdrop = document.getElementById('modal-backdrop');
const modal = document.getElementById('modal');
const openModalBtn = document.getElementById('open-modal-btn');
const modalCancel = document.getElementById('modal-cancel');
const modalConfirm = document.getElementById('modal-confirm');

let modalPrevFocus = null;
let modalOpen = false;

function openModal() {
  if (modalOpen) return;
  modalOpen = true;
  modalPrevFocus = document.activeElement;

  modalBackdrop.classList.add('is-open');
  modalBackdrop.setAttribute('aria-hidden', 'false');

  // Focus the dialog after transition starts
  requestAnimationFrame(() => {
    modal.focus();
  });

  document.addEventListener('keydown', handleModalKeydown);
}

function closeModal() {
  if (!modalOpen) return;
  modalOpen = false;

  modalBackdrop.classList.remove('is-open');
  modalBackdrop.setAttribute('aria-hidden', 'true');

  document.removeEventListener('keydown', handleModalKeydown);
  modalPrevFocus?.focus();
}

function handleModalKeydown(e) {
  if (e.key === 'Escape') {
    closeModal();
    return;
  }

  // Focus trap
  if (e.key === 'Tab') {
    const focusable = getFocusable(modal);
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  }
}

openModalBtn.addEventListener('click', openModal);
modalCancel.addEventListener('click', closeModal);
modalConfirm.addEventListener('click', () => {
  closeModal();
  showToast('success', 'Action confirmed', 'Your changes have been published.');
});

// Close on backdrop click (not modal itself)
modalBackdrop.addEventListener('click', e => {
  if (e.target === modalBackdrop) closeModal();
});

/* ── Toasts ── */
const toastContainer = document.getElementById('toast-container');
const TOAST_DURATION = 3000;

const TOAST_CONFIG = {
  success: { icon: '✓', title: 'Success', msg: 'Operation completed successfully.' },
  error:   { icon: '✕', title: 'Error',   msg: 'Something went wrong. Please try again.' },
  warning: { icon: '⚠', title: 'Warning', msg: 'Proceed with caution.' },
  info:    { icon: 'ℹ', title: 'Info',    msg: 'Here is some useful information.' },
};

function showToast(type, title, msg) {
  const cfg = TOAST_CONFIG[type];
  const toast = document.createElement('div');
  toast.className = `toast toast-${type}`;
  toast.setAttribute('role', 'status');
  toast.innerHTML = `
    <span class="toast-icon">${cfg.icon}</span>
    <div class="toast-content">
      <div class="toast-title">${title || cfg.title}</div>
      <div class="toast-msg">${msg || cfg.msg}</div>
    </div>
    <button class="toast-dismiss" aria-label="Dismiss notification">×</button>
  `;

  toastContainer.appendChild(toast);

  // Animate in
  requestAnimationFrame(() => {
    requestAnimationFrame(() => toast.classList.add('is-visible'));
  });

  // Auto-dismiss
  let dismissTimer = setTimeout(() => removeToast(toast), TOAST_DURATION);

  toast.querySelector('.toast-dismiss').addEventListener('click', () => {
    clearTimeout(dismissTimer);
    removeToast(toast);
  });
}

function removeToast(toast) {
  toast.classList.remove('is-visible');
  toast.classList.add('is-leaving');
  toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}

document.querySelectorAll('.toast-btn').forEach(btn => {
  btn.addEventListener('click', () => showToast(btn.dataset.type));
});

/* ── Drawer ── */
const drawerOverlay = document.getElementById('drawer-overlay');
const drawer = document.getElementById('drawer');
const openDrawerBtn = document.getElementById('open-drawer-btn');
const drawerCloseBtn = document.getElementById('drawer-close');

let drawerPrevFocus = null;
let drawerOpen = false;

function openDrawer() {
  if (drawerOpen) return;
  drawerOpen = true;
  drawerPrevFocus = document.activeElement;

  drawerOverlay.classList.add('is-open');
  drawerOverlay.setAttribute('aria-hidden', 'false');
  drawer.classList.add('is-open');

  requestAnimationFrame(() => {
    drawer.focus();
  });

  document.addEventListener('keydown', handleDrawerKeydown);
}

function closeDrawer() {
  if (!drawerOpen) return;
  drawerOpen = false;

  drawerOverlay.classList.remove('is-open');
  drawerOverlay.setAttribute('aria-hidden', 'true');
  drawer.classList.remove('is-open');

  document.removeEventListener('keydown', handleDrawerKeydown);
  drawerPrevFocus?.focus();
}

function handleDrawerKeydown(e) {
  if (e.key === 'Escape') {
    closeDrawer();
    return;
  }

  // Focus trap
  if (e.key === 'Tab') {
    const focusable = getFocusable(drawer);
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  }
}

openDrawerBtn.addEventListener('click', openDrawer);
drawerCloseBtn.addEventListener('click', closeDrawer);
drawerOverlay.addEventListener('click', closeDrawer);