Skip to main content

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.
container
ContainerNode
required
The DOM node to render into. Must be a valid DOM element.
returns
VNode<any>
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>
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
  );
}

Tooltips

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

  1. Context Preservation: Portals maintain the React context from their parent
  2. Event Bubbling: Events bubble through the React tree, not the DOM tree
  3. Cleanup: Automatically unmounts when the portal component unmounts
  4. 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

  1. Container Management: Ensure the container element exists before creating the portal
  2. Cleanup: Let Preact handle cleanup - don’t manually remove portal content
  3. Accessibility: Manage focus and keyboard navigation properly
  4. Z-Index: Use CSS to control stacking order
  5. 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

Build docs developers (and LLMs) love