createPortal
Create a portal to continue rendering the vnode tree at a different DOM node. Portals allow you to render children into a DOM node that exists outside the parent component’s DOM hierarchy.
Signature
function createPortal(
vnode: ComponentChildren,
container: ContainerNode
): VNode<any>
vnode
ComponentChildren
required
The vnode(s) to render in the portal.
The DOM node to render into. Must be a valid DOM element.
A portal vnode that renders its children into the specified container.
Usage
Basic Portal
Render content into a different DOM node:
import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks';
function Modal({ children, isOpen }) {
if (!isOpen) return null;
// Render into document.body
return createPortal(
<div className="modal-overlay">
<div className="modal">
{children}
</div>
</div>,
document.body
);
}
// Usage
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Open Modal</button>
<Modal isOpen={showModal}>
<h2>Modal Title</h2>
<p>Modal content</p>
<button onClick={() => setShowModal(false)}>Close</button>
</Modal>
</div>
);
}
Portal with Custom Container
Render into a specific DOM element:
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
function Tooltip({ children, content, targetId }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const target = document.getElementById(targetId);
setContainer(target);
}, [targetId]);
if (!container) return children;
return (
<>
{children}
{createPortal(
<div className="tooltip">{content}</div>,
container
)}
</>
);
}
// HTML:
// <div id="tooltip-container"></div>
// Usage:
<Tooltip content="Help text" targetId="tooltip-container">
<button>Hover me</button>
</Tooltip>
Modal with Focus Management
import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks';
function Modal({ children, isOpen, onClose }) {
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
// Store currently focused element
const previousFocus = document.activeElement;
// Focus the modal
modalRef.current?.focus();
// Handle escape key
const handleEscape = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
// Cleanup
return () => {
document.removeEventListener('keydown', handleEscape);
previousFocus?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
className="modal"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}
Common Use Cases
Modals
import { createPortal } from 'preact/compat';
function Dialog({ title, children, onClose }) {
return createPortal(
<div className="dialog-overlay">
<div className="dialog">
<header>
<h2>{title}</h2>
<button onClick={onClose}>×</button>
</header>
<div className="dialog-content">
{children}
</div>
</div>
</div>,
document.body
);
}
import { createPortal } from 'preact/compat';
import { useState, useRef, useEffect } from 'preact/hooks';
function Tooltip({ children, text }) {
const [show, setShow] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const triggerRef = useRef();
const handleMouseEnter = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
x: rect.left + rect.width / 2,
y: rect.top
});
setShow(true);
}
};
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShow(false)}
>
{children}
</span>
{show && createPortal(
<div
className="tooltip"
style={{
position: 'absolute',
left: `${position.x}px`,
top: `${position.y - 30}px`,
transform: 'translateX(-50%)'
}}
>
{text}
</div>,
document.body
)}
</>
);
}
Notifications
import { createPortal } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks';
function Toast({ message, duration = 3000, onClose }) {
useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
return createPortal(
<div className="toast">
{message}
<button onClick={onClose}>×</button>
</div>,
document.body
);
}
function ToastContainer() {
const [toasts, setToasts] = useState([]);
const addToast = (message) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message }]);
};
const removeToast = (id) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
return (
<>
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
onClose={() => removeToast(toast.id)}
/>
))}
</>
);
}
import { createPortal } from 'preact/compat';
import { useState, useRef, useEffect } from 'preact/hooks';
function Dropdown({ trigger, children }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const triggerRef = useRef();
useEffect(() => {
if (isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
x: rect.left,
y: rect.bottom + 5
});
}
}, [isOpen]);
useEffect(() => {
if (isOpen) {
const handleClickOutside = (e) => {
if (!triggerRef.current?.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [isOpen]);
return (
<>
<div ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
{isOpen && createPortal(
<div
className="dropdown"
style={{
position: 'absolute',
left: `${position.x}px`,
top: `${position.y}px`
}}
>
{children}
</div>,
document.body
)}
</>
);
}
Implementation Details
The createPortal implementation:
function Portal(props) {
const _this = this;
let container = props._container;
_this.componentWillUnmount = function () {
render(null, _this._temp);
_this._temp = null;
_this._container = null;
};
if (_this._container && _this._container !== container) {
_this.componentWillUnmount();
}
if (!_this._temp) {
let root = _this._vnode;
while (root !== null && !root._mask && root._parent !== null) {
root = root._parent;
}
_this._container = container;
_this._temp = {
nodeType: 1,
parentNode: container,
childNodes: [],
_children: { _mask: root._mask },
ownerDocument: container.ownerDocument,
namespaceURI: container.namespaceURI,
insertBefore(child, before) {
this.childNodes.push(child);
_this._container.insertBefore(child, before);
}
};
}
render(
createElement(ContextProvider, { context: _this.context }, props._vnode),
_this._temp
);
}
export function createPortal(vnode, container) {
const el = createElement(Portal, { _vnode: vnode, _container: container });
el.containerInfo = container;
return el;
}
Key Features
- Context Preservation: Portals maintain the React context from their parent
- Event Bubbling: Events bubble through the React tree, not the DOM tree
- Cleanup: Automatically unmounts when the portal component unmounts
- useId Support: Preserves mask for
useId hook functionality
Event Bubbling
Events bubble through the React component tree, not the DOM tree:
import { createPortal } from 'preact/compat';
function App() {
const handleClick = (e) => {
console.log('Clicked in portal!');
};
return (
<div onClick={handleClick}>
<p>Parent component</p>
{createPortal(
<button>Click me</button>,
document.body
)}
</div>
);
}
// Clicking the button triggers handleClick, even though
// the button is rendered in document.body
Context Inheritance
Portals inherit context from their parent:
import { createPortal, createContext, useContext } from 'preact/compat';
const ThemeContext = createContext('light');
function Modal({ children }) {
const theme = useContext(ThemeContext);
return createPortal(
<div className={`modal theme-${theme}`}>
{children}
</div>,
document.body
);
}
function App() {
return (
<ThemeContext.Provider value="dark">
<Modal>
<p>This modal has the dark theme</p>
</Modal>
</ThemeContext.Provider>
);
}
Best Practices
- Container Management: Ensure the container element exists before creating the portal
- Cleanup: Let Preact handle cleanup - don’t manually remove portal content
- Accessibility: Manage focus and keyboard navigation properly
- Z-Index: Use CSS to control stacking order
- Event Handling: Remember that events bubble through the React tree
Common Patterns
Portal Manager
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
function createPortalRoot(id) {
const existingRoot = document.getElementById(id);
if (existingRoot) return existingRoot;
const root = document.createElement('div');
root.id = id;
document.body.appendChild(root);
return root;
}
function Portal({ children, id = 'portal-root' }) {
const [container] = useState(() => createPortalRoot(id));
return createPortal(children, container);
}
Lazy Portal Container
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
function LazyPortal({ children, containerId }) {
const [container, setContainer] = useState(null);
useEffect(() => {
// Wait for container to be available
const checkContainer = () => {
const el = document.getElementById(containerId);
if (el) {
setContainer(el);
} else {
setTimeout(checkContainer, 100);
}
};
checkContainer();
}, [containerId]);
if (!container) return null;
return createPortal(children, container);
}
TypeScript
Type your portal components:
import { createPortal } from 'preact/compat';
import { ComponentChildren } from 'preact';
interface ModalProps {
children: ComponentChildren;
isOpen: boolean;
onClose: () => void;
}
function Modal({ children, isOpen, onClose }: ModalProps) {
if (!isOpen) return null;
return createPortal(
<div className="modal" onClick={onClose}>
{children}
</div>,
document.body
);
}
Source
Implementation: compat/src/portals.js:1-75