Web Components in Astro
I’ve recently been jumping into learning more about Astro, using a personal project that needs to be replatformed as an excuse to give it a try. The first thing I wanted to dive into and learn more about was how components are handled, and especially how well it plays with browser native Web Components.
I burned a lot of time here just trying to understand how a web component could be copied into an Astro project. So instead of waiting to blog about the whole process, I wanted to share my learnings about components separately first.
First Attempt – AstroHeart#anchor
My first attempt was to get the AstroHeart
example in the Web components with custom elements section of the Scripts and Event Handling documentation to work.
I got it working when the whole code block was placed in a layout or a page, but not when it was a referenced component from the /components/
directory. I think we can all agree that adding web components to a page or layout would be categorized as a “Bad Idea”.
After much fiddling I came to the conclusion that an Astro component that then instantiates a native web component is the best I could manage. It feels weird, but it works.
Second Web Component Attempt – Sidebar#anchor
Now that I know it’s possible, I wanted to ramp things up with an actually usable — and slightly more complicated — example. The first web component I needed to port over for my project happened to be the excellent Sidebar from the Every Layout project.
A couple new issues emerged in this process of adding it, here’s how I got passed them both.
Passing Props#anchor
Welp, the Every Layout Sidebar has five separate attributes that can be added to the <sidebar-l>
custom element and I quickly realized I need to manage that prop handshake between Astro and the native web component.
First, I went with the most obvious solution of gathering all props and manually passing them in, something like the following:
---
const { side, sideWidth, contentMin, space, noStretch } = Astro.props;
---
<sidebar-l
side={side}
sideWidth={sideWidth}
contentMin={contentMin}
space={space}
noStretch={noStretch}
>
<slot />
</sidebar-l>
But that so verbose and error prone, there’s gotta be a better way right? Turns out, there is! Replace that code above with this and you’re all set.
<sidebar-l {...Astro.props}>
<slot />
</sidebar-l>
Still a little weird but much better, right? Now if names change or more props are added I don’t have to keep going back to update this.
Scoped vs Global Styles#anchor
When styles are added to the Astro component using <style>
tags, they’re automatically scoped. In some cases, as is the case for this Sidebar component with several modification options, this can be less desirable.
I found this out first hand when I was attempting to adjust the spacing between the sidebar and the main content area using <Sidebar space="var(--space-m)">
. I came to realize that the default styles were of a higher specificity than the modified styles applied via that space
attribute.
In cases like this, drop a is:global
into the opening style tag like this, <style is:global>
and it’ll be treated as a global style where the cascade for modifications will work.
I suppose you could also move the component styles to where the rest of your global styles are located, but you lose that encapsulation of everything being in one place.
Live example#anchor
I’ve worked up another Stackblitz for this Sidebar component example too.
Third Attempt – Auto-import Multiple Components#anchor
I still felt a bit odd with wrapping web components inside Astro components, even though I was able to overcome any blockers that emerged. I think it’s because having to manually Astro-ify every component I wanted to use feels like it goes against the portability of native web components.
So, I tried again. #anchor
With a bit more encouragement from Daniel Saunders, who reminded me that all it really takes is an import of the web component somewhere in the page, I set my sights on trying that.
As a quick proof of concept, I grabbed another of the free Every Layout components – the Stack – and built up a minimally viable page with the following. The references to web-components
is because I wanted another separate directory under src
to keep Astro components separate from web components.
<stack-l space="3rem">
<h2>H2 headline</h2>
<stack-l space="1.5rem">
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quasi aperiam,
cupiditate qui totam incidunt ipsum dolores.
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quasi aperiam,
cupiditate qui totam incidunt ipsum dolores.
</p>
</stack-l>
<h2>H2 headline</h2>
</stack-l>
<script>
import '../web-components/Stack/Stack.js';
import '../web-components/Stack/Stack.css';
</script>
Sure enough, that worked just fine!
Automating imports#anchor
But, adding a script tag with imports of the (potentially double digit) JS and CSS web component files I’d need to use on every page doesn’t sound like fun to me. Instead, I set my sights on auto-importing based on a glob pattern.
Enter Astro.glob() as that sounds like just what I need. I realized that what I was doing wasn’t going to work though, because I was attempting the import in the front matter which meant this was trying to import on the server side where HTMLElement
isn’t defined.
Remembering that it needs to be in a script tag within the content of the page rather than in the front matter got me away from that error, but it still didn’t work.
I read up on what Astro.glob()
is doing, it’s a wrapper for Vite’s Glob Import (import.meta.glob
). That’s set up by default to lazy load which meant that it wasn’t going to end up on the page because I wasn’t using it on the server side. From the docs:
Matched files are by default lazy-loaded via dynamic import and will be split into separate chunks during build. If you’d rather import all the modules directly (e.g. relying on side-effects in these modules to be applied first), you can pass
{ eager: true }
as the second argument
Once I did just that, I got it working!
Auto-importing at the Layout Level#anchor
One more change now that I had something functional, instead of doing this import on the page I moved it to the bottom of my global layout file.
Now, I have access to all web components on all pages that use this layout. Here’s the final code for importing everything, placed in my layout:
<script>
const webComponentsCSS = await import.meta.glob(
'../web-components/**/*.css',
{ eager: true }
);
const webComponentsJS = await import.meta.glob(
'../web-components/**/*.js',
{ eager: true }
);
</script>
I added a second web component to my setup to make sure it was importing both, and sure enough it did! Rather than pasting all that code here, scroll down to the Live example link below to see it all isolated in another Stackblitz.
Performance Implications#anchor
Because I’m still learning about all this, and the project I have in mind to use it is very small, I’ve not delved too deep into the implications of importing all web components in this way. I did run a production build to see how everything came together, it appears that all components are compiled into one hoisted.js
file.
There’s likely some consideration needed for very large applications, because I’d bet the compiled source wouldn’t be ideal for projects of any real complexity. The first thing I’d try is bundling web components into sub directories and using this glob import pattern in smaller chunks.
Live example#anchor
Once again, I made this a minimally reproducible Stackblitz of two native web components being used on a page after being auto imported in the parent layout.
Conclusion#anchor
That’s where I’m at as of November 2023, I’m pretty happy with the auto-importing method noted in that third attempt above. But I am still very interested in hearing about other ways to do this, and especially about how it affects page performance. If you’ve got other methods to make this work, I’d love to see some examples!
I’ve already updated this post with the third example after some more fiddling, if I figure out or hear of any further updates I’ll be sure to keep this post updated.
I’ve also completed the replatforming of Accessibility Solutions from Nuxt to Astro, which was the impetus behind this deep dive into web components in Astro. If you’re interested in reading more about that experience, check out Nuxt 2 to Astro 3 Replatforming – from Setup to Production.
Need help with an upcoming project?
I'd love to hear more about what you have coming up and how I can help bring it to life! My earliest availability is currently Q1 2025.
Get in Touch