Web Components - The Template-Viewport Pattern for the Shadow DOM
Jonathan Gros-Dubois
Posted on November 21, 2023
Over the past couple of months, I've been working on a new no-code (HTML markup only) 'serverless' platform which uses native Web Components on the front end (see https://saasufy.com/).
One of the problems I ran into early on was related to styling components which make use of the Shadow DOM. For those who don't know, the Shadow DOM is a mechanism which allows a component to support a range of features; most notably, the ability to generate new child elements inside the component at runtime without polluting the main DOM and without conflicting with (or overwriting) child elements slotted in from the outside.
I had previously built components without using the Shadow DOM but I ran into a situation where I needed to work with slotted content/HTML whilst also generating additional HTML from within the component.
The template
The component I wanted to build was a general-purpose collection-browser
component; the goal of this component was to render a customizable, browsable list of elements by filling out an arbitrarily complex HTML template with data from a back end server for each entry in a collection. I realized that this could be achieved using the Shadow DOM with slotted <template>
elements like this:
<template slot="item">
<div>Hello {{username}}!</div>
</template>
Unfortunately, upon rendering the filled-out template inside the Shadow DOM, external CSS definitions would not apply to them. This posed a significant problem because it meant that the collection-browser
component could not be made 'general purpose' across multiple different projects and/or companies as it lacked styling flexibility.
The reason why external CSS styles do not apply to elements inside the Shadow DOM is because the Shadow DOM fully encapsulates its styling; this means that style definitions from the Shadow DOM cannot affect elements outside of it and it also means that elements which are inside the Shadow DOM cannot be styled with CSS that is defined outside of the component. This poses a significant problem when building general-purpose components.
While there are ways to 'inject' style information into the Shadow DOM from the outside, the approaches are unfamiliar and add complexity*. To be viable, the HTML generated by the collection-browser
component had to abide by the page's main CSS styles automatically and without any magic.
This led me to the discovery of a simple pattern which I later re-used for many of other components. I call it the template-viewport pattern
. The idea is that because HTML which is generated inside the Shadow DOM cannot be styled externally, any HTML generated from inside the component had to be injected outside of the Shadow DOM (inside the Light DOM).
The viewport
The simplest solution I could find to allow this was to invent the concept of a 'viewport' element - This meant that the collection-browser
component would need to accept two slotted elements:
- A template to use as input to generate some 'filled out' output HTML.
- A viewport element to act as a container for the output HTML.
The idea behind this is that if a viewport element is slotted into my collection-browser
component from the outside, any elements which are injected inside it (including those generated internally by the component) will abide by the page's main CSS styles as they will be part of the Light DOM and not the Shadow DOM.
Working with slotted elements with Web Components is simple. I defined a render()
method for my component which generates placeholder tags for the slotted elements like this:
this.shadowRoot.innerHTML = `
<slot name="item"></slot>
<slot name="viewport"></slot>
`;
Here, the slot with name="item"
will hold a <template slot="item">
element slotted in from the outside and the slot with name="viewport"
will hold a slotted <div slot="viewport"></div>
element.
Getting a reference to the Light DOM viewport element from inside the collection-browser via the Shadow DOM's <slot name="viewport">
element is done like this:
let viewportNode = this.shadowRoot
.querySelector('slot[name="viewport"]').assignedNodes()[0];
Rendering some HTML inside it is just a matter of setting its innerHTML property like viewportNode.innerHTML = ...
Components in action
Here's what the component looks like from outside:
<!--
Loads some chat messages from the server and renders
each message based on the template with
slot="item" into the slot="viewport" element near
the bottom.
-->
<collection-browser
collection-type="Chat"
collection-fields="username,message,createdAt"
collection-view="recentView"
collection-view-params=""
collection-page-size="50"
>
<template slot="item">
<div>
<div class="chat-username">
<b>{{Chat.username}}</b>
</div>
<div class="chat-message">{{Chat.message}}</div>
<div class="chat-created-at">{{date(Chat.createdAt)}}</div>
</div>
</template>
<div slot="viewport" class="chat-viewport"></div>
</collection-browser>
This construct has proven itself to be robust and works well with any amount of nesting. So for example you can have a collection-browser
rendering a template which contains another collection-browser
with its own template so it generates lists within a list.
Another good use case for this approach has been to build an app-router
component which generates a different template based on the current URL's location.hash
:
<app-router>
<template slot="page" route-path="/home">
<div>This is the home page</div>
</template>
<template slot="page" route-path="/about-us">
<div>This is the about-us page</div>
</template>
<template slot="page" route-path="/products">
<div>This is the products page</div>
</template>
<div slot="viewport"></div>
</app-router>
Again, this works well with any level of nesting and you can mix and match different components.
EDIT
If you want to see this collection-browser
and app-router
code used in a working app, check out this GitHub repo: https://github.com/Saasufy/product-browser-demo/blob/main/index.html#L47-L80 - It can run anywhere (just Git clone) but if you're as lazy as me, you'll want to try the hosted version here: https://saasufy.github.io/product-browser-demo/index.html#/sport
The repo for the chat example I alluded to in this guide can be found here: https://github.com/Saasufy/chat-app and is hosted here: https://saasufy.github.io/chat-app/ (you can log in with GitHub by clicking on the link at the bottom of the log in form).
Anyway, that's it. I hope you find a use for this pattern in your own projects. Don't hesitate to hit the like button if you found this guide useful.
* An approach to explicitly style elements inside a component's shadow DOM is by using the CSS parts API. See https://developer.mozilla.org/en-US/docs/Web/CSS/::part - This approach is ideal if you want to style inner elements but don't want them to abide by your page's style definitions.
Posted on November 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.