DEV Community

Jonathan Gros-Dubois
Jonathan Gros-Dubois

Posted on • Edited on

Web Components  -  The Template-Viewport Pattern  for the Shadow DOM

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>
Enter fullscreen mode Exit fullscreen mode

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>
`;
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
dannyengelman profile image
Danny Engelman

you lost me at the router. Might help if you actually post working code

Collapse
 
jondubois profile image
Jonathan Gros-Dubois • Edited

Thanks for the feedback, that is an oversight on my part.

I used app-router here github.com/Saasufy/product-browser... to build this product browser widget saasufy.github.io/product-browser-... - It's hooked up to my no-code serverless platform (where it gets the data from). All the source/markup is inside index.html.

You should be able to clone the GitHub repo and run it anywhere if you want to tinker with it.