Popup модальное окно

Зачем нужно

Модальное окно (modal) -- один из самых частых UI-паттернов. Оно блокирует взаимодействие с основным контентом и фокусирует внимание пользователя на конкретной задаче: подтверждение действия, форма, уведомление. Правильная реализация включает управление фокусом, доступность и блокировку скролла.

Где используется

  • Подтверждение удаления ("Вы уверены?")
  • Формы авторизации / регистрации
  • Просмотр изображений (lightbox)
  • Уведомления и предупреждения
  • Мастеры (wizard) по шагам

Подход 1: Нативный <dialog> (рекомендуемый)

HTML-элемент <dialog> -- встроенное решение браузера с поддержкой ::backdrop, фокус-трапа и клавиши Escape из коробки.

HTML-разметка

<button id="openBtn" type="button">Открыть модальное окно</button>

<dialog id="modal" class="modal">
  <form method="dialog">
    <h2 class="modal__title">Подтверждение</h2>
    <p class="modal__body">Вы уверены, что хотите удалить этот элемент?</p>
    <div class="modal__actions">
      <button value="cancel" class="btn btn--secondary">Отмена</button>
      <button value="confirm" class="btn btn--primary">Удалить</button>
    </div>
  </form>
</dialog>

CSS-стили

.modal {
  border: none;
  border-radius: 12px;
  padding: 32px;
  max-width: 480px;
  width: 90%;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}

/* Оверлей через ::backdrop */
.modal::backdrop {
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
}

/* Анимация появления */
.modal[open] {
  animation: fadeIn 0.2s ease-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.modal__title {
  margin: 0 0 16px;
  font-size: 1.25rem;
}

.modal__body {
  margin: 0 0 24px;
  color: #555;
}

.modal__actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

JavaScript

const modal = document.getElementById('modal');
const openBtn = document.getElementById('openBtn');

// Открытие модального окна (showModal блокирует фон)
openBtn.addEventListener('click', () => {
  modal.showModal;
});

// Обработка результата
modal.addEventListener('close', () => {
  if (modal.returnValue === 'confirm') {
    console.log('Пользователь подтвердил действие');
  } else {
    console.log('Пользователь отменил');
  }
});

// Закрытие по клику на backdrop
modal.addEventListener('click', (e) => {
  if (e.target === modal) {
    modal.close('cancel');
  }
});

Важно: showModal автоматически создаёт backdrop, блокирует Tab-навигацию за пределами диалога и закрывается по Escape.


Подход 2: Кастомное модальное окно

Когда нужна полная кастомизация или поддержка старых браузеров.

HTML-разметка

<div id="customModal" class="custom-modal" role="dialog" aria-modal="true"
     aria-labelledby="modalTitle" hidden>
  <div class="custom-modal__overlay"></div>
  <div class="custom-modal__content">
    <button class="custom-modal__close" aria-label="Закрыть">&times;</button>
    <h2 id="modalTitle">Заголовок</h2>
    <p>Контент модального окна.</p>
    <button class="btn btn--primary" id="confirmBtn">OK</button>
  </div>
</div>

CSS

.custom-modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
}

.custom-modal[hidden] {
  display: none;
}

.custom-modal__overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
}

.custom-modal__content {
  position: relative;
  background: #fff;
  border-radius: 12px;
  padding: 32px;
  max-width: 480px;
  width: 90%;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  animation: fadeIn 0.2s ease-out;
}

.custom-modal__close {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  line-height: 1;
  color: #999;
}

Focus Trap (ловушка фокуса)

class Modal {
  constructor(element) {
    this.modal = element;
    this.overlay = element.querySelector('.custom-modal__overlay');
    this.closeBtn = element.querySelector('.custom-modal__close');
    this.previouslyFocused = null;

    this.closeBtn.addEventListener('click', () => this.close());
    this.overlay.addEventListener('click', () => this.close());
    document.addEventListener('keydown', (e) => this.handleKeydown(e));
  }

  open {
    this.previouslyFocused = document.activeElement;
    this.modal.hidden = false;
    document.body.style.overflow = 'hidden'; // Блокировка скролла

    // Установить фокус на первый интерактивный элемент
    const focusable = this.getFocusableElements;
    if (focusable.length) focusable[0].focus();
  }

  close {
    this.modal.hidden = true;
    document.body.style.overflow = '';

    // Вернуть фокус на элемент, который вызвал модальное окно
    if (this.previouslyFocused) {
      this.previouslyFocused.focus();
    }
  }

  handleKeydown(e) {
    if (this.modal.hidden) return;

    // Закрытие по Escape
    if (e.key === 'Escape') {
      this.close();
      return;
    }

    // Focus trap: Tab циклически внутри модального окна
    if (e.key === 'Tab') {
      const focusable = this.getFocusableElements;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

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

  getFocusableElements {
    const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    return [...this.modal.querySelectorAll(selector)].filter(
      (el) => !el.disabled && !el.hidden
    );
  }
}

// Использование
const modal = new Modal(document.getElementById('customModal'));
document.getElementById('openBtn').addEventListener('click', () => modal.open());

Блокировка скролла (scrollbar lock)

При открытии модального окна страница не должна прокручиваться. Но простое overflow: hidden вызывает сдвиг контента из-за исчезновения полосы прокрутки.

function lockScroll() {
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
  document.body.style.overflow = 'hidden';
  document.body.style.paddingRight = `${scrollbarWidth}px`;
}

function unlockScroll() {
  document.body.style.overflow = '';
  document.body.style.paddingRight = '';
}

Accessibility-чеклист

Атрибут Назначение
role="dialog" Сообщает скринридеру, что это диалог
aria-modal="true" Контент за пределами диалога недоступен
aria-labelledby Связывает с заголовком диалога
aria-describedby Связывает с описанием (опционально)
Escape Закрывает модальное окно
Focus trap Tab не выходит за пределы диалога
Возврат фокуса После закрытия фокус возвращается на триггер

Частые ошибки

Ошибка Проблема Решение
Нет focus trap Tab уходит за модальное окно Реализуй ловушку фокуса
show вместо showModal Нет backdrop и Escape Используй showModal
Скролл страницы при открытом модале Пользователь теряется overflow: hidden на body
Нет aria-modal Скринридер читает фон Добавь aria-modal="true"
Сдвиг контента при блокировке скролла Полоса прокрутки исчезает Компенсируй paddingRight

Связанные темы

Ресурсы