Skip to content

Instantly share code, notes, and snippets.

@vijayksingh
Forked from praveenpuglia/shadow-dom.md
Created October 30, 2020 18:08
Show Gist options
  • Save vijayksingh/139fdf1da2217661ee0873bd61fab118 to your computer and use it in GitHub Desktop.
Save vijayksingh/139fdf1da2217661ee0873bd61fab118 to your computer and use it in GitHub Desktop.
Everything you need to know about Shadow DOM

Shadow DOM

Heads Up! It's all about the V1 Spec.

In a nutshell, Shadow DOM enables local scoping for HTML & CSS.

Shadow DOM fixes CSS and DOM. It introduces scoped styles to the web platform. Without tools or naming conventions, you can bundle CSS with markup, hide implementation details, and author self-contained components in vanilla JavaScript. - https://developers.google.com/web/fundamentals/getting-started/primers/shadowdom

It's like it's own little world which hardly affects or gets affected by the outside world.

It's what you write as a component author to abstract away the implementation details of your component. It can also decide what to do with the user-provided light DOM.

Terminologies

- DOM : What we get over the wire or wireless is a string of text. To render something on the screen, the browsers have to parse that string of text and convert it into a data model so it can understand things better. It also preserves the hierarchy from the original string by creating putting those parsed objects in a tree structure.

We need to do that to make the machines understand our documents better. This tree like data model of our document is called Document Object Model.

- Component Author : The person who creates an element and defines how it works. Generally the person who writes a lot of shadow DOM code. Example - Browser vendors create the input element.

- Component User : Well, they use your component. They can pass in light DOM and set attributes and properties on your component. They DON'T get to change the internals of a component. Example - We the user who use the input element.

- Shadow Root: It's what gets attached to an element to give that element it's shadow DOM. Technically it's a non-element node, a special kind of document fragment.

<custom-picture>
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ISOLATION
    #shadow-root
        ...
    ____________________________________________ DOCUMENT FRAGMENT

    <!--LIGHT DOM-->
</custom-picture>

Throughout the document, I have put shadow root inside those weird ASCII boundaries. This will put more emphasis on thinking how they are actually document fragments and they have a wall around it.

- Shadow Host: The element to which the shadow root gets attached. The host can access it's shadow root via a property on itself. .shadowRoot.

- Shadow Tree : All the elements that go into the Shadow Root, which is scoped from outside world, is called Shadow Tree.

The elements in a shadow tree are not descendants of the shadow host in general (including for the purposes of Selectors like the descendant combinator) - Spec

- Light DOM: - The set of DOM elements we can sandwich between the opening and closing tags. - The DOM that lives outside shadow DOM. - The DOM, the user of your element writes. - The DOM that is the actual children of your element.

<custom-picture>
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    #shadow-root
    ___________________________

    <!--Light DOM-->
    <img src="https://path.to/a-kitten.png">
    <cite>A Nice Kitten!</cite>
    <!--Light DOM-->
</custom-picture>

- DocumentFragment:

The DocumentFragment interface represents a minimal document object that has no parent. It is used as a light-weight version of Document to store a segment of a document structure comprised of nodes just like a standard document. The key difference is that because the document fragment isn't part of the actual DOM's structure, changes made to the fragment don't affect the document, cause reflow, or incur any performance impact that can occur when changes are made. - MDN

How to create Shadow DOM?

<div class="dom"></div>
let el = document.querySelector(".dom");
el.attachShadow({mode: "open"});
// Just like prototype & constructor bi-directional references, we have...
el.shadowRoot // the shadow root.
el.shadowRoot.host // the element itself.

// put something in shadow DOM
el.shadowRoot.innerHTML = "Hi I am shadowed!";

// Like any other normal DOM operation.
let hello = document.createElement("span");
hello.textContent = "Hi I am shadowed but wrapped in span";
el.shadowRoot.appendChild(hello);

Could we use append() instead of appendChild() ?

Yes! But here are the differences from MDN.

  • ParentNode.append() allows you to also append DOMString object, whereas Node.appendChild() only accepts Node objects.
  • ParentNode.append() has no return value, whereas Node.appendChild() returns the appended Node object.
  • ParentNode.append() can append several nodes and strings, whereas Node.appendChild() can only append one node.

What happens if I use an input element instead of the div to attach the shadow DOM?

Well, it doesn't work. Because duh! browser already hosts it's own shadow DOM for those elements. You'll get an error.

Note

  • Shadow DOM, cannot be removed once created. It can only be replaces with a new one.
  • If you are creating a custom element, you should be creating the shadowRoot in it's constructor. It can also be probably called in connectedCallback() but I am not sure if that introduces performance problems or any other problems.
  • To see how browsers implement shadow DOM for elements like input or textarea, Go to DevTools > Settings > Elements > [x] Show user agent shadow DOM.

Shadow DOM Modes

Open

You saw the {mode: "open"} in the attachShadow() method right? Yeah! That's it. What open mode does is that it provides a way for us to access the shadow DOM of an element in JS via the element. It also lets us access the element from within the shadow DOM.

This is done by the two implicit properties created when we call attachShadow() in open mode.

  1. The element gets a property called shadowRoot which points to the shadow DOM being attached to it.
  2. The shadowRoot gets a property called host pointing to the element itself.
// From the "How to create shadow DOM" example
el.attachShadow({mode: "open"});
// Just like prototype & constructor bi-directional references, we have...
el.shadowRoot // the shadow root.
el.shadowRoot.host // the el itself.

Closed

Pass {mode: "closed"} to attachShadow() to create a closed shadow DOM. It makes the shadow DOM inaccessible from JS.

So which one should I use?

Almost always sue open mode shadow DOMs because they make it possible for both the component author and user to change things how they want.

Remember we did el.shadowRoot stuff up there? Yeah! That won't work. The element doesn't get any reference to it's shadow DOM. This is especially a problem when creating custom elements and want to access the shadow DOM for manipulation.

class CustomPicture extends HTMLElement {
    constructor() {
        this.attachShadow({mode: "open"}); // this.shadowRoot exists. add or remove stuff in there using this ref.
        this.attachShadow({mode: "closed"}); // this.shadowRoot returns null. Bummer!      
    }
}
// You could always do the following in your constructor.
// but it totally defies the purpose of using closed mode.
this._shadowRoot = this.attachShadow({mode: "closed"});

Also, closed mode isn't a security mechanism. It just gives a fake sense of security. Nobody can stop someone from modifying how Element.prototype.attachShadow() works.

Styles inside Shadow DOM.

  • They are scoped.
  • They don't leak out.
  • They can have simple names.
  • They are cool, be like them.
<custom-picture>
    #shadow-root 
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        <style>
            /*Applies only to spans inside shadow DOM. Doesn't leak out.*/
            span {
                color: red;
            }
        </style>
        <span>Hello!</span>
        __________________________________________________________________
</custom-picture>

Oh! oh! So can we also include a stylesheet too?

Yeeeeaaaah.. but not in all browsers.

<custom-picture>
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    #shadow-root 
        <!--All styles coming from custom-picture.css will be scoped inside this shadow root-->
        <link rel="stylesheet" href="custom-picture.css">
        <span>Hello!</span>
    _____________________________________________________

Does it get affected by global CSS?

Yeah. In some ways. Only the properties that are inherited will make their way through the shadow DOM boundary. Example -

  • color
  • background
  • font-family etc.

The * selector also affects things because * means all elements and that includes the element to which you are attaching the shadow root too, the host element. Things which get applied to the host and can be inheried, will pass the shadow DOM boundary to apply to inner elements.

Terminologies

  • :host: targets the host element. BUT!
/* winner */
custom-picture {
    background: red;
}
/* loser */
#shadow-root
    <style>
        :host {
            background: green;
        }
    </style>
  • :host(<selector>): Does the component host match the selector ? Basically to target different states of the same host. Examples
:host([disabled]){
    ...
}

:host(:focus){
    ...
}

:host(:focus) span {
    /*change all spans inside the element when the host has focus*/
}
  • :host-context(<selector>): Is the host a descendant of selector ? Lets us change component's styles based on how the parent looks like. General application could be in theming.
:host-context(.light-theme) {
    background: lightgray;
}

:host-context(.dark-theme) {
    background: darkgray;
}

/*You can also do...*/
:host-context(.aqua-theme) > * {
    color: aqua; /* lame */
}

A note about :host() and :host-context()

Both of these functional pseudo classes can only take <compound-selector> but not a combinator like space or ">" or "+" etc. In case of :host() that means you can only choose the attributes and other aspects of the host element.

In case of :host-context() that means you can choose the attributes and other aspects of one particular element which is an ancestor of the :host. Only one!

Do the modes affect how styles get applied/cascaded?

Nope! Only affects how we do things in JS.

How does the user-agent styles get applied to even the shadow DOM elements?

Based on how shadow Roots or in general DocumentFragment works, user-agent styles (global) shouldn't have applied to all the elements inside a shadow root. So how do they work?

From the spec...

Windows gain a private slot [[defaultElementStylesMap]] which is a map of local names to stylesheets. This makes it possible to write elements inside shadow root and still get the default browser styles applied to them.

Which styles to put in shadow DOM ?

The purpose of having styles inside a shadow DOM is to just have default styles and provide hooks via css custom properties so component users can make changes to those default styles.

<business-card>
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    #shadow-root
        <h1 class="card-title">Hardcoded Title - </h1>
    ---------------------------------------------------
</business-card>
 /*inside shadow DOM*/
 .card-title {
    color: var(--card-title-color, #000);
 }

 /* Component users can then override this color as*/
 business-card {
     --card-title-color: magenta; 
 }

Few notes from the spec

  • A shadow host is outside of the shadow tree it hosts, and so would ordinarily be untargettable by any selectors evaluated in the context of the shadow tree (as selectors are limited to a single tree), but it is sometimes useful to be able to style it from inside the shadow tree context.
  • To work around that problem, the shadow host is treated as replacing the shadow root node.
  • When considered within its own shadow trees, the shadow host is featureless. Only the :host, :host(), and :host-context() pseudo classes are allowed to match it.

Events in Shadow DOM.

Events work towards maintaining the encapsulation provided by the shadow DOM. Essentially, if an event occurs somewhere in shadow DOM, to outside world it'll look as if that event has triggered from the host element itself and not a secific part of the shadow DOM. This is called re-targeting of the event.

Inside the shadow DOM however the events aren't retargered and we can find out which specific element was an event associated with.

So say if our flattened DOM tree looks like this..

<body>
    <custom-picture>
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        #shadow-root
            <button> Hello </button>
        ---------------------------------
    </custom-picture>
</body>

On click of the button, to body, or anywhere outside custom-picture, the event.target will point to the <custom-picture> itself.

Inside <custom-picture> however, the event target will be the button which was really clicked.

Most events go out of shadow DOM boundary and when they do, they get re-targered. Some events aren't allowed to pass that boundary. Precisely these...

  • abort
  • error
  • select
  • change
  • load
  • reset
  • resize
  • scroll
  • selectstart

Slots

Slots are a pretty big thing in Shadow DOM.

Slots are placeholders inside your component that users can fill with their own markup. - https://developers.google.com/web/fundamentals/getting-started/primers/shadowdom#slots

When creating custom components, we want to be able to provide only the necessary markup that goes into a particular component and use/group/style that as we want to as component authors.

The DOM that a component user provides is called light DOM and slots are the way we can arrange that markup however we want. Style based on differnt situations and group them.

There are two aspects of a slot.

  • Light DOM Elements : They say which slot they wanna go into.
    <custom-picture>
        <!--Light DOM <img> saying it should be put into the profile-picture slot-->
        <img src="assets/user.svg" slot="profile-picture">
    </custom-picture>
  • The Actual Slot : The <slot> element, residing somewhere in shadow DOM with a name for itself to be found by light DOM.
    <custom-picture>
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        #shadow-root
            <slot name="profile-picture">
                <!--The <img> from the light DOM gets rendered here!-->
            </slot>
        _________________________________________
        

A very important note about how light DOM and slots play together.

Elements are allowed to "cross" the shadow DOM boundary when a invites them in. These elements are called distributed nodes. Conceptually, distributed nodes can seem a bit bizarre. Slots don't physically move DOM; they render it at another location inside the shadow DOM. - https://developers.google.com/web/fundamentals/getting-started/primers/shadowdom#slots

What if I don't provide the slot attribute in the <img> in <custom-picture> ?

You'll see nothing rendered. Here's why.

  1. A host element that has shadow DOM, only renders stuff that goes inside it's shadow DOM.
  2. In order to get light DOM elements rendered, they need to be part of the shadow DOM.
  3. The way we make them part of the shadow DOM is by putting them in slots.
  4. In our example above, there's no element in light DOM that wants to go into a slot named profile-picture.
  5. Since there's no one, the <img> from light DOM is not rendered.
  6. Takeaway - Named slots accomodate only those light DOM elements which specify they wanna go into that particular slot.

What if I want to render all elements that don't say where they should go ?

This will require us to have a general purpose slot in our shadow DOM. A slot without a name. Example -

    <custom-picture>
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        #shadow-root
            <!--General purpose slot, render every element from light DOM that doesn't mention a slot name, here.-->
            <slot>
                <!--The <img> from the light DOM gets rendered here!-->
            </slot>
        _________________________________________
        <!--Light DOM-->
        <img src="assets/user.svg">

What if I add two unnamed slots?

Woah! 😲 Actually, we can have multiple unnamed slots or even named slots but they are essentially useless. The light DOM elements will go into the first slots they match. So in the case above...

    <custom-picture>
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        #shadow-root
            <slot>
                <!--The <img> from the light DOM gets rendered here! Winner!-->
            </slot>
            <slot>
                <!--Doesn't come here!-->
            </slot>
        _________________________________________
        <!--Light DOM-->
        <img src="assets/user.svg">

Resources

More Queries & Bugs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment