A Future for Web Components Without Shadow DOM

Published: 05 October 2024

There’s been a lot of buzz around Web Components (WCs) lately, and honestly, I’ve felt the pain myself. It seems like every time we hit a snag, we just keep adding more APIs to the platform instead of stepping back and rethinking what went wrong in the first place.

I’ve tried adjusting my expectations to work with WCs, but there are still missing pieces. And, to be fair, I don't have all the answers yet to dive headfirst into a Working Group proposal. So, here I am.

Having co-created Radix UI Primitives (with over 8 million weekly downloads), I have some ideas on how components can be built and consumed. In this post, I’m sharing where I see both the potential and the gaps. Are the APIs to blame, or are our expectations simply misguided?

The Shadow DOM Dilemma

Okay, let’s get straight to it and address the elephant in the room—the shadow DOM. It’s the feature most tutorials start with and also the one that tends to spark the most debate. The promise sounds great, but in practice? It doesn’t always live up to the hype. We’re constantly creating new APIs to try and make it suit our expectations.

Personally, I’ve learned to expect less from the shadow DOM. After trying to fit a square peg into a round hole, I realised it just doesn’t align with how I like to build components. I prefer components to be like LEGO bricks: small parts that the user can piece together themselves—kind of like how you build a <table> with <tr> and <td>. The shadow DOM, however, encapsulates a component's structure and styles, which can limit external control.

Yes, it’s useful for highly scoped, limited-control things like ad widgets, but for design systems? Not so much. It's exactly the reason people have built their own custom select components for so many years—we want control.

New APIs and proposals like exposing shadow DOM elements, Declarative Shadow DOM, ::part(), delegatesFocus, and ElementInternals try to ease the friction, but I’d rather not force my consumers to learn a whole new set of tools just to style things, or spend my days reinventing the wheel.

And while I’ve discovered some cool use cases for shadow DOM—like an <affordance /> component that can switch between an accordion and a tabbed UI without losing state (think play state in a video), it still comes with the aformentioned trade-offs. If only shadow DOM had a truly “open” mode… maybe someday!

But here’s where we might’ve gone astray: We’ve been treating shadow DOM as the way to reuse HTML, when maybe it was never meant to be that silver bullet.

Custom Elements Without Shadow DOM

When I realised shadow DOM didn’t suit me, I explored Custom Elements (CEs) without it. And hey, there’s potential here.

With CEs, you can inject whatever HTML you want into your component using JavaScript. You can wrap the consumer’s content or replace it entirely—it’s up to you. I like this approach because as mentioned, I want to make components that work like native HTML elements like <table>, <tr>, and <td>. There really is no need for <slot>s here.

But it’s not all sunshine and rainbows:

  1. The consumer’s light DOM won’t behave properly in things like flex containers, so display: contents and subgrids are needed. Unfortunately, display: contents isn't widely supported yet and breaks accessibility.
  2. If I inject markup dynamically, it won’t be available on the server side.
  3. I have concerns about the HTML outline getting obliterated when design systems require components like <my-heading>.

So where do we go from here?

Built-in Elements

Custom Elements don’t have to be completely custom tags. There’s a lesser-known API called built-ins, where you can attach behaviors to existing HTML elements, like this:

<button is="menu-trigger">View</button>

This addresses a lot of my concerns. The user controls the markup, so layout, server-side rendering, and HTML outline issues disappear. But… (there’s always a but):

  1. We can’t chain behaviors in the is attribute, so we can’t make the button both a menu-trigger and a tooltip-trigger.
  2. A built-in can only extend one element type, so we can't make a tooltip-trigger that can be attached to any element.
  3. No Safari support, and they have no plans to support it.

While there is a polyfill for Safari, we'll need the Custom Attributes proposal to solve the others, but we’re not there yet.

I tried to come up with workarounds, like subclass factories, but it got messy real quick. Then I found Matthew Phillips' custom-attributes package which I can use in the meantime.

Learning From My Mistakes

Looking back, I realise I was searching for a one-size-fits-all solution when that just doesn’t exist. We can mix and match approaches! The APIs still need work, sure, but we’re making progress. Here’s something that could work without shadow DOM (because not everything’s an ad):

<h2 acme-heading>Menu component</h2>

<acme-menu>
  <acme-tooltip>
    <button acme-menu-trigger acme-tooltip-trigger>Open</button>
    <div acme-tooltip-content>Click me</div>
  </acme-tooltip>
  <div acme-menu-content>
    <div acme-menu-item>Item One</div>
    <a href="#" acme-menu-item>Item Two</a>
  </div>
</acme-menu>

I’m using custom elements as placeholders for sharing state—kind of like React Context providers (fragments)—so they don’t mess with the HTML outline (once display: contents gets sorted, of course). I’m keeping things semantic with attributes instead. It’s a pretty nice middle ground, but portability is still a bit of a pain point.

For the longest time, I resisted this approach because of portability issues. I thought a component library needed to tightly control what markup gets rendered (hello, shadow DOM) so users wouldn’t have to deal with all the nitty-gritty themselves.

But now? I realise how wrong I was. I was trying to shoehorn the framework components mental model into the platform, and that's not really the intention here.

The various WC APIs are simply different ways to attach behaviours and styles to elements, and if we take inspiration from Shadcn UI—people love copy/paste code. It turns out that all we really need to do is document snippets and maybe throw in a handy CLI script to help with portability.

There’s just one more thing though...

The Missing Link in Frameworks

One of my biggest gripes with WCs is the observedAttributes API. When I started exploring WCs, it led me to assume the goal was to build components like this:

<my-button variant="large"></my-button>

Then frameworks could pass props as attributes:

<my-button variant={props.variant}></my-button>

Do you see the problem here? I recently read a post from Lea Verou where she highlights how Web Components still need frameworks, and honestly, she’s spot on. Not only do we need them to help with portability, but this whole approach falls apart as soon as you need to pass anything that isn’t a string.

That makes total sense. Attributes can only be strings, that's why React calls them props and not attributes! We need to pass data as properties if we want to receive types like numbers in WCs. So, I have a hard time buying it when people claim WCs are framework-agnostic.

To make this work, we need wrappers for every framework that pass their properties correctly.

// pseudocode example for React
const LIB_PROPS = Symbol.for('lib.props');
const Button = (props) => {
  const ref = React.useRef(null);

  React.useEffect(() => {
    if (!ref.current) return;
    for (const [key, value] of Object.entries(props)) {
      ref.current[LIB_PROPS] ??= {};
      ref.current[LIB_PROPS][key] = value;
    }
  }, [props]);

  return <button acme-button ref={ref}>{props.children}</button>;
};

But honestly, I’m okay with that. We’d only need to write a framework wrapper for each framework, not rewrite the whole component library for each one.

It’s not perfect, but it’s a step forward. Attributes could be used for setting up some default state only (like the open attribute on dialog) and then property setters would be used for reactivity—no observedAttributes.

The Custom Attributes proposal hints at a solution to this with parse() but tbh, I'm not sure if I am interpreting that correctly. If I am, that could help avoid these wrappers but I'm honestly not convinced I'd want all my properties in the markup in the first place.

Conclusion—We’re Getting There!

The state of Web Components is still a bit patchy, but we’re heading in a good direction. Sure, there are multiple ways and evolving APIs to tackle components today, but everyone approaches component composition differently. The progress we’re making lets us mix and match different APIs to fit our needs. Most importantly, Shadow DOM isn’t the be-all and end-all, and we can build components without it.

© 2024