Anatomy Of A Web Component
Did you know you could build with components without using a framework? Did you know you could use these components with other frameworks? You might be thinking, "How in the..". Let me show you the way... I'll walk you through the shape and usage of native JS components (or custom elements). Strap yourselves in kids, it's going to be a long one!
What Are Web Components
If you're already familiar with Javascript frameworks like React and Vue feel free to skip ahead.
For the uninitiated, Web components are a way of encapsulating functionality into a reusable custom element. We already have a bunch of useful elements in html, like <inputs> and <buttons> but sometimes you need something that the web doesn't provide like a <carousel>. And you might want to share that new thing across multiple files. Most developers use frameworks or Libraries for this. The advantage is, they provide a bunch or features and utilities out-of-the-box. But, what if we want to share our components across multiple, diverse front-end environments? Can you share React components with Vue?? NO! How about Vue components with Svelte? NEGATIVE! Luckily there's a solution for this. Enter custom elements, or what most of us call web components.
What Do They Look Like
I'm so glad you asked. There are a lot of ways to create, write, and configure a web component. At the most basic level, a web component is just a JavaScript class that extends HTMLElement and gets registered with the browser.
Here’s the smallest valid web component you can write:
class MyComponent extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello from a Web Component!';
}
}
customElements.define('my-component', MyComponent);
And then you can use it anywhere:
<my-component></my-component>
Ok, that's the what, let's get into the how of creating components.
Best Practices (and Opinion)
I personally like matching Vue’s structure because it’s pretty ergonomic (and I have the most experience with it). You can also write components in a React/JSX style if you really want.
The beauty of web components is that they’re just JavaScript, so there’s a lot of flexibility—but there are right ways and wrong ways to write them.
The two biggest mistakes I see:
- Re-creating DOM nodes unnecessarily
- Treating everything like a template string
Let’s talk about that.
HTML Templates
There is a time to use template strings, and there is a time to create the element. It's a performance consideration.
Do this:
Use a <template> when your markup is static or mostly static:
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
padding: 1rem;
border: 1px solid #ccc;
}
</style>
<slot></slot>
`;
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
Why this is good:
- Parsed once
- Cloned cheaply
- Clear separation of structure and logic
Not This:
Rebuilding your entire DOM on every update:
class BadCard extends HTMLElement {
connectedCallback() {
this.render();
}
render() {
this.innerHTML = `
<div class="card">
<slot></slot>
</div>
`;
}
}
customElements.define('bad-card', BadCard);
Why this is bad:
- Blows away DOM on every render
- Destroys event listeners
- Slower than you think
Template strings are fine for small, throwaway components, but don’t scale well.
How Web Components are Like Other Frameworks
Let’s map common framework concepts to native web component APIs.
Props
In terms of passing values to the component, we can just use attributes
class MyButton extends HTMLElement {
static get observedAttributes() {
return ['label'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button></button>`;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'label') {
this.shadowRoot.querySelector('button').textContent = newValue;
}
}
}
customElements.define('my-button', MyButton);
Usage:
<my-button label="Click Me"></my-button>
So remember, props are to frameworks as attributes are to Web Components.
Properties & Methods
Not everything in a component needs to flow through attributes.
Because web components are real DOM elements, you can expose properties and methods that are accessible from the outside using normal dot syntax—just like any other JavaScript object.
This is something frameworks often discourage, but for web components it’s a first-class feature.
Public Properties
Public properties can be read or written directly on the element instance.
class ToggleElement extends HTMLElement {
constructor() {
super();
this._open = false;
}
set open(value) {
this._open = Boolean(value);
this.render();
}
get open() {
return this._open;
}
render() {
this.textContent = this._open ? 'Open' : 'Closed';
}
}
customElements.define('toggle-element', ToggleElement);
Usage:
<toggle-element id="toggle"></toggle-element>
<script>
const toggle = document.getElementById('toggle');
toggle.open = true; // Sets property
console.log(toggle.open); // Reads property
</script>
Framerwork analogy:
- React →
ref.current - Vue →
template ref - Web Components → direct element access
Public Methods
Methods allow consumers to imperatively control a component when declarative props aren’t enough.
class ModalElement extends HTMLElement {
open() {
this.setAttribute('open', '');
}
close() {
this.removeAttribute('open');
}
}
customElements.define('modal-element', ModalElement);
Usage:
document.querySelector('modal-element').open();
This is especially useful for:
- Modals
- Tooltips
- Focus management
- Media controls
Private Properties & Methods
Sometimes, you don’t want consumers touching your internal state.
Modern JavaScript allows true private fields using #.
class SecureCounter extends HTMLElement {
#count = 0;
increment() {
this.#count++;
this.textContent = this.#count;
}
}
customElements.define('secure-counter', SecureCounter);
Outside access:
const counter = document.querySelector('secure-counter');
counter.increment(); // ✅ works
counter.#count; // ❌ SyntaxError (truly private)
Why this matters:
- Prevents accidental misuse
- Clearly defines your public API
- Makes components safer to share
When to Use What
- Attributes → declarative, serializable, HTML-friendly
- Properties → dynamic state
- Methods → imperative actions
- Private fields → internal implementation details
This pattern mirrors how native elements:
<video>.play()<input>.value<dialog>.showModal()
Look at us! We're using the platform!
LifeCycle Hooks
class LifeCycleDemo extends HTMLElement {
constructor() {
super();
console.log('constructor');
}
connectedCallback() {
console.log('mounted');
}
disconnectedCallback() {
console.log('unmounted');
}
attributeChangedCallback(name, oldVal, newVal) {
console.log('prop changed', name);
}
}
customElements.define('lifecycle-demo', LifeCycleDemo);
Mapping:
| Framework | Web Component |
|---|---|
| mounted | connectedCallback |
| unmounted | disconnectedCallback |
| watch | attributeChangedCallback |
Events
Events work exactly like you’d expect, because they’re just DOM events.
class EventButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button>Click</button>`;
}
connectedCallback() {
this.shadowRoot
.querySelector('button')
.addEventListener('click', () => {
this.dispatchEvent(
new CustomEvent('my-click', {
detail: { time: Date.now() },
bubbles: true,
composed: true
})
);
});
}
}
customElements.define('event-button', EventButton);
Usage:
<event-button></event-button>
<script>
document.querySelector('event-button')
.addEventListener('my-click', e => {
console.log(e.detail);
});
</script>
Reactivity?
There’s no built-in reactivity system—but you don’t need one.
class CounterElement extends HTMLElement {
constructor() {
super();
this._count = 0;
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<button>+</button>
<span>0</span>
`;
}
connectedCallback() {
this.shadowRoot.querySelector('button')
.addEventListener('click', () => {
this.count++;
});
}
set count(val) {
this._count = val;
this.shadowRoot.querySelector('span').textContent = val;
}
get count() {
return this._count;
}
}
customElements.define('counter-element', CounterElement);
Reactivity is just:
- state
- setters
- updating the DOM
No proxies. No magic. Full control.
Using Web Components with Other Frameworks
Yes, it is absolutely possible—and this is where web components really shine.
Because they’re just HTML, every major framework supports them.
Using in React
function App() {
return (
<my-button
label="Hello"
onMy-click={(e) => console.log(e.detail)}
/>
);
}
Using in Vue
<template>
<event-button @my-click="handleClick" />
</template>
<script>
export default {
methods: {
handleClick(e) {
console.log(e.detail);
}
}
}
</script>
Props go in. Events come out. No adapters required.
Final Thoughts
Web components aren’t here to replace frameworks—but they do replace the need to rewrite the same component over and over again for every framework.
If you:
- Build design systems
- Share components across teams
- Want framework-agnostic UI
Web components are worth learning.
And the best part? You already know enough JavaScript to write them.