Messin’ around with web components. Also—JavaScript, generally

25 min read Original article ↗
Table of contents

I’ve been enjoying Robin Rendle’s new email newsletter called The Cascade. He’s been kicking the tires on web components (also known as custom elements) and last week posted some thoughts about why you might want to use them.

Now this week he went a little deeper into some code for one of these bad boys and expressed some mild frustration and confusion about how it all works.

He’s trying to set up a custom element like this:

<thanksgiving-button></thanksgiving-button>

With some script like:

const template = document.createElement('template')
template.innerHTML = `<button>Happy Thanksgiving!</button>`

class ThanksGivingButton extends HTMLElement {
  constructor() {
    super()
    this._shadowRoot = this.attachShadow({ mode: 'open' })
    this._shadowRoot.appendChild(template.content.cloneNode(true))
  }
}

customElements.define('thanksgiving-button', ThanksGivingButton)

Then, he says this:

I think I have to use the constructor() here since I’m setting this. But also there are no good blog posts out there explaining any of this stuff and so I challenge you, nay dare you, to really explain all this to me

Ha, challenge accepted.

Objects, Classes and “this”

To understand all of this (and, ahem, this), we have to step way, way back into first principles.

(Almost) everything in JavaScript is an object

Although this is simplifying some, in JavaScript if something is not a string ("hello"), number (42), or the programming-y bits like true, false, or undefined, it’s an object.

Think of objects like a basket of stuff. They have properties (bits of data on the object), and methods (functions attached to the object). For example, here is a simple object, stored as the variable ultimateQuestion:

const ultimateQuestion = {
  answer: 42,
  getAnswer: function () {
    return `The answer to life and so forth is 42`
  },
}

// accessing property by name
// ultimateQuestion['answer'] is equivalent
ultimateQuestion.answer
// --> 42

// calling a method
ultimateQuestion.getAnswer()
// --> "The answer to life and so forth is 42"

So in the above, answer is a property, and getAnswer is a method (function) on that object.

You’re probably familiar with some of the big important objects in a browser’s environment like document or window, but under the covers, basically everything is an object. Arrays are a special kind of object with numbers for properties and special methods like .forEach(); Dates are objects; even functions are, deep down, objects with properties and methods of their own.

What is "this"?

So, fair warning up front: the this keyword in JavaScript is one of the most confusing parts of the language.

You may have noticed on my ultimateQuestion that I repeated 42, once in the answer property, then again in what I’m logging in the getAnswer method:

const ultimateQuestion = {
  answer: 42,
  getAnswer: function () {
    return `The answer to life and so forth is 42`
  },
}

That kinda stinks. Like our answer is some data we’re storing on the object, and it’d be great if getAnswer could refer to that data, yah?

Enter this:

const ultimateQuestion = {
  answer: 42,
  getAnswer: function () {
    return `The answer to life and so forth is ${this.answer}`
  },
}

Lookit, now we’re using this in getAnswer. When we call that method…

ultimateQuestion.getAnswer()
// --> "The answer to life and so forth is 42"

… it works just like before, but now it’s referring to the answer property on the ultimateQuestion object (represented in the method by this)!

Should I change the answer property on the ultimateQuestion object, calling getAnswer() later on will do the right thing:

ultimateQuestion.answer = 1
ultimateQuestion.getAnswer()
// --> "The answer to life and so forth is 1"

So, we’ve arrived at one of the things that this can be: this is the context in which a method is called. With ultimateQuestion.getAnswer(), we are invoking the getAnswer method, but it’s attached to the ultimateQuestion object, so this within the method is that specific ultimateQuestion object.

In short, to know what this actually is, you have to look at what object the function is attached to when it is getting called.

//                  ↙️ to know what "this" is in here…
ultimateQuestion.getAnswer()
//    ↖️ …you have to look back here

Now, I am glossing over MANY oddities in the language, like you can redefine what this is on the fly with the .call(), .apply(), and .bind() methods on functions, or if you were to try to peel off getAnswer() and call it separated from ultimateQuestion, it wouldn’t do the same thing…

// with .call, you're invoking a method with a different "this"
ultimateQuestion.getAnswer.call({ answer: 'strippers & blackjack' })
// --> "The answer to life and so forth is strippers & blackjack"

const getAnswer = ultimateQuestion.getAnswer
getAnswer()
// --> actually what happens here depends on if you're in strict mode or not lolololol

JavaScript is bonkers sometimes! But let’s move on.

Creating new objects with constructor functions

So my little ultimateQuestion object is pretty great, but you might be thinking, “Golly it’d be great to create my own questions, with different answers!”

More power to you! This is where constructors come in!

A “constructor” is a fancy name for a function that returns a new object. That’s all they are!

But they’re, uh, a little weird. Let’s make a constructor function:

function UltimateQuestion(answer) {
  this.answer = answer
  this.getAnswer = function () {
    return `The answer to life and so forth is ${this.answer}`
    // btw don't do this in real life
    // I am trying very hard not to say "prototype" in this article
  }
}

(Constructors function names are capitalized by convention, but they don’t have to be.)

Wait, we’re using this here now? Hang in there!

You use a constructor function like so, with the new keyword:

const myQuestion = new UltimateQuestion(42)
myQuestion.getAnswer()
// --> "The answer to life and so forth is 42"

The super important part is the new keyword before calling the function (as in new UltimateQuestion(42)), which leads us to the second special thing that this can be: in a (constructor) function called with new, this is the new object being created.

So my UltimateQuestion function is sticking a property and method onto this, which is a new object every time someone calls new UltimateQuestion().

Now because we have a dedicated function for building them, we can make a bunch of UltimateQuestions, each with their own stored answer property and getAnswer() method that refers to that property.

const bendersQuestion = new UltimateQuestion('strippers & blackjack')
bendersQuestion.getAnswer()
// --> "The answer to life and so forth is strippers & blackjack"

You might have noticed that I didn’t have to do something like return this at the end of the constructor; that’s assumed when you use new.

You might be thinking, “Golly, this seems maybe needlessly complicated? If I wanted a function that makes an object for me, couldn’t I just… make and return a new object?”

function makeUltimateQuestion(answer) {
  return {
    answer,
    getAnswer: function () {
      return `The answer to life and forth is ${this.answer}`
    },
  }
}

const myQuestion = makeUltimateQuestion(42)
const bendersQuestion = makeUltimateQuestion('strippers & blackjack')
bendersQuestion.answer
// --> "strippers & blackjack"

That works! I’d probably do that!

But what constructor functions and new let you do is check what kind of object you have with instanceof.

const ultimateQuestion = new UltimateQuestion(42)

ultimateQuestion instanceof UltimateQuestion
// --> true

ultimateQuestion instanceof HTMLElement
// --> false

const imposterQuestion = { answer: 42 }
imposterQuestion instanceof UltimateQuestion
// --> false

What instanceof does is tells you if the thing on the left sprang forth in any way from a constructor on the right.

This is… well in many years of writing JavaScript I rarely used instanceof, but it’s a low-key superpower in TypeScript.

Remember: almost everything is an object, so being able to narrow down what specific kind of object something is — and, accordingly, gaining some understanding of what properties and methods will be on that object — can be very useful.

someRandomObject.answer
// ❌ TypeScript error; it isn't sure "answer" is a property on this object

if (someRandomObject instanceof UltimateQuestion) {
  // ✅ TypeScript understands that "answer" is a property on this object
  someRandomObject.answer
}

A more practical real-world use is verifying that a given element is a specific HTML element, each of which has a dedicated constructor like HTMLImageElement or HTMLParagraphElement:

if (someElement instanceof HTMLImageElement) {
  // now TS knows the someElement could have <img />-specific properties
  // like "src", "width", "height"
}

Class is in session

Alright, now you can make your own new questions with whatever answer you want, but the constructor function…

function UltimateQuestion(answer) {
  this.answer = answer
  this.getAnswer = function () {
    return `The answer to life and so forth is ${this.answer}`
  }
}

… is kinda gross-looking, yeah? You have to keep referring to this to add properties and methods to our newly-created object. Also, you kinda have to know that UltimateQuestion is a special constructor function — either because of the (not-required!) capitalized name or because it uses this inside.

Enter class, which is (really!) just a different, arguably nicer syntax for constructor functions. My original ultimateQuestion would look like this as a class:

class UltimateQuestion {
  // property
  answer = 42

  // method
  getAnswer() {
    return `The answer to life and so forth is ${this.answer}`
  }
}

const myQuestion = new UltimateQuestion()
myQuestion.answer
// --> 42
myQuestion.getAnswer()
// --> "The answer to life and so forth is 42"

(Like with constructor functions, it’s conventional to capitalize class names.)

There’s some weird stuff now! The property answer looks more like a variable assignment (answer = 42), instead of like an object property assignment (answer: 42,) for some reason!

Also, the class UltimateQuestion that we’re creating is just a template for objects. If you tried to do UltimateQuestion.answer, it’ll be undefined. You don’t actually make a real object with your properties and methods until you call it like a function with new UltimateQuestion() — just like constructor functions.

We’ve reverted a bit here; the UltimateQuestion class above doesn’t let you set any old answer.

If we wanted folks to define their own answers again, classes can have a special method called constructor(). When you go to actually make a new object from a class with new, the constructor method gets called.

Again, this is just like our “regular function” constructors, but with a class it’s now explicitly called constructor. Also just like before when we were calling a regular function with new, within the constructor() method the value of this is the new object that is being created.

class UltimateQuestion {
  constructor(answer) {
    // "this" is the new object we will be making
    this.answer = answer
  }

  getAnswer() {
    return `The answer to life and so forth is ${this.answer}`
  }
}

const goodQuestion = new UltimateQuestion(42)
const worseQuestion = new UltimateQuestion(43)

Here’s where I can see the appeal of this syntax:

  • With class, we’re making it clear that we’re defining the template for an object.
  • Anything required for initialization or setup can be done in the constructor, so that has a nice, dedicated purpose and a clear(ish) name.
  • You can define the various methods on your object separately from the constructor, so they’re not cluttering up the constructor code.

Oh, and also you can build on existing classes by…

Extending classes

Another neat thing about class is its close buddy extends. What extends lets you do is, well, extend existing classes. When you extend an existing class (or constructor), you are building on top of what the original does for free, without having to duplicate that code.

Here’s an example where I make a new class that adds additional methods:

class QuestionLogger extends UltimateQuestion {
  whisperAnswer() {
    console.log(`[mumbles softly] ${this.getAnswer()}`)
  }

  yellAnswer() {
    console.log(`THE ANSWER TO LIFE AND SO FORTH IS ` + this.answer + '!!!!!!')
  }
}

Above, QuestionLogger is the child or subclass, and it is building on top of what happens in UltimateQuestion, the parent class.

Check out how I can access and reuse the properties and methods of UltimateQuestion, via this in each of those methods. Those bits from UltimateQuestion come along for free because I am extending it.

Maybe we could add additional methods that let you change existing properties in a controlled way:

class CounterQuestion extends UltimateQuestion {
  increment() {
    this.answer = this.answer + 1
  }
  decrement() {
    this.answer = this.answer - 1
  }
}

const counterQuestion = new CounterQuestion(42)
counterQuestion.getAnswer()
// --> "The answer to life and so forth is 42"

counterQuestion.increment()
counterQuestion.increment()
counterQuestion.increment()
counterQuestion.decrement()
counterQuestion.getAnswer()
// --> "The answer to life and so forth is 44"

See how it’s totally fine to access and even change the properties on this in the child methods?

Child classes can even override a method on the parent class:

class LouderUltimateQuestion extends UltimateQuestion {
  getAnswer() {
    return `THE ANSWER TO LIFE AND SO FORTH IS ${this.answer}!!!!!!`
  }
}

new LouderUltimateQuestion(42).getAnswer()
// --> "THE ANSWER TO LIFE AND SO FORTH IS 42!!!!!!"

Note also that I am not defining a constructor() method in the child classes. If I leave that off, they’ll just (kinda sorta) reuse the same one as the parent class.

But if you do want to override that constructor…

What is “super()”?

So when a child class is extending a parent and has to do more stuff in its constructor, that’s where super() comes into play.

Maybe a child class of UltimateQuestion wants let you pass in a template to control what gets returned when you call getAnswer(), so that’s an additional argument in the constructor:

class TemplatedAnswerQuestion extends UltimateQuestion {
  constructor(answer, template) {
    super(answer)

    this.template = template
  }

  getAnswer() {
    return this.template.replace('{{ANSWER}}', this.answer)
  }
}

const theQuestion = new TemplatedAnswerQuestion(300, 'The answer is {{ANSWER}}')
theQuestion.getAnswer()
// --> "The answer is 300"

Because my child TemplatedAnswerQuestion needs to store more internal data (the template property when it gets created), it needs its own constructor method to store it somewhere when we make one of these.

But in a child’s constructor(), you must call the special super() function, which then creates the new (parent) object first. Once that happens, you can access this (the new object) in the child’s constructor, and it’ll have all the properties and methods of the parent class.

So if you need to store additional properties on this or do more custom setup, you’d do that in the constructor(), after calling super().

If you’re not doing any of that, you don’t need to define the child’s constructor() method, and you also don’t need to worry about super() at all.

Why do you have to call super() manually? If you want to do something like logging or futzing with the arguments or what-have-you before the parent object gets created, you can do that:

class TemplatedAnswerQuestion extends UltimateQuestion {
  constructor(answer, template) {
    // stuff like logging before super() is fine
    console.log('creating a TemplatedAnswerQuestion with arguments', { answer, template })

    // you must call super() before accessing "this" here
    super(answer)

    // now you can access "this"
    this.template = template
  }
}

So you have some control over when super() gets called, but you must do it at some point and you must do it before using this in the child’s constructor(). It’s the law! Just kidding but you will get a runtime error if you don’t.

Parent classes might defer to their children

One last thing before we actually talk about web components, I promise.

Up to this point we’ve had a class (UltimateQuestion) and extended it to adjust or overwrite some parts of the resulting object. But the UltimateQuestion was fine on its own, honestly. The extensions were just for funsies.

What if we designed a class with the expectation that it will be extended?

Like (and OK yes I know this is super contrived) our parent class sets up a listener so that whenever you click on the document, we’ll do… something…

class QuestionDocumentClicker {
  constructor(answer) {
    this.answer = answer

    document.addEventListener('click', () => {
      if (this.documentClickedCallback) {
        this.documentClickedCallback()
      }
    })
  }
}

But look, we didn’t actually define that documentClickedCallback() method that gets called when you click.

What we’re doing here is not defining that method in the parent, and assuming that any child classes that are extending this class will define it for us. That way, the parent doesn’t particularly have to care about what happens; that’s a concern for the child class. The parent just stores the answer and sets up the event handler.

A child class then might look like this, where all it has to do is provide the “missing” documentClickedCallback() method:

class MyClicker extends QuestionDocumentClicker {
  documentClickedCallback() {
    console.log(`My super special answer is ${this.answer}`)
  }
}

const myClicker = new MyClicker(42)

// (click the document)
// --> [logs] "My super special answer is 42"

If you’re wondering, “Huh? Why are we using ‘callback’ in the name for the method?” you don’t have to! That’s just a sorta-conventional way of describing a function that will get called at some future time, typically in response to an event like a click or a request completing or a timer finishing up.

We’ll see methods like this — where it’s on the child class to define what happens when certain events happen — in action with web components.

Phew! That’s a whirlwind tour of objects, constructors, this, class, and extending classes.

(I am papering over a lot, like how objects in JavaScript use “prototype” inheritance so this is really all a facade to make it look like other “class”-based languages but, god, don’t go looking into that too deeply.)

The HTMLElement class

OK that went longer than I thought, so let’s tie all this back to web components.

When you’re defining a custom element’s class, you do so (typically!) by creating a new class that extends the HTMLElement class, which is the common-denominator shared class for all elements in HTML. Like here’s Robin’s example:

class ThanksGivingButton extends HTMLElement {
  constructor() {
    super()
    this._shadowRoot = this.attachShadow({ mode: 'open' })
    // some other things use this._shadowRoot…
  }
}

So, put another way given what we now understand about classes, we’re defining the setup code to make a new thing (ThanksGivingButton), and it is building on top of HTMLElement.

Now in that snippet above, Robin is using a constructor() for this new child thing.

Finally, finally I’m circling back to his initial point of confusion, but the first point is that for web components…

(Probably) Don’t use a constructor on custom elements

Like, you can, but you don’t have to, probably.

You probably want to do stuff when the element is fully “in” the page anyway — that’s when you can look for children of your custom element or replace the innards with templates AKA 99% of what you’d do in a web component. You should do that work in the connectedCallback() method.

In the same manner as our extremely contrived example QuestionDocumentClicker above, the HTMLElement parent class knows to call a child class’s connectedCallback method when the custom elements lands on the document.

A common misconception I see — and it appears that this might be where Robin is coming from — is that you can only access this in a constructor function, but as I’ve shown in my whirlwind tour of objects and constructors above, that’s not true at all: Any method on an object (or class) can access this!

Accordingly, when the connectedCallback method runs, you have access to this, and in that context this is the now-extended HTMLElement element that is currently on the page.

Because we’re extending the HTMLElement class, this has all the bits and bobs you find on other elements: you can set this.innerHTML, you can look for descendent elements with this.querySelector(), set up a shadow DOM with this.addShadow(), add an event listener with this.addEventListener(), and so forth.

Anyway, for Robin’s case, all his logic that’s in the constructor can (and probably should, in my opinion) move to the connectedCallback method:

class ThanksGivingButton extends HTMLElement {
  connectedCallback() {
    this._shadowRoot = this.attachShadow({ mode: 'open' })
    this._shadowRoot.appendChild(template.content.cloneNode(true))
  }
}

No more constructor, no more super(). And you can use this in there just fine.

Avoiding constructor() in web components comes right from the spec, BTW:

In general, work should be deferred to connectedCallback as much as possible — especially work involving fetching resources or rendering.

Now you might be wondering, “Hang on, why isn’t the constructor() the thing that gets run when inserting the element? Why is there also this connectedCallback?”

Good question! Typically we think about these things as custom HTML elements that you author in markup like…

<thanksgiving-button></thanksgiving-button>

The real magic with these web component classes is that the browser is doing so much stuff for you. After you’ve registered a custom element, when the browser sees that markup in the page, it’ll effectively run that custom element’s constructor() function, then also immediately call connectedCallback() on your behalf.

So in general, you’re not writing scripts that makes these things like new ThanksGivingButton(); you just write the setup code and then use HTML markup to place them wherever you want in the page.

But, if you wanted to, you can create new elements in JavaScript manually, and insert them into the page later, if ever, which separates the initialization (constructor time) from the mounting (connectedCallback time):

const myThanksgivingButton = new ThanksGivingButton()
// ^ constructor() fires

// do some stuff, wait a bit, whatever

// insert that element into the body
document.body.appendChild(myThanksgivingButton)
// ^ connectedCallback() fires

So that’s why connectedCallback() is its own thing, and it’s also arguably the most important thing: probably most of what you want to do with a web component should happen there.

Update: For some more nuance on connectedCallback, see this follow-on article where I build a functional web component and hit some issues with it.

(Probably) You don’t need to store a separate reference to the shadowRoot

Another thing Robin does (I’ve seen this in many other examples) is store a reference to the custom element’s shadowRoot on this, like this._shadowRoot:

Here’s just the stuff I moved out of his constructor and into connectedCallback again:

connectedCallback() {
  this._shadowRoot = this.attachShadow({ mode: 'open' })
  this._shadowRoot.appendChild(template.content.cloneNode(true))
}

So, you don’t really need to store your own reference to the element’s shadowRoot.

Calling this.attachShadow() will automatically stick a property shadowRoot on this for you like ✨magic✨ — but you know now that it’s not really magic because object methods can fiddle with this — so we can simplify things:

class ThanksGivingButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' })
    // now you have this.shadowRoot automatically.
    // you don't need to store it again as this._shadowRoot

    // go ahead and interact with this.shadowRoot now
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
}

If you define other standard callback methods or custom methods, they’ll also have access to this.shadowRoot, assuming they’re getting called after this.attachShadow({ mode: 'open' }) happens.

Actually, attaching a shadowRoot very early is one of the rare good reasons to use a constructor() for web components, because that will happen before anything else. Again, the spec calls this out!

In practice, you probably don’t need to worry about this, but if you wanted to be extra safe and separate out that step, that’d look like:

class ThanksGivingButton extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  // you probably don't want use your template until the element is fully "in" the document
  // so that’s broken out into the connectedCallback() method
  connectedCallback() {
    // the constructor called this.attachShadow()
    // so this.shadowRoot has been set up and you can just access it directly
    // storing and accessing this._shadowRoot is not necessary
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
}

Alright, so, more than 3,000 words later that’s the speedrun of extending classes to make custom elements by way of explaining how objects work (kinda sorta) 😅.

If you want to know more, this 2019 article about the web component API by Danny Moerkerke does a great job taking it from here (tip o’ the cap to @asuh@mastodon.social for sharing this with me).

Let’s look at something else from Robin’s post.

Why “:host”?

A bit later Robin starts exploring encapsulated elements and styles with the Shadow DOM. Using Shadow DOM is a whole other thing with web components, but the idea is the innards of your component (elements and styles) can be isolated from the surrounding document. Like if your component has styles for a button that’s in its Shadow DOM, those button styles only apply to buttons within your web component and won’t affect other buttons on the page.

So Robin was fussing with that, and he tried sticking a <style> tag into the template like so:

template.innerHTML = `
  <style>thanksgiving-button button { background: red; }</style>
  <button>Happy Thanksgiving!</button>
`

But that doesn’t work! As he says:

We can’t select thanksgiving-button like this and instead we have to use a strange CSS selector called :host

Here’s his working code after that adjustment:

template.innerHTML = `
  <style>:host button { background: red; }</style>
  <button>Happy Thanksgiving!</button>
`

What’s the deal with :host? Why can’t you refer to the element by its selector, thanksgiving-button, like you would a div or a p?

Well, the answer becomes clearer when you define the custom element:

class ThanksGivingButton extends HTMLElement {
  // etc
}

//                       ⬇️ this is the tag name
customElements.define('thanksgiving-button', ThanksGivingButton)
//                                             ⬆️ this is the class to use

That customElements.define method is what lets you finally write these doohickeys as HTML like <thanksgiving-button></thanksgiving-button>.

The first string argument is what defines that new tag name you’ll use. But that tag name could be anything (as long as it has a hyphen — that’s a hard requirement of the spec)!

When you’re playing around with these or using them just for yourself, you usually just have a specific element name in mind, but for libraries or sharing it’s typical to let folks use whatever name they want. Maybe they just don’t like your choice, or maybe they already have a <thanksgiving-button> in their codebase and need to use some other name to try out this one (you can’t have two custom elements with the same name).

So! Because you can’t truly know what tag name your custom element will have, that’s what the :host selector in Shadow’d CSS is for. It’s a special selector that says, "whatever the heck my tag name ends up being, target that".

So Robin has to do :host { /* styles for the outer element */} to style the wrapper, whether it’s thanksgiving-button or turkey-day-button or whatever.

Can you do encapsulated styles without Shadow DOM?

I’m not going to get into all the ins and outs of the Shadow DOM and style encapsulation, but here Robin is expressing a very real frustration:

I do want to write my web component like this…

<thanksgiving-button>
  <button>Happy Thanksgiving</button>
</thanksgiving-button>

…and yet have all those styles isolated in some way. I don’t want button styles leaking in, nor do I want button styles inside leaking out. If I could simply encapsulate styles here then I would barely ever need the complexity of all this template stuff and writing HTML with JS and Shadow DOM which feels sickly to me anywho and overly complex.

I’ve already rambled enough about the script side of things to talk about web component styling today, but here’s some of the things smart people have written about this:

Probably the “cleanest” method available now is defining components with Zach Leatherman’s Webc language and it’ll spit out mostly-encapsulated styles for you without using a Shadow DOM. Looks promising! But that’ll introduce a build step and additional tooling.

I was dinking around with some of this on my own and came up with this godawful hack that:

  1. defines styles in a string of CSS, using :host
  2. looks up the tag name at runtime (that’s the this.localName property)
  3. Inserts a stylesheet into the document, replacing :host with the now-known tag name so the selectors work:
class ThanksgivingButton extends HTMLElement {
  // oh god I didn't even get into static class properties…
  static stylesDefined = false

  connectedCallback() {
    if (ThanksgivingButton.stylesDefined) {
      // bail. we already have the styles
      return
    }

    const styles = `
      :host { display: block; }
      :host {
        padding: 30px;
        background: orange;
      }
      :host button {
        /* reset all inherited styling */
        all: unset;
        color: red;
    }
    `

    // replace all the instances of :host with the runtime tagname eg "thanksgiving-button"
    const myStyles = styles.replaceAll(':host', this.localName)

    // insert that stylesheet into the document
    const styleEl = document.createElement('style')
    styleEl.innerHTML = myStyles
    document.head.appendChild(styleEl)

    // make sure we don't insert the styles again
    ThanksgivingButton.stylesDefined = true
  }
}

customElements.define('thanksgiving-button', ThanksgivingButton)

That gives you some rudimentary style encapsulation without the shadow DOM and we’re not introducing any libraries. There’s probably something deeply wrong with this. Like WebC does something similar for styles, but it generates a unique CSS class on the fly, which is better. Don’t do this.

That said, here’s a real rudimentary CodePen of that in action.

Anyway, it’s fun to see people experimenting with these more. I’m happy to pile on!

List of updates
Added a section about using "instanceof"; big overhaul of the examples
Added a link to an article by Danny Moerkerke about web components for further reading
Linked to my follow-on article for select-allosaurus