Портал рендерить дочірні елементи в інший вузол DOM, ніж той, де знаходиться компонент:
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
<div className="fixed inset-0 grid place-items-center bg-black/50" onClick={onClose}>
<div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}
Навіщо: модальні вікна, підказки, випадні меню мають бути поверх усього. Якщо їхня розмітка глибоко в дереві, батьківські стилі заважають: overflow: hidden обрізає, transform ламає position: fixed, z-index обмежений контекстом накладання батька.
Ключова особливість - події. Портал змінює місце в DOM, але не в дереві React. Події спливають за деревом React:
<div onClick={() => console.log('клік у батьку')}>
<Modal open>
<button>Кнопка у вікні</button> {/* клік спливе до div батька! */}
</Modal>
</div>
Клік по кнопці, яка в DOM лежить у body, все одно викличе onClick батька. Те саме з контекстом: портал бачить провайдери свого предка в React, а не за місцем у DOM.
Це зручно (стан, контекст і обробники працюють як для звичайних дітей), але інколи несподівано - наприклад, «клік поза меню» через contains() у DOM вважатиме клік у порталі зовнішнім.
Що ще потрібно для якісного модального вікна (портал цього не дає):
- фокус усередині вікна і повернення фокусу після закриття;
- закриття по Escape;
- блокування прокрутки сторінки;
aria-modal,aria-labelledby.
Нативна альтернатива - <dialog> з showModal(): браузер сам виносить вікно на верхній шар (без порталу й боротьби із z-index), дає фокус і Escape. Для багатьох випадків простіше за портал.
SSR: на сервері порталів у document.body немає - модальні вікна зазвичай рендерять лише на клієнті (після монтування).