Представьте такую ситуацию: у вас есть карточка товара с overflow: hidden и position: relative. Внутри этой карточки вы хотите показать всплывающую подсказку или модальное окно. Но CSS-свойства родителя обрезают всё, что выходит за его границы.
// Проблема: tooltip обрезается родителем
<div style={{ overflow: 'hidden', position: 'relative', height: '50px' }}>
<ProductCard>
<Tooltip>Это подсказка — она будет обрезана!</Tooltip>
</ProductCard>
</div>Решение — порталы (portals). Portal позволяет рендерить компонент в другом узле DOM (например, прямо в document.body), при этом сохраняя его в React-дереве на прежнем месте.
import ReactDOM from 'react-dom'
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null
// Рендерим в document.body, но компонент остаётся в React-дереве
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body // <- второй аргумент: DOM-узел назначения
)
}
// Использование как обычного компонента:
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<div style={{ overflow: 'hidden' }}>
<button onClick={() => setIsOpen(true)}>Открыть</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>Я рендерюсь в document.body!</h2>
</Modal>
</div>
)
}Три главных сценария:
1. Модальные окна и диалоги
Должны перекрывать весь интерфейс. Рендеринг в document.body гарантирует, что z-index: 9999 сработает правильно.
2. Тултипы и выпадающие меню
Родительский элемент часто имеет overflow: hidden или overflow: auto. Portal позволяет тултипу выйти за эти ограничения.
3. Уведомления (Toast)
Система уведомлений независима от текущего компонента. Удобно рендерить в отдельный контейнер #notifications всегда присутствующий в DOM.
// Подготовка контейнера для уведомлений (index.html):
// <div id="notifications"></div>
function Toast({ message }) {
return ReactDOM.createPortal(
<div className="toast">{message}</div>,
document.getElementById('notifications')
)
}Это ключевая особенность порталов: хотя компонент находится в DOM в другом месте, события всплывают по React-дереву (там, где компонент объявлен).
function Parent() {
const handleClick = () => console.log('Клик поймал Parent!')
return (
<div onClick={handleClick}>
<Modal>
{/* Клик на кнопку всплывёт до Parent — несмотря на то,
что в DOM Modal находится в document.body */}
<button>Нажми меня</button>
</Modal>
</div>
)
}Это поведение — намеренное. Оно позволяет компонентам-родителям обрабатывать события из порталов, как если бы портал был вложен обычным образом.
Лучшая практика — создавать и очищать DOM-контейнер через useEffect:
function usePortal(id = 'portal-root') {
const [container] = useState(() => {
let el = document.getElementById(id)
if (!el) {
el = document.createElement('div')
el.id = id
document.body.appendChild(el)
}
return el
})
return container
}
function Modal({ children }) {
const container = usePortal('modal-root')
return ReactDOM.createPortal(children, container)
}При использовании порталов следует придерживаться системы z-index:
/* Рекомендуемые уровни: */
.dropdown { z-index: 100; }
.sticky-header { z-index: 200; }
.tooltip { z-index: 300; }
.modal-overlay { z-index: 1000; }
.notification { z-index: 1100; }
.debug-panel { z-index: 9999; }Реализация паттерна портала на JavaScript: аппендим элемент к document.body, управляем z-index, делегируем события через родителя
// Демонстрируем концепцию порталов через DOM API.
// В React это ReactDOM.createPortal(), здесь — прямая работа с DOM.
// --- Создаём "компонент" Modal ---
function createModal() {
let overlay = null
let isOpen = false
let onCloseCallback = null
function mount(content, onClose) {
if (isOpen) return
isOpen = true
onCloseCallback = onClose
// Создаём overlay в document.body — как портал в React
overlay = document.createElement('div')
overlay.style.cssText = [
'position: fixed',
'inset: 0',
'background: rgba(0,0,0,0.5)',
'display: flex',
'align-items: center',
'justify-content: center',
'z-index: 1000', // перекрываем всё
].join(';')
const dialog = document.createElement('div')
dialog.style.cssText = [
'background: white',
'padding: 24px',
'border-radius: 8px',
'min-width: 300px',
'position: relative',
].join(';')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
dialog.innerHTML = content
const closeBtn = document.createElement('button')
closeBtn.textContent = '✕ Закрыть'
closeBtn.style.cssText = 'margin-top: 16px; cursor: pointer;'
closeBtn.addEventListener('click', unmount)
dialog.appendChild(closeBtn)
// Клик по overlay закрывает модал
overlay.addEventListener('click', (e) => {
if (e.target === overlay) unmount()
})
overlay.appendChild(dialog)
// Ключевой момент: добавляем в document.body, а не в родительский контейнер
document.body.appendChild(overlay)
console.log('Модал открыт. Добавлен в:', overlay.parentElement.tagName)
console.log('Модал находится вне родительского контейнера? Да!')
}
function unmount() {
if (!isOpen || !overlay) return
isOpen = false
overlay.remove()
overlay = null
if (onCloseCallback) onCloseCallback()
console.log('Модал закрыт и удалён из DOM')
}
return { mount, unmount, isOpen: () => isOpen }
}
// --- Демонстрация проблемы с overflow:hidden ---
// Создаём родительский контейнер с overflow:hidden
const container = document.createElement('div')
container.style.cssText = 'overflow: hidden; height: 60px; border: 2px solid red; padding: 10px;'
container.textContent = 'Родительский контейнер (overflow: hidden)'
document.body.appendChild(container)
// Обычный элемент внутри — будет обрезан
const overflowingDiv = document.createElement('div')
overflowingDiv.style.cssText = 'position: absolute; top: 80px; background: orange; padding: 8px;'
overflowingDiv.textContent = 'Обычный div: виден только если не обрезан'
container.appendChild(overflowingDiv)
console.log('=== Демонстрация порталов ===')
console.log('Контейнер имеет overflow:hidden')
console.log('Без портала: дочерние элементы обрезаются')
console.log('С порталом: рендерим в document.body — обрезания нет')
// --- Демонстрация всплытия событий ---
function createEventDemo() {
let parentClickCount = 0
// В React: событие из портала всплывает по React-дереву (к Parent)
// Здесь симулируем это через кастомные события
function simulatePortalEvent(source) {
console.log('\n--- Клик из компонента:', source, '---')
console.log('Событие всплывает по React-дереву:')
console.log(' ' + source + ' → Modal → Parent (поймано!)')
parentClickCount++
console.log('Parent получил событий:', parentClickCount)
}
return { simulatePortalEvent }
}
const eventDemo = createEventDemo()
eventDemo.simulatePortalEvent('Button внутри Modal')
// --- Создаём и открываем модал ---
const modal = createModal()
if (typeof document !== 'undefined') {
const openBtn = document.createElement('button')
openBtn.textContent = 'Открыть модал (через портал)'
openBtn.style.cssText = 'margin: 20px; padding: 10px 20px; cursor: pointer;'
openBtn.addEventListener('click', () => {
modal.mount(
'<h2>Модал через портал</h2>' +
'<p>Я рендерюсь в document.body,</p>' +
'<p>но события всплывают к родителю!</p>',
() => console.log('Callback onClose вызван')
)
})
document.body.appendChild(openBtn)
}
// --- z-index приоритеты ---
const zIndexLevels = {
'Обычный контент': 1,
'Выпадающее меню': 100,
'Липкая шапка': 200,
'Тултип': 300,
'Модальное окно': 1000,
'Уведомления (Toast)': 1100,
'Dev инструменты': 9999,
}
console.log('\n=== Рекомендуемые z-index уровни ===')
Object.entries(zIndexLevels).forEach(([name, z]) => {
console.log('z-index ' + String(z).padStart(5) + ': ' + name)
})Представьте такую ситуацию: у вас есть карточка товара с overflow: hidden и position: relative. Внутри этой карточки вы хотите показать всплывающую подсказку или модальное окно. Но CSS-свойства родителя обрезают всё, что выходит за его границы.
// Проблема: tooltip обрезается родителем
<div style={{ overflow: 'hidden', position: 'relative', height: '50px' }}>
<ProductCard>
<Tooltip>Это подсказка — она будет обрезана!</Tooltip>
</ProductCard>
</div>Решение — порталы (portals). Portal позволяет рендерить компонент в другом узле DOM (например, прямо в document.body), при этом сохраняя его в React-дереве на прежнем месте.
import ReactDOM from 'react-dom'
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null
// Рендерим в document.body, но компонент остаётся в React-дереве
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body // <- второй аргумент: DOM-узел назначения
)
}
// Использование как обычного компонента:
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<div style={{ overflow: 'hidden' }}>
<button onClick={() => setIsOpen(true)}>Открыть</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>Я рендерюсь в document.body!</h2>
</Modal>
</div>
)
}Три главных сценария:
1. Модальные окна и диалоги
Должны перекрывать весь интерфейс. Рендеринг в document.body гарантирует, что z-index: 9999 сработает правильно.
2. Тултипы и выпадающие меню
Родительский элемент часто имеет overflow: hidden или overflow: auto. Portal позволяет тултипу выйти за эти ограничения.
3. Уведомления (Toast)
Система уведомлений независима от текущего компонента. Удобно рендерить в отдельный контейнер #notifications всегда присутствующий в DOM.
// Подготовка контейнера для уведомлений (index.html):
// <div id="notifications"></div>
function Toast({ message }) {
return ReactDOM.createPortal(
<div className="toast">{message}</div>,
document.getElementById('notifications')
)
}Это ключевая особенность порталов: хотя компонент находится в DOM в другом месте, события всплывают по React-дереву (там, где компонент объявлен).
function Parent() {
const handleClick = () => console.log('Клик поймал Parent!')
return (
<div onClick={handleClick}>
<Modal>
{/* Клик на кнопку всплывёт до Parent — несмотря на то,
что в DOM Modal находится в document.body */}
<button>Нажми меня</button>
</Modal>
</div>
)
}Это поведение — намеренное. Оно позволяет компонентам-родителям обрабатывать события из порталов, как если бы портал был вложен обычным образом.
Лучшая практика — создавать и очищать DOM-контейнер через useEffect:
function usePortal(id = 'portal-root') {
const [container] = useState(() => {
let el = document.getElementById(id)
if (!el) {
el = document.createElement('div')
el.id = id
document.body.appendChild(el)
}
return el
})
return container
}
function Modal({ children }) {
const container = usePortal('modal-root')
return ReactDOM.createPortal(children, container)
}При использовании порталов следует придерживаться системы z-index:
/* Рекомендуемые уровни: */
.dropdown { z-index: 100; }
.sticky-header { z-index: 200; }
.tooltip { z-index: 300; }
.modal-overlay { z-index: 1000; }
.notification { z-index: 1100; }
.debug-panel { z-index: 9999; }Реализация паттерна портала на JavaScript: аппендим элемент к document.body, управляем z-index, делегируем события через родителя
// Демонстрируем концепцию порталов через DOM API.
// В React это ReactDOM.createPortal(), здесь — прямая работа с DOM.
// --- Создаём "компонент" Modal ---
function createModal() {
let overlay = null
let isOpen = false
let onCloseCallback = null
function mount(content, onClose) {
if (isOpen) return
isOpen = true
onCloseCallback = onClose
// Создаём overlay в document.body — как портал в React
overlay = document.createElement('div')
overlay.style.cssText = [
'position: fixed',
'inset: 0',
'background: rgba(0,0,0,0.5)',
'display: flex',
'align-items: center',
'justify-content: center',
'z-index: 1000', // перекрываем всё
].join(';')
const dialog = document.createElement('div')
dialog.style.cssText = [
'background: white',
'padding: 24px',
'border-radius: 8px',
'min-width: 300px',
'position: relative',
].join(';')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
dialog.innerHTML = content
const closeBtn = document.createElement('button')
closeBtn.textContent = '✕ Закрыть'
closeBtn.style.cssText = 'margin-top: 16px; cursor: pointer;'
closeBtn.addEventListener('click', unmount)
dialog.appendChild(closeBtn)
// Клик по overlay закрывает модал
overlay.addEventListener('click', (e) => {
if (e.target === overlay) unmount()
})
overlay.appendChild(dialog)
// Ключевой момент: добавляем в document.body, а не в родительский контейнер
document.body.appendChild(overlay)
console.log('Модал открыт. Добавлен в:', overlay.parentElement.tagName)
console.log('Модал находится вне родительского контейнера? Да!')
}
function unmount() {
if (!isOpen || !overlay) return
isOpen = false
overlay.remove()
overlay = null
if (onCloseCallback) onCloseCallback()
console.log('Модал закрыт и удалён из DOM')
}
return { mount, unmount, isOpen: () => isOpen }
}
// --- Демонстрация проблемы с overflow:hidden ---
// Создаём родительский контейнер с overflow:hidden
const container = document.createElement('div')
container.style.cssText = 'overflow: hidden; height: 60px; border: 2px solid red; padding: 10px;'
container.textContent = 'Родительский контейнер (overflow: hidden)'
document.body.appendChild(container)
// Обычный элемент внутри — будет обрезан
const overflowingDiv = document.createElement('div')
overflowingDiv.style.cssText = 'position: absolute; top: 80px; background: orange; padding: 8px;'
overflowingDiv.textContent = 'Обычный div: виден только если не обрезан'
container.appendChild(overflowingDiv)
console.log('=== Демонстрация порталов ===')
console.log('Контейнер имеет overflow:hidden')
console.log('Без портала: дочерние элементы обрезаются')
console.log('С порталом: рендерим в document.body — обрезания нет')
// --- Демонстрация всплытия событий ---
function createEventDemo() {
let parentClickCount = 0
// В React: событие из портала всплывает по React-дереву (к Parent)
// Здесь симулируем это через кастомные события
function simulatePortalEvent(source) {
console.log('\n--- Клик из компонента:', source, '---')
console.log('Событие всплывает по React-дереву:')
console.log(' ' + source + ' → Modal → Parent (поймано!)')
parentClickCount++
console.log('Parent получил событий:', parentClickCount)
}
return { simulatePortalEvent }
}
const eventDemo = createEventDemo()
eventDemo.simulatePortalEvent('Button внутри Modal')
// --- Создаём и открываем модал ---
const modal = createModal()
if (typeof document !== 'undefined') {
const openBtn = document.createElement('button')
openBtn.textContent = 'Открыть модал (через портал)'
openBtn.style.cssText = 'margin: 20px; padding: 10px 20px; cursor: pointer;'
openBtn.addEventListener('click', () => {
modal.mount(
'<h2>Модал через портал</h2>' +
'<p>Я рендерюсь в document.body,</p>' +
'<p>но события всплывают к родителю!</p>',
() => console.log('Callback onClose вызван')
)
})
document.body.appendChild(openBtn)
}
// --- z-index приоритеты ---
const zIndexLevels = {
'Обычный контент': 1,
'Выпадающее меню': 100,
'Липкая шапка': 200,
'Тултип': 300,
'Модальное окно': 1000,
'Уведомления (Toast)': 1100,
'Dev инструменты': 9999,
}
console.log('\n=== Рекомендуемые z-index уровни ===')
Object.entries(zIndexLevels).forEach(([name, z]) => {
console.log('z-index ' + String(z).padStart(5) + ': ' + name)
})Создай компонент Modal с использованием ReactDOM.createPortal. Модал должен рендериться в отдельный div вне основного дерева. Добавь кнопки для открытия/закрытия модала.
Используй ReactDOM.createPortal(overlay, document.body). Первый аргумент — JSX для рендеринга, второй — DOM-узел куда рендерить. Модал будет физически в document.body, но события будут всплывать по React-дереву.