Web Components and You (part 7): Let's talk about Shadow DOM mode
Published 2024-04-14, About 11 minute read.
When you create a shadow root for a web component you have a choice that must
make: should it be in "open" or "closed" mode? The MDN docs
aren't very explicit about this, but you have to either include "open" or closed" or you'll get an error.
The above snippet would error if you tried attaching a shadow root without the
mode option.
So which should you chose?
I argue that offering the shadow DOM was not a great decision. My thesis in
this post is that we should be using the open Shadow DOM. The closed Shadow
DOM does a poor job of enforcing privacy and creates unecessary problems for
consumers and accessibility. To outline why, let's look at:
-
What are the differences and what is the benefit of a closed shadow DOM?
- How does this affect consumers?
- How does this affect accessibility?
- Does closed shadow DOM actually provide encapsulation and privacy?
What are the differences and what is the benefit of a closed shadow DOM?
📎
We've covered the shadow DOM
quite a bit
in this series, but we've always used the "open" mode without mentioning why.
Basically, if someone were to query your element, they would be able to reach
into the shadow root and traverse and manipulate the shadow root just like
they could query and manipulate anything else in the DOM. Only in an open
shadow root you would have to access the shadow DOM portion through
myelement.shadowRoot.
So, for example, if you wanted to programatically change something about a
button in an open shadow DOM this is no problem. Query the custom element,
reach into the shadow DOM and manupulate the button
This is not so in the closed shadow DOM. When you try to access the
shadowRoot property of the element, you'll receive null.
What's really interesting is that
not even the custom element host itself gets access to the shadowRoot
property internally. In order to make sure the class continues to have a
reference to the shadow root, it needs to store the created shadow root as a
property on the class. You could store it under a prefixed value like
_shadowRoot, but technically anyone could then still query your
component and access that property. So the only way for the component to
maintain access to the created shadow root is to save a reference of the
shadow root to a private class property like #shadowRoot
Here's an example of what this would look like with vanilla JS
You can choose to expose the shadow DOM with other methods. In the example
above, if you query the element in the console and then run
someMethod(), you can actually get a reference to the shadow DOM
and interact with it. In this way, you get to choose what your consumer can
do.
But, this is very unappealing to me. In doing this, not only am I unecessarily
constraining the way that I access my own shadow DOM, but I'm also depending
on private class properties. For
some
very good
reasons
I'm wary of using private properties and methods.
Still, to see what this is like in Lit, take a gander at this example:
This creates issues. Yes, the shadow root is now closed... but how do you
access it inside the Lit class instance itself? You use
this.renderRoot which
for reasons is publicly available.
Yes, you read that right. For Lit to work well, access to the shadow root is
made publicly available, closed or open. If you read the linked issue, this is
for many good reasons- decorators depend on renderRoot to be able to function
without a lot of overhead, and certain lifecycle methods depend on renderRoot
as well. There are ways to override these behaviors, but it's hard to justify
that the juice is worth the squeeze.
Let's talk about consumers next
Consumers and the closed shadow root. 📎
One thing to consider that
we touched a bit on in my last post
was that sometimes you may need to inspect the contents of slot to find
elements you need. For our example, we looked at
assignedElements() to get slot contents.
In shoelace, the shoelace drop menu expects that you provide a clickable
element that
it seeks out and attaches necessary aria attributes and event handlers.
I actually think this is the best way to go. Sure, it's a pain to search
through slot contents because it could be simple markup, or it could be a
complex tree of nested shadow roots. But if I were to give a button to a
component in it's trigger slot, I expect it to wire up the
necessary parts for me.
But then consider if the consumer wants to provide a button in a closed shadow
DOM. In Shoelace's case, you can't inspect the closed shadow DOM's tree, and
you won't be able to determine what to wire up.
Here's an example where a simple closed shadow root button could break
shoelace:
Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint
cillum sint consectetur cupidatat.
Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum
sint consectetur cupidatat.
Accessibility is broken :(
If you click the triggers, you will see that the dropdown will open for each.
Everything's working great, right?
Well, not exactly. Shoelace was able to notice the trigger click not because
it went into the closed shadow DOM and found the button, but because the click
event listener is on the slot element. Comparing the two buttons, the
aria attributes are missing in the closed shadow root. Aria attributes are
broken with a closed shadow root. So while this is as graceful as possible for
shoelace, the closed shadow root inhibits shoelace from adding proper aria
attributes.
Here is the DOM with a regular (no shadow root) button element:
Here is the DOM with a closed shadow root button element. Notice the aria
attributes could not be attached:
Again, accessibility is an evolving area of web components,
so perhaps we'll be able to cover issues like these. However, the goal of the
closed shadow DOM is to keep access to internals restricted by default. To me
this is directly opposed to allowing programatic inspection, so it's a risk
that these sorts of bugs will come along that don't have a great workaround.
All this to say, well-known libraries expect to be able to traverse DOM
contents to provide accessibility and functionality out of the box. They
expect that any web components will not be closed. They also generally do a
great job of only messing with internals that are necessary and they're very
careful about doing so. Consumers of libraries necessarily will break many
established libraries if their own libraries are closed by default.
There are more issues with accessibility... 📎
One of the touchstones to good accessbility with dropdowns, modals, and
dialogs is that each should trap focus within the component. What this often
requires is that the developer scan through all the elements inside the
component and when someone would normally tab out of the content area and
continue on with the page they are instead re-focused back to the first
tabbable element in the component.
Basically, if someone were to be navigating through content in a modal with a
keyboard, the modal that's open should be the only content they can navigate
through until that modal is dismissed.
So what's the big deal? A web component can do that, right?
The short answer is yes: there's nothing really "new" about how web components
interact with the DOM. We've been making focus traps since the practice
evolved. However, the existence of a shadow root complicates finding all
tabbable elements
Getting all tabbable elements... recursively! 📎
We used to be able to make a simple query selector all call and get what we
needed inside of a component:
The problem now is that we need to recursively search through any shadow
roots, and any shadow roots inside those shadow roots, and so on and so on.
This means that we need to implement a search that resembles this...
So, basically recursively find all the nested shadow roots in the page, and
then find all tabbable elements in all those shadow roots. This
should produce a depth-first sequential array of tabbable elements.
Again, doable, but not as nice 😅 There's just one problem.
Closed shadow DOMs won't let you do this generally 📎
This is the same issue what we saw regarding accessibility, aria attributes,
and DOM inspection in the previous section. This may cause issues, especially
if the element is the first or last item in a focus trap. Here's an example
using the shoelace dialog component, which searches for tabbable elements in
much the same way as above. Try opening the dialog here and tabbing through
the elements
Here is some content
The dialog is unaware of me!
The problem is that shoelace's dialog element is unaware of the web component.
This is out of sync with a screen reader, which will recognize the web
component as you read through the dialog contents. This issue can be
alleviated with manually placing
tabindex="0" on all of your components that should gain focus,
but it doesn't make much sense moving through the document with screen reader,
and it's extra work to maintain and include.
There may be some workarounds for this. For some libraries, like
focus-trap/tabbable, they
try to solve the issue by
accepting a function which returns a reference to the closed shadow DOM, which might help as a workaround. But this only works if you're expecting
it.
Some good further reading:
-
Managing focus in the shadow dom
-
Dialogs and shadow DOM: can we make it accessible?
Yes, it's a lot of work to close the shadow DOM but we get privacy, right?
📎
Well, not really.
In the case of Lit, the framework exposes the shadow root, open or closed.
To get around this requires a lot of boilerplate and extra work, and even then
many things will not work out of the box for things expecting the
shadowRoot to be available (like Lit decorators
@query(), etc.)
Even if you're willing to put up with all this, there are easy ways to bypass
and crack open the closed shadow DOM. Just take a look at this snippet, and
inspect the shadow dom in the example DOM:
Wait... is it closed?
That's right... you can modify the Element prototype and basically crack open
any web component on the page.
"But wait!" you might say, "That's bad manners! That's like modifying a
property or a method with an underscore prefix! Isn't a prototype injection
like this a mortal sin?" Yes, but the closed shadow DOM is so hard to work
with I can easily see people doing this. The reality is that people can and
will take advantage of this. The fact that this is so easy to do, in my mind,
convinced me that trying to
enforce privacy or encapsulation through a closed shadow DOM
is the wrong approach. Even using a closed shadow DOM as a deterrent to me
makes your life so annoying to develop and is so easily cracked that it just
doesn't make sense.
Consider also that if you look at any of the major web component libraries out
there, I know of none that use the closed shadow DOM. Popularity doesn't mean
"better" across the board, but it usually is an indicator of the right path to
take, especially among well-designed libraries.
I would offer that strong documentation, allowing flexibility by offering a
robust API, and abstracting at the right level will all stop people from
trying to mess with your internals. And, frankly, if people mess with the
internals of your component, and things break for the consumer, it's their
fault.
To summarize: 📎
It's very useful to know about the closed shadow DOM. I think at one point it
was trying to provide something in theory that is not bad at all. It turns out
in practice that closing the shadow DOM doesn't achieve what was intended.
Here are the main reasons:
-
A closed shadow DOM is easily cracked and doesn't provide real encapsulation
-
It's not possible to introspect a closed shadow DOM without contortions,
boilerplate, and other workarounds that makes developer experience
unecessarily painful
-
The philosophy of a closed shadow DOM works directly against common
accessibility patterns and requirements
What do you think? Is there something I missed? Am I just plain wrong? Find me on Bluesky or Mastodon. I also have an RSS feed here