::Slotted CSS Selector For Nested Children in Shadowdom Slot

::slotted CSS selector for nested children in shadowDOM slot

styling ::slotted elements in shadowDOM

TL;DR

  • ::slotted Specs: https://drafts.csswg.org/css-scoping/#slotted-pseudo

  • slotted content remains in light DOM, is reflected to a <slot> in shadow DOM

  • ::slotted(x) targets the lightDOM outer-Element (aka 'skin'), NOT the SLOT in shadowDOM

  • ::slotted(x) takes basic selectors

  • Inheritable styles trickle into shadowDOM

    https://lamplightdev.com/blog/2019/03/26/why-is-my-web-component-inheriting-styles/

  • For the latest WHATWG discussion on SLOT and related topics, see

    • https://github.com/whatwg/html/issues/6051#issuecomment-816971072

      Participants: rniwa (Apple) , annvk (Mozilla), dominic (Google)
    • https://github.com/WICG/webcomponents/issues/934#issuecomment-906063140

background

Yes, ::slotted() not styling nested elements is expected behavior.

The term slotted is counterintuitive,

it implies element lightDOM is moved to shadowDOM

slotted lightDOM is NOT moved, it remains.. hidden.. in lightDOM

the content (IF slotted) is reflected to a <slot></slot>

Or from Google Developer Documentation

, .

' ; .

I use the term reflected instead of render because render implies you can access it in shadowDOM.

You can not, because slotted content isn't in shadowDOM... only reflected from lightDOM.


Why :slotted has limited functionality

More advanced shadowDOM styling was tried.

WebComponents version 0 (v0) had <content> and ::content; but it was removed from the spec:

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/content

The main takeway from the W3C standards discussions

(@hayatoito (Google team) here and here) is:

So in V1 we have :slotted: https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted


Addition #1 : Performance if ::slotted allowed for complex selectors

From Mozilla developer Emilio:

source: https://github.com/w3c/webcomponents/issues/889

The performance issue is that it increments the amount of subtrees in
which every node needs to go look for rules that affect to them.

Right now the logic goes like: if you're slotted, traverse your slots
and collect rules in their shadow trees as needed. This is the code
This is nice because the complexity of styling the element
depends directly on the complexity of the shadow trees that you're
building, and it only affects slotted nodes.

If you want to allow combinators past slotted then every node would
need to look at its ancestor and prev-sibling chain and look at which
ones of them are slotted, then do that process for all their slots.
Then, on top, you also need to change the general selector-matching
code so that selectors that do not contain slotted selectors don't
match if you're not in the right shadow tree.

That's a cost that you pay for all elements, regardless of whether you
use Shadow DOM or ::slotted, and is probably just not going to fly.


So due to performance issues

:slotted( S ) got limited CSS selector functionality:

  • ► it only takes simple selectors for S. --> Basically anything with a space won't work

  • ► it only targets lightDOM 'skin'. --> In other words, only the first level

<my-element>
<h1>Hello World</h1>
<p class=foo>
<span>....</span>
</p>
<p class=bar>
<span>....</span>
</p>
</my-element>
  • ::slotted(h1) and ::slotted(p) works

  • ::slotted(.foo) works

  • ::slotted(span) (or anything deeper) will not work (not a 'skin' element)

Note: ::slotted([Simple Selector]) confirms to Specificity rules,

but (being simple) does not add weight to lightDOM skin selectors, so never gets higher Specificity.

You might need !important in some (rare) use cases.

 <style>
::slotted(H1) {
color: blue !important;
}
<style>

Styling slotted content

Also see: Applying more in depth selection to the :host CSS pseudo class

#1 - style lightDOM

The <span> is hidden in lightDOM, any changes made there will continue to reflect to its slotted representation.

That means you can apply any styling you want with CSS in the main DOM

(or a parent shadowDOM container if you wrapped <my-element> in one)

 <style>
my-element span {
.. any CSS you want
}
<style>

#2 - (workaround) move lightDOM to shadowDOM

If you move lightDOM to shadowDOM with: this.shadowRoot.append(...this.childNodes)

you can do all styling you want in a shadowDOM <style> tag.

Note: You can not use <slot></slot> and :slotted() anymore now.

<slot>s only works with content reflected from lightDOM.

For an example where an element wraps itself in an extra shadowDOM layer,

so no CSS bleeds out, and <slot>s can be used, see:

  • https://jsfiddle.net/WebComponents/5w3o2q4t/?slotmeister

#3 - ::part (shadow Parts)

It is a different/powerful way of styling shadowDOM content:

Apple finally implemented shadowParts in Safari 13.1, March 2020

see:

  • https://meowni.ca/posts/part-theme-explainer/

  • https://css-tricks.com/styling-in-the-shadow-dom-with-css-shadow-parts/

  • https://dev.to/webpadawan/css-shadow-parts-are-coming-mi5

  • https://caniuse.com/mdn-html_global_attributes_exportparts

Note! ::part styles shadowDOM,

<slot></slot> content remains in lightDOM!


references

be aware: might contain v0 documentation!

  • https://css-tricks.com/encapsulating-style-and-structure-with-shadow-dom/

  • https://developers.google.com/web/fundamentals/web-components/shadowdom?hl=en#composition_slot

  • https://polymer-library.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-your-elements

  • https://github.com/w3c/webcomponents/issues/331

  • https://github.com/w3c/webcomponents/issues/745

  • https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/slotchange_event

  • ::part() - https://developer.mozilla.org/en-US/docs/Web/CSS/::part


Example: Using slots as a router

Change the slot-name on buttonclick and reflect content from lightDOM:

<template id=MY-ELEMENT>
<style>
::slotted([slot="Awesome"]){
background:lightgreen
}
</style>
<slot><!-- all unslotted content goes here --></slot>
<slot id=answer name=unanswered></slot>
</template>
<style>/* style all IMGs in lightDOM */
img { max-height: 165px;border:3px dashed green }
img:hover{ border-color:red }
</style>
<my-element><!-- content below is: lightDOM! -->
SLOTs are: <button>Cool</button> <button>Awesome</button> <button>Great</button>
<span slot=unanswered>?</span>
<div slot=Cool> <img src="https://i.imgur.com/VUOujQT.jpg"></div>
<span slot=Awesome><b>SUPER!</b></span>
<div slot=Awesome><img src="https://i.imgur.com/y95Jq5x.jpg"></div>
<div slot=Great> <img src="https://i.imgur.com/gUFZNQH.jpg"></div>
</my-element>
<script>
customElements.define('my-element', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode:'open'})
.append(document.getElementById(this.nodeName).content.cloneNode(true));
this.onclick = (evt) => {
const label = evt.composedPath()[0].innerText; // Cool,Awesome,Great
this.shadowRoot.getElementById("answer").name = label;
}
}
});
</script>

How to style descendants of element that was slotted (in the stylesheet of the shadowroot)?

  • slotted content remains in lightDOM, is reflected to a <slot>
  • ::slotted(*) can only target the lightDOM SKIN with simple selectors

For long answer see: ::slotted CSS selector for nested children in shadowDOM slot

Update: example <div SLOT=Hello> content is not IN shadowDOM

<my-element>
<div slot=Hello>Hello reflected (slotted) lightDOM DIV</div>
</my-element>

<my-element>
<div slot=NoSlot>Hello not slotted lightDOM DIV</div>
</my-element>

<script>
customElements.define('my-element', class extends HTMLElement {
constructor() {
super()
.attachShadow({mode: 'open'})
.innerHTML=`<slot name=Hello><b>I am shadowDOM SLOT content</b></slot>`;
}
connectedCallback() {
console.log(this.shadowRoot.querySelector('slot').innerHTML)
}
});
</script>

Applying selection style to slotted elements?

You can't, because slotted content is NOT moved to shadowDOM, it remains in ligthDOM.

You style slotted content in ligthDOM (in this case the main DOM)

For very detailed answer see: ::slotted CSS selector for nested children in shadowDOM slot

I added extra CSS to show:

  • Using variables (that penetrate shadowDOM) to declare a colors ones

  • Using a #selectable DIV wrapper selects both custom elements

    See for yourself what the x-widget ::selection selector would do

Select all text:
<div id=selectable>
<x-widget>CONTENT1</x-widget>
<x-widget><div>CONTENT2</div></x-widget>
</div>
<style>
body {
--selectionBackground: green; --selectionColor: white;
font-size: 2em;
}
#selectable ::selection {
background: var(--selectionBackground); color: var(--selectionColor);
font-weight: bold;
}
</style>
<script>
window.customElements.define('x-widget', class extends HTMLElement {
constructor() {
const template = document.createElement('template');
template.innerHTML = `<div>TEMPLATE</div><div><slot></slot></div>`;
const style = document.createElement('style');
style.textContent = `
::selection { /* colors only template text, not slot content */
background: var(--selectionBackground);
color: var(--selectionColor);
}
::slotted(*) { /* selectors select HTMLElements! */
color: red; /* CONTENT1 is TEXT, NOT an HTMLElement! */
}`;
super().attachShadow({mode: 'open'})
.append(style, template.content.cloneNode(true));
}});
</script>

CSS: How to target ::slotted siblings in Shadow DOM root?

Sure you can select siblings of slots / slotted.

The thing you can not do is select a element which has been slotted and is not a top-level node.

Select siblings:

slot[name=<slotname>] ~ <selector>

Select slotted top-level node

::slotted(<compound-selector>)

A compound-selector contains a tag/class/id/name etc. but must not have any combinators. Like <space> for example.

.myClass OK

<anyTag>[<anyAttribute>[=<anyValue>]] OK

.<myClass> > .<anotherClass> NO

Examples

var element = document.querySelector('.templateMe');
var shadow = element.attachShadow({mode: 'open'});
var template = document.querySelector('.myTemplate');
shadow.appendChild(template.content.cloneNode(true));
<template class="myTemplate">
<style type="text/css">
::slotted([slot=slot1]) { /* slot1 every slotted element - YES */
color: red;
}

slot[name=slot1] { /* slot1 itself - YES */
text-decoration: underline;
}

slot[name=slot1] + .siblingA { /* slot1 siblingA (direct sibling) - YES */
color: green;
}

slot[name=slot1] ~ .siblingB { /* slot1 siblingB (any sibling) - YES */
color: orange;
}

slot[name=slot2]::slotted(.selectMeA) { /* slot2 TOP-LEVEL CHILD (slotted) - YES */
color: purple;
}

slot[name=slot2]::slotted(.selectMeB) { /* slot2 NOT TOP-LEVEL CHILD - NO */
font-weight: bold;
}

slot[name=slot2]::slotted(.selectMeC[name=myName]) { /* slot2 TOP-LEVEL CHILD (slotted) - YES */
color: khaki;
}

slot[name=slot2] + .siblingC { /* slot2 sibling - YES */
color: blue;
}

</style>
<div>
<slot name="slot1"></slot>
<div class="siblingA">Sibling A of Slot 1</div>
<div class="siblingB">Sibling B of Slot 1</div>
</div>
<hr/>
<div>
<slot name="slot2"></slot>
<div class="siblingC">Sibling C of Slot 2</div>
</div>
</template>

<div class='templateMe'>
<span slot="slot1">Im in Solt 1</span>
<span slot="slot2" class="selectMeA">
Im in Solt 2, im selectable.
<div class='selectMeB'>
NOT selectable (because no top level node of slotted)!
</div>
</span>
<span slot="slot2" class="selectMeC" name="myName">Im in Solt 2 too and selectable!</span>
</div>

Style nested web component where child can be same type as, but needs different styles to, parent

Took some time to fully understand what you want (and I could be wrong)

  • You want to specify the margin-top for all CHILDREN (except the first child)

    with: <vertical-layout childmargin="2em">
  • For nested <vertical-layout> the element should have the margin-top of its PARENT container

Problem with your: <vertical-layout style="--spacing-size: 2em">, is that the 2em is set on the <vertical-layout> itself (and all its children)

You want it applied to children only

You can't do that with CSS in shadowDOM; because that doesn't style slotted content.

See: ::slotted CSS selector for nested children in shadowDOM slot


I have changed your HTML and attributes to reflect the margins you want:

(px notation for better comprehension)

    0px <vertical-layout id="Level1" childmargin="15px">
15px <div>child1-1</div>
15px <div>child1-2</div>
15px <div>child1-3</div>
15px <vertical-layout id="Level2" childmargin="10px">
0px <div>child2-1</div>
10px <div>child2-2</div>
10px <vertical-layout id="Level3" childmargin="5px">
5px <div>child3-1</div>
5px <div>child3-2</div>
5px <div>child3-3</div>
</vertical-layout>
10px <div>child2-3</div>
</vertical-layout>
15px <div>child1-4</div>
15px <div>child1-5</div>
</vertical-layout>

CSS can not read that childmargin value; so JS is required to apply that value to childelements

As you also don't want to style the first-child...

The code for the connectedCallback is:

    connectedCallback() {
let margin = this.getAttribute("childmargin");
setTimeout(() => {
let children = [...this.querySelectorAll("*:not(:first-child)")];
children.forEach(child=>child.style.setProperty("--childmargin", margin));
});
}

Notes

  • * is a bit brutal.. you might want to use a more specific selector if you have loads of child elements; maybe:
[...this.children].forEach((child,idx)=>{
if(idx) ....
};
  • You are looping all children; could also set the style direct here.. no need for CSS then

  • The setTimeoutis required because all child have not been parsed yet when the connectedCallback fires.

Because all your <vertical-layout> are in GLOBAL DOM (and get refelected to <slot> elements)

You style everything in GLOBAL CSS:

  vertical-layout > *:not(:first-child)  {
margin-top: var(--childmargin);
}

Then all Web Component code required is:

customElements.define("vertical-layout", class extends HTMLElement {
constructor() {
super()
.attachShadow({mode:"open"})
.innerHTML = "<style>:host{display:flex;flex-direction:column}</style><slot></slot>";
}
connectedCallback() {
let margin = this.getAttribute("childmargin");
setTimeout(() => {
let children = [...this.querySelectorAll("*:not(:first-child)")];
children.forEach(child=>child.style.setProperty("--childmargin", margin));
});
}
});

<vertical-layout id="Level1" childmargin="15px">
<div>child1-1</div>
<div>child1-2</div>
<div>child1-3</div>
<vertical-layout id="Level2" childmargin="10px">
<div>child2-1</div>
<div>child2-2</div>
<vertical-layout id="Level3" childmargin="5px">
<div>child3-1</div>
<div>child3-2</div>
<div>child3-3</div>
</vertical-layout>
<div>child2-3</div>
</vertical-layout>
<div>child1-4</div>
<div>child1-5</div>
</vertical-layout>

<style>
body {
font: 12px arial;
}
vertical-layout > *:not(:first-child) {
font-weight: bold;
margin-top: var(--childmargin);
}
vertical-layout::before {
content: "<vertical-layout " attr(id) " childmargin=" attr(childmargin);
}
vertical-layout > vertical-layout {
background: lightblue;
border-top: 4px dashed red;
}
vertical-layout > vertical-layout > vertical-layout {
background: lightcoral;
}
</style>

<script>
customElements.define("vertical-layout", class extends HTMLElement {
constructor() {
super()
.attachShadow({
mode: "open"
})
.innerHTML =
`<style>
:host {
display: flex;
flex-direction: column;
background:lightgreen;
padding-left:20px;
border:2px solid red;
}
::slotted(*){margin-left:20px}
:host([childmargin]) ::slotted(:not(:first-child)) {
color:blue;
}
</style>
<slot>
<slot></slot>
</slot>`;
}
connectedCallback() {
let margin = this.getAttribute("childmargin");
setTimeout(() => {
let children = [...this.querySelectorAll("*:not(:first-child)")];
children.map(child=>{
child.style.setProperty("--childmargin", margin);
child.append(` margin-top: ${margin}`);
})
});
}
});

</script>

Shadow DOM innerHTML with slots replaced by assignedNodes()

Since there doesn't seem to be a browser-native way of answering the question (and it seems that browser developers don't fully understand the utility of seeing a close approximation to what the users are approximately seeing in their browsers) I wrote this code.

Typescript here, with pure-Javascript in the snippets:

const MATCH_END = /(<\/[a-zA-Z][a-zA-Z0-9_-]*>)$/;

/**
* Reconstruct the innerHTML of a shadow element
*/
export function reconstruct_shadow_slot_innerHTML(el: HTMLElement): string {
return reconstruct_shadow_slotted(el).join('').replace(/\s+/, ' ');
}

export function reconstruct_shadow_slotted(el: Element): string[] {
const child_nodes = el.shadowRoot ? el.shadowRoot.childNodes : el.childNodes;
return reconstruct_from_nodeList(child_nodes);
}

function reconstruct_from_nodeList(child_nodes: NodeList|Node[]): string[] {
const new_values = [];
for (const child_node of Array.from(child_nodes)) {
if (!(child_node instanceof Element)) {
if (child_node.nodeType === Node.TEXT_NODE) {
// text nodes are typed as Text or CharacterData in TypeScript
new_values.push((child_node as Text).data);
} else if (child_node.nodeType === Node.COMMENT_NODE) {
const new_data = (child_node as Text).data;
new_values.push('<!--' + new_data + '-->');
}
continue;
} else if (child_node.tagName === 'SLOT') {
const slot = child_node as HTMLSlotElement;
new_values.push(...reconstruct_from_nodeList(slot.assignedNodes()));
continue;
} else if (child_node.shadowRoot) {
new_values.push(...reconstruct_shadow_slotted(child_node));
continue;
}
let start_tag: string = '';
let end_tag: string = '';

// see @syduki's answer to my Q at
// https://stackoverflow.com/questions/66618519/getting-the-full-html-for-an-element-excluding-innerhtml
// for why cloning the Node is much faster than doing innerHTML;
const clone = child_node.cloneNode() as Element; // shallow clone
const tag_only = clone.outerHTML;
const match = MATCH_END.exec(tag_only);
if (match === null) { // empty tag, like <input>
start_tag = tag_only;
} else {
end_tag = match[1];
start_tag = tag_only.replace(end_tag, '');
}
new_values.push(start_tag);
const inner_values: string[] = reconstruct_from_nodeList(child_node.childNodes);
new_values.push(...inner_values);
new_values.push(end_tag);
}
return new_values;
}

Answer in context:

const MATCH_END = /(<\/[a-zA-Z][a-zA-Z0-9_-]*>)$/;


/**
* Reconstruct the innerHTML of a shadow element
*/
function reconstruct_shadow_slot_innerHTML(el) {
return reconstruct_shadow_slotted(el).join('').replace(/\s+/, ' ');
}

function reconstruct_shadow_slotted(el) {
const child_nodes = el.shadowRoot ? el.shadowRoot.childNodes : el.childNodes;
return reconstruct_from_nodeList(child_nodes);
}


function reconstruct_from_nodeList(child_nodes) {
const new_values = [];
for (const child_node of Array.from(child_nodes)) {
if (!(child_node instanceof Element)) {
if (child_node.nodeType === Node.TEXT_NODE) {
new_values.push(child_node.data);
} else if (child_node.nodeType === Node.COMMENT_NODE) {
const new_data = child_node.data;
new_values.push('<!--' + new_data + '-->');
}
continue;
} else if (child_node.tagName === 'SLOT') {
const slot = child_node;
new_values.push(...reconstruct_from_nodeList(slot.assignedNodes()));
continue;
} else if (child_node.shadowRoot) {
new_values.push(...reconstruct_shadow_slotted(child_node));
continue;
}
let start_tag = '';
let end_tag = '';

const clone = child_node.cloneNode();
// shallow clone
const tag_only = clone.outerHTML;
const match = MATCH_END.exec(tag_only);
if (match === null) { // empty tag, like <input>
start_tag = tag_only;
} else {
end_tag = match[1];
start_tag = tag_only.replace(end_tag, '');
}
new_values.push(start_tag);
const inner_values = reconstruct_from_nodeList(child_node.childNodes);
new_values.push(...inner_values);
new_values.push(end_tag);
}

return new_values;
}

class HelloThere extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML = '<span>Hello <slot></slot>!</span>';
}
}

customElements.define('hello-there', HelloThere);
<hello-there><b>S</b>amantha</hello-there>
<div>Output: <input type="text" size="200" id="output"></input></div>
<script>
const ht = document.querySelector('hello-there');
const out = document.querySelector('#output');

</script>
<button onclick="out.value = ht.innerHTML">InnerHTML hello-there</button><br>
<button onclick="out.value = ht.outerHTML">OuterHTML hello-there</button><br>
<button onclick="out.value = ht.shadowRoot.innerHTML">InnerHTML hello-there shadow</button><br>
<button onclick="out.value = ht.shadowRoot.outerHTML">OuterHTML hello-there shadow (property does not exist)</button><br>
<button onclick="out.value = reconstruct_shadow_slot_innerHTML(ht)">Desired output</button>

How do I style the last slotted element in a web component

For long answer on ::slotted see: ::slotted CSS selector for nested children in shadowDOM slot


From the docs: https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted

::slotted( <compound-selector-list> )

The pseudo selector goes inside the brackets: ::slotted(*:last-child)

Note: :slotted(...) takes a simple selector

See (very) long read: ::slotted CSS selector for nested children in shadowDOM slot

  customElements.define('my-table', class extends HTMLElement {
constructor() {
let template = document.cloneNode(document.querySelector('#t1').content, true);
super()
.attachShadow({ mode: 'open' })
.append(template);
}
})
<template id="t1">
<style>
:host {
display: flex;
padding:1em;
}
::slotted(*:first-child) {
background: green;
}
::slotted(*:last-child) {
background: yellow;
flex:1;
}
::slotted(*:first-of-type) {
border: 2px solid red;
}
::slotted(*:last-of-type) {
border: 2px dashed red;
}
</style>
<slot name="column"></slot>
</template>

<my-table>
<div slot="column">Alpha</div><div slot="column">Bravo</div><div slot="column">Charlie</div>
</my-table>
<my-table>
<div slot="column">Delta</div><div slot="column">Echo</div>
</my-table>


Related Topics



Leave a reply



Submit