Web Components Tailwind And SSR

Foreword

This post is not meant to be inflammatory or to rag on any technology. This is meant to be educational. If you’re looking for a slam piece, I don’t think you’re in the right spot.

This blog post is intended more to be a high level overview, and won’t get into the nitty gritty implementation details.

This was post was inspired by Live with Adam Wathan at Rails World 2023 of Remote Ruby where Adam and the Remote Ruby crew talk about styling Web Components with Tailwind and Web Component SSR.

Full Disclosure

I work full time on a Web Component library called Shoelace for FontAwesome . So yes. I have a vested interest in web components and shadow DOM. But I’ll try to keep this as unbiased and educational as possible.

What we’ll be covering

Using Tailwind with Web Components

Before we dive in, let’s first cover two different “types” of users of Web Components.

There are “authors” of web components, people actually writing web components, and there are “consumers” of web components. People who are pulling web components into their applications or libraries.

Let’s start with authors.

Using Tailwind as a Web Component Author

As an author, there’s a few ways you can use Tailwind inside your shadow DOMs.

1.) Use a <link> tag in the shadow DOM of every element pointing to either the Tailwind CDN or pointing to a fully built Tailwind stylesheet. This is probably the easiest, but also the worst way to use Tailwind as a web component author. It incurs significant runtime overhead to every element. If you point to the CDN you lose out on JIT and ship way more CSS than necessary.

2.) Have users load your stylesheet globally in a <link> tag in the head of the document and then use Constructed Stylesheets to pull the styles into your component. I made an example in a CodePen used this approach, it introduces a decent amount of code for you to maintain, and will still require you to have users provide a global stylesheet. This is a good approach if you’re authoring web components in your application code. But for libraries that may not expect users to have Tailwind loaded globally, it really isn’t practical.

I previously made a CodePen of using Constructable Stylesheets with Bootstrap, just s/bootstrap/tailwind. The implementation is the same.

https://codepen.io/paramagicdev/pen/oNJwdgW

3.) Run the Tailwind compiler over all of your web components and JIT the right styles into a per-component stylesheet and import that stylesheet in the individual component. This will produce the smallest individual components, however, you will have duplicated CSS across many components so you lose out on the benefit of shared classes which is a large benefit of utility classes. This is probably also the hardest to setup since you need a per-component Tailwind process to create a stylesheet. I don’t recommend this approach, but it is an option.

4.) Run the Tailwind compiler over all of your web components and JIT the right styles into a shared stylesheet and import that stylesheet across components. This will require some additional setup and introduces a build step, but it’s probably the best option. The downside is every component has every Tailwind component you’re using across your app so components will have tons of styles they wont use.

Essentially you would do something like this if you were using Lit

JavaScript
// shared.styles.js
// This file is auto-generated.
export const styles = css`
  /** This CSS is generated by Tailwind */
  .px-2 {
    padding: 0.5rem;
  }

  /** ... */
`

And then in your component import the auto-generated stylesheet like this:

JavaScript
import { LitElement, html } from "lit"
import { styles } from "../shared.styles.js"

export default class MyComponent extends LitElement {
  static styles = styles

  render () {
    return html`<div class="px-2 py-4">
      <slot></slot>
    </div>`
  }
}

Yes. It’s weird using JavaScript files for CSS. Once Import Attributes lands in browsers, we can swap to directly importing CSS files inside of JS files so we don’t need an intermediate build step to convert the CSS file to an exported JS string.

Those are the 4 ways I could come up with to use Tailwind inside of Web Components as an author.

There are other ways using libraries that come with runtimes I didn’t cover here, but will provide links to.

Additional ways to load Tailwind in Web Component Shadow DOM

Disclaimer

I have not used these libraries / starter kits so I cannot speak to what the authoring experience is like.

Consuming Web Components with Tailwind

Using Tailwind with web components as a “consumer” doesn’t require any changes to your process.

If web components you are “consuming” use Shadow DOM, then you’re not able to “openly style” the components.

Meaning, you need to use a special pseudo-element selector called ::part that looks like this: ::part(component-part) to be able to target elements within the Shadow DOM.

Here is a very basic example using Shoelace of how you would use “parts” on a <sl-card>.

Here is <sl-card> and what it renders in it’s “shadowRoot”

HTML
<sl-card>
  #ShadowRoot
  <div part="base" class=" card card--has-footer card--has-image ">
    <slot name="image" part="image" class="card__image"></slot>
    <slot name="header" part="header" class="card__header"></slot>
    <slot part="body" class="card__body"></slot>
    <slot name="footer" part="footer" class="card__footer"></slot>
  </div>
</sl-card>

If I wanted to for example change the amount of “padding” on the <div part="base"> I have a couple of options:

1.) Use an external stylesheet. This is good if you plan to do this everywhere.

SCSS
sl-card::part(base) {
  @apply px-4 py-2;
}

2.) Use the Tailwind JIT syntax. Which is admittedly clunky here, but it works and is the “Tailwind way”.

HTML
<sl-card class="[&::part(base)]:px-4 [&::part(base)]:py-2">
</sl-card>

3.) Write a tailwind extension for the ::part() pseudo element. (Or bother Adam Wathan on Twitter to put it in “Tailwind Core”)

Ideally, I would want to do something like this:

HTML
<sl-card class="part(base):px-4 part(base):py-2">
</sl-card>

Web Components and SSR

What’s more inflammatory than talking about Tailwind? Talking about Tailwind, Web Components, and SSR!

Seriously though, I’ll keep the SSR story brief, and provide some history and things browsers on working on to create a more robust SSR story.

Where the Web Components render

There are 2 “broad” categories of web components for the purposes of “rendering”.

You have “Light DOM” components, and “Shadow DOM” components.

Light DOM components render their elements in the regular DOM, same as you would expect from a framework like React, Svelte, etc.

“Shadow DOM” components render their elements into a “Shadow Root” that’s essentially a different, separate, encapsulated document. This has stronger guarantees around structure and styling. It does with it’s own set of tradeoffs which we’ll cover later.

Integrations

Light DOM components are pretty well understood and behave like you would expect. They do present some challenges when integrating with other frameworks such as React / Svelte / Vue etc, because the component rendering ends up competing with the framework rendering, making it much harder to ship them as reusable libraries that “Work everywhere”. However, they are fantastic for application level development (Looking at you Enhance )

Libraries like Enhance and WebC have fantastic SSR stories, use light dom, and are web components!

From now on, we’ll focus on shadow DOM libraries, since light dom libraries are pretty well understood and established.

Shadow DOM is where things get tricky, especially if you haven’t used them before. First off, shadow DOM comes with “scoped styling” which means authors of web components expose “parts” for you to style. Shadow DOM elements are also not exposed to the top level document, and come with accessibility gotchas because they don’t have cross-root aria available yet. Shadow DOM also makes progressive enhancement more challenging due to scoped documents and “forms” not composing across shadow roots. These are all works in progress with proposals on how to solve them.

Don’t let all of the above scare you! Shadow DOM web components still have a place! They are caveats, not show stoppers. I believe in telling people everything up front, rather than hiding the punchline. I’ll let you evaluate if they’re right for you :)

Let’s keep it moving! Let’s talk SSR for shadow DOM components.

The problem, until recently, was that the only way to render a shadow DOM was with JavaScript, which made SSR not possible.

Introducing, Declarative Shadow DOM

Browsers have recently shipped a proposal called “Declarative Shadow DOM” (Abbreviated as DSD). Declarative Shadow DOM allows you to attach a “ShadowRoot” or “Shadow DOM” to any element (even a <div>!) without JavaScript (in supported browsers).

HTML
<div>
  <template shadowrootmode="open">
    <div style="border: 4px dotted dodgerblue; padding: 16px;">
      Look mom! I'm in a shadow root! without JavaScript (Except older browsers and Firefox)!
    </div>
  </template>
</div>

Browser Support:

Browser support is still not as high as it could be, with Firefox still not natively supporting it at the time of writing. https://caniuse.com/?search=declarative%20shadow%20dom%


Firefox has recently assigned a maintainer to start work on DSD. https://bugzilla.mozilla.org/show_bug.cgi?id=1712140#a76008685_434964


The polyfill is also quite minimal, albeit not 100% the same since DSD is natively implemented at the HTML parser level. https://www.matuzo.at/blog/2023/web-components-accessibility-faq/declarative-shadowdom-polyfill/

Why can’t the browser SSR web components out of the box?!

Because the browser doesn’t know what your component is doing. Same reason the browser can’t SSR your React components without an SSR-capable framework in pl I also recognize needing to be explicit can result in ace to turn the component into an HTML string. Right now most frameworks (web components and JS frameworks) support SSR by running in a NodeJS process, stubbing the DOM, rendering with appropriate data, and sending the string to the client for the initial render.

This means things like ResizeObservers, height / width calculations, etc aren’t going to be 1:1 with the browser, but approximations.

This also means for components to properly support SSR, they should ideally be pure implementations that don’t rely on “side-effects” except where necessary.

This is the real world. Side effects happen. Some components simply need JS or side-effects and can’t rely solely on attributes. I get it. It’s a guideline. Not a hard and fast rule.

But how can I make my components work without JS or prior to JS loading

Alright, we’ve moved the goal posts from SSR to progressive enhancement which is a whole other can of worms we won’t talk about here.

With WebC / Enhance and other light dom rendered libraries, it’s fairly trivial to progressively enhance since it’s all light dom and you don’t need to worry about cross-root forms and other shadow dom specific issues.

With shadow dom rendered components, now you’re looking at “form associated custom elements”. This is beyond the scope of this post, but there are proposals out there to help smooth over this rough edge. I’m not going to pretend it doesn’t exist and it is definitely a weak point of shadow dom rendered web components today. There are ways around it without form association if you use Web Components purely as styling wrappers, or if you have light DOM fallbacks until your shadow DOM component renders, but again. Out of scope, but is a real and legitimate concern.

Looking to the future

Unfortunately, DSD does not provide us with a proper templating solution which would be the cherry on top for robust SSR. As it stands, we have DSD which is a primitive allowing us to render ShadowRoot HTML without needing JavaScript, but how you render that declarative shadow dom is up to the web component itself and it’s underlying library / implementation. A universal templating language would be amazing.

There is a proposal for a “mustache-like” templating language built into the browser, but it really doesn’t have too much support right now. But would give us a much better story for “SSR out of the box”

https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md

So the long story short is we have primitives in place for rendering shadow DOM HTML. It’s just not as robust as it could be. But to say it’s not possible is false.

SSR + Frameworks

Now we’re into the hard part. Frameworks. Without JS frameworks, we could have a pretty compelling SSR story. However, ignoring frameworks is ignoring the entire premise of web components. A cross-compatible component layer you can pull into any project.

The problem with frameworks starts with their SSR implementations being a “closed garden”. Multiple attempts have been made by the Lit development team to talk with NextJS team to support Lit SSR in NextJS, however, talks largely stalled. Without hooks into the SSR process, it’s impossible to tell frameworks how your component should be rendered.

Now the question is, who’s on the hook for (pun intended) SSR? Is it NextJS? Is it the Lit team? What incentive does NextJS have to implement Web Component SSR support if they have a solution that already works well for them? Opening up their SSR story opens them up to having to provide more support if those hooks allow people to break things in unexpected ways.

I know I’m picking on NextJS here, but it’s the only metaframework I have experience with. I’m sure Nuxt / SvelteKit / etc have their own WebComponent SSR issues, but I don’t have firsthand experience to confirm or deny. I also recognize providing hooks opens up a lot of unintended surface area for breakage. SSR is hard!

Author’s note

My beef with SSR today is not even specific to web components. Consider this an author’s note if you will. Most frameworks expect you to be running a Node process, which is fine if your backend is in Node. However, if you’re using Ruby, Elixir, PHP, or any other non-Node backend, it requires running a separate Node process and passing data back and forth. I’d love a world where we could all SSR components in our backend of choice without needing to run a separate process. This is a pipe dream, but hopefully one day we can get there, just like we can do with plain ole’ HTML today.

Conclusion

Phew! We made it through!

Yes. This should have been 2 blog posts. But I only wanted to write 1. Besides, its what the Remote Ruby crew talked about!

As a web component author, Tailwind is usable with web components, but it’s not as great as it could be. Each approach comes with it’s own set of tradeoffs.

As a web component consumer, Tailwind really doesn’t change much. The clunkiest part (pun intended) is around parts. But I believe an extension, or support in “Tailwind Core” could smooth over the rough edge.

Web Component SSR is possible today. Web Component SSR with shadow dom is almost there out of the box today. If you’re okay with a polyfill, it could be considered “there”.

Some examples of web component libraries that support SSR today:

I don’t know how to end this, but if you liked or enjoyed this post, please let me know. This took a ton of time to write, and I tried to be as unbiased and educational as possible.