Реализация всплывающих окон (Modal/Popup) на сайте
Модальные окна — один из компонентов с наибольшим количеством скрытых проблем: управление фокусом, скролл под модалкой, z-index конфликты, анимации, мобильный клавиатурный сдвиг. Нативный <dialog> решает большую часть без JS.
Нативный <dialog>: правильная основа
<dialog id="confirm-modal" class="modal">
<div class="modal__content">
<button class="modal__close" autofocus aria-label="Закрыть">✕</button>
<h2 class="modal__title">Подтверждение действия</h2>
<p>Вы уверены, что хотите удалить запись?</p>
<div class="modal__actions">
<button class="btn btn--danger" id="confirm-btn">Удалить</button>
<button class="btn" id="cancel-btn">Отмена</button>
</div>
</div>
</dialog>
const modal = document.getElementById('confirm-modal') as HTMLDialogElement
// showModal() vs show(): showModal блокирует фон через ::backdrop
function openModal() {
modal.showModal()
document.body.style.overflow = 'hidden' // блокируем скролл фона
}
function closeModal() {
modal.close()
document.body.style.overflow = ''
}
// Закрытие по Escape встроено в <dialog>
// Закрытие по клику на backdrop
modal.addEventListener('click', (e) => {
const rect = modal.getBoundingClientRect()
const clickedBackdrop = (
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom
)
if (clickedBackdrop) closeModal()
})
document.getElementById('cancel-btn')!.addEventListener('click', closeModal)
document.querySelector('.modal__close')!.addEventListener('click', closeModal)
dialog.modal {
padding: 0;
border: none;
border-radius: 12px;
max-width: 500px;
width: calc(100% - 32px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: modal-in 0.2s ease;
}
dialog.modal::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
animation: backdrop-in 0.2s ease;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Анимация закрытия — через класс */
dialog.modal.closing {
animation: modal-out 0.15s ease forwards;
}
@keyframes modal-out {
to { opacity: 0; transform: scale(0.95); }
}
React: universal Modal компонент
import { useEffect, useRef, ReactNode } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
closeOnBackdrop?: boolean
}
export function Modal({
isOpen, onClose, title, children,
size = 'md',
closeOnBackdrop = true,
}: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement
dialog.showModal()
document.body.style.overflow = 'hidden'
} else {
dialog.close()
document.body.style.overflow = ''
previousFocusRef.current?.focus()
}
}, [isOpen])
// Синхронизируем с нативным close (Escape)
useEffect(() => {
const dialog = dialogRef.current
const handleClose = () => onClose()
dialog?.addEventListener('close', handleClose)
return () => dialog?.removeEventListener('close', handleClose)
}, [onClose])
function handleBackdropClick(e: React.MouseEvent<HTMLDialogElement>) {
if (!closeOnBackdrop) return
const rect = dialogRef.current!.getBoundingClientRect()
if (
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom
) {
onClose()
}
}
return createPortal(
<dialog
ref={dialogRef}
className={`modal modal--${size}`}
onClick={handleBackdropClick}
aria-labelledby={title ? 'modal-title' : undefined}
>
<div className="modal__content" onClick={e => e.stopPropagation()}>
{title && (
<div className="modal__header">
<h2 id="modal-title" className="modal__title">{title}</h2>
<button className="modal__close" onClick={onClose} aria-label="Закрыть">
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" strokeWidth="2"/>
</svg>
</button>
</div>
)}
<div className="modal__body">{children}</div>
</div>
</dialog>,
document.body
)
}
Focus trap: удержание фокуса внутри
Нативный <dialog> с showModal() уже обеспечивает focus trap. Для кастомных решений:
function trapFocus(element: HTMLElement): () => void {
const focusable = element.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
function handleTab(e: KeyboardEvent) {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
element.addEventListener('keydown', handleTab)
first?.focus()
return () => element.removeEventListener('keydown', handleTab)
}
Стек модалок (вложенные)
class ModalStack {
private stack: HTMLDialogElement[] = []
open(dialog: HTMLDialogElement) {
this.stack.push(dialog)
dialog.showModal()
document.body.style.overflow = 'hidden'
}
close() {
const top = this.stack.pop()
top?.close()
if (this.stack.length === 0) {
document.body.style.overflow = ''
}
}
closeAll() {
while (this.stack.length) {
this.stack.pop()?.close()
}
document.body.style.overflow = ''
}
}
export const modalStack = new ModalStack()
Bottomsheet для мобильных
На мобильных модалка снизу — нативнее, чем по центру экрана:
@media (max-width: 768px) {
dialog.modal {
margin: auto auto 0;
width: 100%;
max-width: 100%;
border-radius: 16px 16px 0 0;
max-height: 90dvh;
animation: sheet-in 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
dialog.modal::backdrop {
align-items: flex-end;
}
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
}
Скролл внутри модалки на iOS
На iOS -webkit-overflow-scrolling: touch внутри position: fixed элементов работает нестабильно:
.modal__body {
overflow-y: auto;
max-height: calc(90dvh - 80px); /* dvh вместо vh — учитывает мобильный UI */
overscroll-behavior: contain; /* не передавать скролл на фон */
-webkit-overflow-scrolling: touch;
}
Confirm-диалог вместо нативного window.confirm
// Использование:
// const confirmed = await confirm({ title: 'Удалить?', message: 'Это действие нельзя отменить' })
let resolveConfirm: (value: boolean) => void
export function confirm(options: { title: string; message: string }): Promise<boolean> {
return new Promise(resolve => {
resolveConfirm = resolve
// Открываем компонент ConfirmModal через глобальный стейт или event bus
eventBus.emit('confirm:open', options)
})
}
function ConfirmModal() {
const [state, setState] = useState<{ title: string; message: string } | null>(null)
useEffect(() => {
eventBus.on('confirm:open', setState)
return () => eventBus.off('confirm:open', setState)
}, [])
const close = (result: boolean) => {
resolveConfirm?.(result)
setState(null)
}
if (!state) return null
return (
<Modal isOpen title={state.title} onClose={() => close(false)} closeOnBackdrop={false}>
<p>{state.message}</p>
<div className="modal__actions">
<button onClick={() => close(true)}>Подтвердить</button>
<button onClick={() => close(false)}>Отмена</button>
</div>
</Modal>
)
}
Сроки
Нативный <dialog> с базовой анимацией и закрытием — 3–4 часа. React-компонент с порталом, focus trap, bottomsheet для мобильных — 1 день. Система с confirm/alert API, стеком модалок и доступностью по WCAG — 1.5–2 дня.







