The idea of headless components based on web components is great, but it is not easy to implement such a component system, at least from what I have tried.
First I want to make it clear that Web Component is a set of different technologies which you can use altogether or separately. It consists of three different web apis: custom element, shadow DOM, template and slot
- Custom element allows you to define your own HTML tags and associated lifecycle callbacks for the HTML element
- Shadow DOM allows you to create an isolated DOM environment separated from the normal DOM (also called the ‘light DOM’). A shadow DOM tree is usually created and attached to a custom element when the element is mounted. Styles in light DOM won’t leak into the shadow DOM environment and also you cannot use normal styling method (like using CSS selectors targeting a specific class name) won’t apply to the shadow DOM tree content.
- Slot API is only available inside a shadow DOM context. It allows you to ‘slot’ light DOM content into the shadow DOM tree.
Since they are three different APIs, you can use them combined or just use one of them in your web component .
And here I want to list some major problems I had when I tried to implement a headless and accessible UI component library using web components for Phoenix LiveView.
Styling issues with Shadow DOM
What @Sebb asked is actually a good question about how styling web components works.
Let’s first think about how to implement a custom button with web component. In most of these JS UI component libs, take Bits UI as an example, the <Button.Root>Click Me</Button.Root>
component renders just a single button element, the rendered html would look like <button>Click Me</button>
.
But below is how it usually implemented in most web component UI libs (like Sholace or Carbon), the actual rendered html would look like:
<my-button>
#shadow-root
<button>Click Me</button>
</my-button>
here is how it looks like inside Chrome DevTools
The difference is that your actual button element now is being wrapped inside a custom element’s shadow root. You cannot use normal CSS selectors to target the button element, so the following CSS would not work:
my-button button {
color: red;
}
Also, if you’re using tailwindcss to set a bunch of class names on the custom element like <my-button class="text-blue"></my-button>
, you’re just setting styles for the wrapper element, not on the actual button element inside the shadow root. It won’t work either if you add tailwind class names directly to the button element, because all your tailwind classes live on the light DOM style, and they won’t affect the shadow DOM styles.
Here’s an example and you can check the demo online here:
The paragraph element and the button element are both inside the shadow DOM, but only the paragraph text color is affected by the
text-blue
class. That’s because the paragraph inherits color styles from the parent element, but the button element has default text color set by the browser user agent.
So if you want to style an element inside a shadow DOM, you’ll have to use part
attribute and ::part()
CSS selector, so that you can target an element inside shadow DOM. This is what Showlace used to allow some component customization, check the Shoelace button customization example.
Other alternatives include using <slot>
to slot light DOM element inside shadow DOM, so you can styling the slotted element. Or you can just ditch shadow DOM and just use custom element API, but you won’t be able to use the slot API.
You may ask, what if we just remove the extra <button>
element wrapper inside the shadow DOM, and use our custom element as the button container, just like:
<my-button>
#shadow-root
<p>Click Me</p>
</my-button>
Well, then you have to take care of accessibility issues with your own custom button element, since <button>
element has default screen reader support and focus management provided by the browser. You will need to add a bunch of ARIA-related attributes to your custom element to make it fully accessible.
Not easy to design a composable and flexible component API
One thing that I like about headless UI is that they’re very flexible and can be easily composed, but it’s hard to acheive a similar component API design with just web compoennts.
Let’s take the AlertDialog component form Bits UI as an example,
<AlertDialog.Root>
<AlertDialog.Trigger />
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title />
<AlertDialog.Description />
<AlertDialog.Cancel />
<AlertDialog.Action />
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
How would you map it into a web component? You may think, we can just wrap every component part into a web component, then it would be something like:
<alert-dialog-root>
<alert-dialog-trigger></alert-dialog-trigger>
<alert-dialog-overlay></alert-dialog-overlay>
<alert-dialog-content>
<alert-dialog-title></alert-dialog-title>
<alert-dialog-description></alert-dialog-description>
</alert-dialog-content>
</alert-dialog-root>
But it won’t work that easily, most of those headless UI libs heavily relies on Context API from the UI framework to be able to share states for all those component parts.
According to the WAI-ARIA Guide, the element served as the alert dialog container should have a aria-labelledby
attribute associated with the dialog title element id and a aria-describedby
attribute associated with the dialog description element id for accessibility,
We don’t set any of these attributes manually since the UI lib did it for us, and this is achieved by the Svelte Context API, so that the parent and child components can share states. But it’s not available cross different web components, as documented in the Custom elements - Caveats and limitations of Svelte API.
So if you want to achieve a similar component API design for web component, it may require some manual JS-side work.
Client-Side DOM changes and Server-Side DOM changes
This what I have talked about in this post: Help me choose between LiveView and Vue - #54 by TunkShif
Let’s take the Switch component form Bits UI as an example now, for accessibility, when the state of a switch component changes, the associated aria-checked
attribute in the switch element should also be changed respectively.
If this DOM change is controlled by JS and happens on the client side, then a following LiveView DOM patch will just overwrite the DOM change, making it return back to the original HTML template sent from the server.
Other examples like tooltip, hover card and popup, these components also involves client side DOM changes, you hover on something, and the associated popup element is set to show, and this is controlled by the JS side. You’ll have to use something like LiveView hooks to sync the client side DOM changes with server side chanegs.
So generally speaking, I don’t think web component is a good choice to build lower primitives for headless component that requires flexibility, since it is initially proposed to create element with functionality and styling completely encapsulated.
From my point of view, web component is good for these two cases:
- Build framework-agnostic design systems. Shoelace is the best example, and it provides the most customizable component designs.
- Encapsulate a single functionality with web component. Like the lit-google-map, emoji-picker-element, giscus-component.
And finally, in my opinion, the biggest obstacle to build an accessible component library for LiveView is that, to build components with rich interactions and full accessibility like keyboard navigation, focus management, typeahead support and popup anchor position, and also taking browser differences into consideration, JavaScript is unavoidable. But once we’re managing DOM states from the client side, it finally falls into fighting with synchronizing client-side states with LiveView DOM patch.
This is a really long thread, and I just want to share some of my own experience with Web Components and LiveView. Thanks for your patience if you’re watching to the end.