Web Components and You (part 6): Slots and how to use them
Published 2024-04-01, About 8 minute read.
It's time to talk about the real workhorse of the shadow DOM: slots! Slots enable you to accept markup from the consumer. The elements are styled as if they're in the light DOM, but they're placed where you determine in your web component. This is a pretty well-known patter in frameworks like Ember, Svelte, Vue, and others.
The simplest example: the default slot
The default slot is is whatever children markup elements you put inside your web component.
Anything in here is being placed into the "default" slot
It can be anything! And it doesn't have to be a single parent nodeHere's an example where the content is being placed in a particular spot in the shadow DOM (in this case it's being put inside the div):
Here is text being passed into the default slot!
And if you were to inspect the DOM, you can see that the element is rendering in the "light dom" portion of the component outside of the shadow DOM. Yet there is a link between the slot and the revealed content:
You're probably already used to this sort of thing. This is what happens in textarea
tags. If you place text inside those tags, that pre-populates the text value to the element
But this is a bit more powerful. The content lives and breaths! Meaning that if you make changes to the content in the slot, the slot contents reflect those changes. Here's an example that hopefully shows how the placed content can still be changed:
Here is text being passed into the default slot!
Okay, I understand if that's not mind blowing. But this is very helpful when we want to let consumers provide their own markup. We don't have to worry if this custom element is being used in a framework, because even when the framework re-renders or updates the contents in the slot, it will automagically be reflected in the custom element.
An example of augmenting the provided slot contents
Case study time! Let's say you're creating an accordion of sorts, and you want the consumer to be able to provide the content and the look of the button that will expand the content? There are two considerations here. First, we need to be able to specify where slots go when there are multiple slots. Second, we need to be able to attach the click action to that consumer-provided element.
First: multiple slots
This is the easier part. You can actually specify a name attribute for slots. When you do, you can then tag the element you want to be reflected into that slot. For example:
This element (the p tag) will go into the slot with name "second-slot"
Examples are worth a thousand words:
One really cool thing about slots is that you don't necessarilly have to have a custom element defined with a class to use slots. Since slots are a feature of a shadow DOM you can actually create a declarative shadow DOM and place content in slots without a class!
First slot ➡️
Second slot ➡️
Default slot ➡️
Second: Attaching functionality to items in the slot
Okay, so we can create an accordion sketch here. We'll use named slots to specify where the content should be rendered. Just for this example, we will accept basically anything for the "content" slot, but we will expect the consumer to know they must provide a clickable element like a button to toggle the accordion open and closed.
This could be anything in the accordion content part!
In this first part the actual click event listener is on the slot
element itself, and this is not going to fly for accessability. Tabbing through the DOM you would end up with focus on the button and the click event would bubble up to the slot. It's not accurate or ideal for accessibility reasons: one being that if you were to handle whether the accordion is expanded with aria-expanded
attribute you would want it to be on the focused element- the button
So let's try that again, but let's attach the attribute and the event listener on the button instead. This will require digging into the shadow DOM and the slot!
This could be anything in the accordion content part!
This is not perfect but much better! Let's break it down:
-
In
firstUpdated()
(when the dom is first created and rendered) we look into the slot using theassignedNodes()
method on the slot in the shadow DOM. This is the only way to access slotted content by traversing through a slot element. This method allows us to inspect elements placed in the slot. We attach the click handler to the button itself, and set the aria attributearia-expanded
. -
We then make sure that the
toggle()
method appropriately changes the state ofexpanded
and updates the aria attribute correctly. -
We make sure to clean up after ourself and remove the event listener in the
disconnectedCallback()
method.
Now this example is a teensy bit contrived because it actually can be simplified in one way: instead of reaching into the slot and using the assignedElements()
method, we can just adjust the getter to query the host element. Remember that elements are rendered into the light DOM and then reflected into place in the shadow DOM? Well, why don't we just edit the light DOM because it's easier to access?
This could be anything in the accordion content part!
But, there's an alternative to this dom drilling
We can leverage custom events instead of attaching handlers.
If you don't like the feeling of modifying the elements that the consumer gives you (in this case, the trigger button,) you can also just make the component expect certain events. In this next example, we'll let the consumer emit a custom event "please-expand"
and the accordion will react to that:
This could be anything in the accordion content part!
I'm not sure that's any better. The downside to this is that you're expecting the consumer to handle aria attribute changes along with instructing the accordion to open, but maybe that's okay? In general you would expect a component to just take care of state and aria attributes, so this seems like the worse of the two options. But it is an option!
Whew. Let's zoom out about and talk about CSS
How about we chat about styling contents that were added into a slot?
Let's say that you want to create a list of things. For some reason, you want to add some spacing between all the elements in the slot. There's a selector for that!
Alright, so let's say that you accept any number of elements in your default slot. But! You want all those elements to have background blue and be spaced with an extra line-space between them. You could do this:
Thing 1
example!
Note that ::slotted()
selects any direct child element that is in a slot. If you want to specify which slot to work with, you can use the [name="..."]
as a selector inside the slotted function parens. That is how the last slotted element turned orange in the above example.
One last item: watching change events in the slot
There may be a time when you need to watch for changes inside a slot, and react to those changes. There's an event for that: 'slotchange'. This event will always run once when the component is connected and the slot contents are filled the first time.
One important thing to note: this event will only be emitted if the direct child nodes of the slot change or are added or subtracted. Any children or other descendants of the slotted node will not trigger this event. Check out the console after adding and removing nodes in the next playground.
Test
I think this is mostly so you can attach handlers and cleanup after yourself if you're programatically doing things to elements in the slots. The annoying thing here is that text change or descendent nodes changing do not trigger this event. If you really need control of mutations in the DOM at a finer level, you can use MutationObserver
That about does it
Slots are very powerful and allow you to compose elements into your web components. It allows you to give some control to your consumer. However, it is still a bit awkward to give functionality to components in slots.
What do you think? Is there a pattern that's really nice that I didn't cover? Let me know in the comments or on bluesky!