Skip to main content

Migrate from Tailwind CSS to Web Components with Declarative Shadow DOM

My personal website was built with Astro and Tailwind CSS. And, since Declarative Shadow DOM has good browser support (except Firefox as the time of writing), I plan to migrate my site from Tailwind CSS to Web Components with Declarative Shadow DOM.

This site might have been completely migrated from Tailwind CSS to Web Components depending on when you see this post.

I will walk you through how I migrate a single component, and the rest would be repeating the same process.

This is Astro components of skill list on home page. For simplicity, I only show two skills in the codes. I’m actually screaming at myself: “why I didn’t extract out the li element as a standalone Astro component?” Guess I was too lazy — it rarely happened, but hey, human are lazy sometimes — but now it doesn’t matter as it will be replaced with web components soon (already done).

Skills.astro
---
import WebComponents from "@icons/web-components.svg";
import Node from "@icons/node.svg";
---
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-3">
  <li class="flex items-center gap-4">
    <span
      class="w-11 h-11 flex items-center justify-center border-t border-zinc-700 bg-zinc-800 rounded-full"
    >
      <img src={WebComponents} class="w-7" alt="Web Components Logo" />
    </span>
    <p class="text-zinc-400">Web Components</p>
  </li>
  <li class="flex items-center gap-4">
    <span
      class="w-11 h-11 flex items-center justify-center border-t border-zinc-700 bg-zinc-800 rounded-full"
    >
      <img src={Node} class="w-7" alt="Node.js Logo" />
    </span>
    <p class="text-zinc-400">Node.js</p>
  </li>
</ul>

To use Web Components, I creat a WCSkill.astro Astro component (prefix WC is to differ from other standard Astro components) and then import it to be used by above Skills.astro component and relace existing li element with <ASkill> and pass down icon and name props which are needed by this component.

Skills.astro
---
import WebComponents from "@icons/web-components.svg";
import Node from "@icons/node.svg";
import ASkill from "@components/WCSkill.astro";
---
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-3">
 <ASkill icon={WebComponents} name="Web Components" />
 <ASkill icon={Node} name="Node.js" />
</ul>

Next step is to create WCSkill.astro component and copy the li element down to this component. And update markup with attributes passed down from parent component.

WCSkill.astro
---
const { icon, name } = Astro.props;
---
<li class="flex items-center gap-4">
  <span
    class="w-11 h-11 flex items-center justify-center border-t border-zinc-700 bg-zinc-800 rounded-full"
  >
    <img src={icon} class="w-7" alt={name + " Logo"} />
  </span>
  <p class="text-zinc-400">{name}</p>
</li>

Now we can bring in Web Components, I wrap existing HTML with a a-skill custom element. Up till now nothing is broken in frontend as this custom element will be ingored by browser.

WCSkill.astro
---
const { icon, name } = Astro.props;
---
<a-skill>
  <li class="flex items-center gap-4">
    <span
      class="w-11 h-11 flex items-center justify-center border-t border-zinc-700 bg-zinc-800 rounded-full"
    >
      <img src={icon} class="w-7" alt={name + " Logo"} />
    </span>
    <p class="text-zinc-400">{name}</p>
  </li>
</a-skill>

Moving on, we need to wrap li element with a template element with an attribute shadowrootmode="open". A template element with the shadowrootmode attribute is detected by the browser HTML parser and immediately applied as the shadow root of its parent element.

WCSkill.astro
---
const { icon, name } = Astro.props;
---
<a-skill>
  <template shadowrootmode="open">
    <li class="flex items-center gap-4">
      <span
        class="w-11 h-11 flex items-center justify-center border-t border-zinc-700 bg-zinc-800 rounded-full"
      >
        <img src={icon} class="w-7" alt={name + " Logo"} />
      </span>
      <p class="text-zinc-400">{name}</p>
    </li>
  </template>
</a-skill>

Now all styles will be broken as our li element will live inside shadowroot and Tailwind can’t reach inside shadowroot. A style element comes to the rescue. We will add back all the styles, just using a tag selector would be enough.

WCSkill.astro
---
const { icon, name } = Astro.props;
---
<a-skill>
  <template shadowrootmode="open">
    <style>
      /* your component styles here */
    </style>
    <li class="flex items-center gap-4">
      <span
        class="w-11 h-11 flex items-center justify-center border-t border-zinc-700 bg-zinc-800 rounded-full"
      >
        <img src={icon} class="w-7" alt={name + " Logo"} />
      </span>
      <p class="text-zinc-400">{name}</p>
    </li>
  </template>
</a-skill>

Now we can remove the class names on all our elements. W00t, it looks great, doen’t it?

WCSkill.astro
---
const { icon, name } = Astro.props;
---
<a-skill>
  <template shadowrootmode="open">
    <style>
      /* your component styles here */
    </style>
    <li>
      <span>
        <img src={icon} alt={name + " Logo"} />
      </span>
      <p>{name}</p>
    </li>
  </template>
</a-skill>

Below are final codes for this component. I don’t know about you, but this makes me so damn happy!

WCSkill.astro
---
const { icon, name } = Astro.props;
---
<a-skill>
  <template shadowrootmode="open">
    <style>
      * {
        box-sizing: border-box;
      }
      li {
        display: flex;
        gap: 1rem;
        align-items: center;
      }
      p {
        margin: 0;
      }
      span {
        display: flex;
        height: 2.75rem;
        width: 2.75rem;
        align-items: center;
        justify-content: center;
        border-radius: 50%;
        background: hsl(0, 0%, 100%);
        box-shadow: 0 0 20px 5px rgba(0,0,0,0.05);
      }
      img {
        width: 1.75rem;
        display: block;
        vertical-align: middle;
        max-width: 100%;
        height: auto;
      }
    </style>
    <li>
      <span><img src={icon} alt={name + " Logo"} /></span>
      <p>{name}</p>
    </li>
  </template>
</a-skill>