Implement React Portals in Lit Web Components

David Cai
2 min readJun 2, 2022

--

Portal

React has a createPortal API to render a child node in a container node:

ReactDOM.createPortal(child, container);

The container node can be anywhere in the DOM. This enables interesting use cases such as injecting app-level overlays, e.g. Modal, Banner, and Toast to document.body :

render() {
return ReactDOM.createPortal(this.props.children, document.body);
}

How can we create portals in Lit web components?

The solution is surprisingly easy — createRenderRoot :

@customElement('banner')
export class Banner extends LitElement {
render() {
return html`
<div class="banner">...</div>
`;
}
createRenderRoot() {
return document.body;
}
}

By default, a Lit component renders its content inside its Shadow DOM. Something like this:

<banner>
|
+- #shadow-root <------ render root
|
+- <div class="banner">

The renderRoot points to the shadowRoot . We can customize the render root by implementing LitElement’s createRenderRoot method. Any HTMLElement can be returned from this method, e.g.:

createRenderRoot() {
return document.querySelector('#banner-container');
}

The rendered content will now be injected to an HTML element with a “banner-container” ID:

<banner>   <------ An empty custom element without content...<div id="overlay-container">
|
+- <div class="banner"> <---- Banner content is ported here

The banner’s content is directly injected into the overlay container. There is no shadow DOM to encapsulate banner styles, which might be something you miss. To bring back shadow DOM for the rendered content, we can render a Lit component — banner-content, instead of a div in Banner’s render method:

@customElement('banner')
export class Banner extends LitElement {
render() {
return html`
<banner-content>...</banner-content>
`;
}
createRenderRoot() {
return document.querySelector('#overlay-container');
}
}
@customElement('banner-content')
export class BannerContent extends LitElement {
render() {
return html`
<div class="banner">...</div>
`;
}
}

The banner-content is a Lit component which by default renders its content inside its own shadow DOM:

<banner>   <------ An empty custom element...<div id="overlay-container">
|
+- <banner-content> <---- Banner content is ported here
|
+- #shadow-root <---- Banner content's own shadow DOM
|
+- <div class="banner">

Now, banner-content styles are encapsulated.

Bonus

Return this in the createRenderRoot method will make a Lit component to render its content in the Light DOM instead of Shadow DOM.

--

--