Web Components for Modern Devs
In this post we’ll simplify UI with Web Components and build a small 3D dice component along the way.
“As developers, we all know that reusing code as much as possible is a good idea. Web Components aim to solve such problems.” — Mozilla
Try a published component
I built a dice webcomponent in Github. All I did to show it on this blog is:
npm install https://github.com/vroegop/dice-webcomponentimport 'die-3d/dice-tray';
<dice-tray-3d>
<die-3d></die-3d>
</dice-tray-3d>
Click to roll and drag the tray for 3D effects! (No mobile for this demo, sorry)
In the ever-evolving landscape of web development, the quest for more efficient, scalable, and maintainable front-end solutions is perpetual. In this blog post, we dive into simplifying UI with Web Components.
This approach not only leads to more robust and less error-prone applications but also significantly streamlines the development process. The best thing of all: you can use them in all frameworks without a big learning curve, or without frameworks at all!
Web Components vs. framework components
If you’ve used frameworks, components often come with framework‑specific imports or file extensions.
// Angular
import { Component } from '@angular/core';
@Component({
selector: 'app-component-overview',
templateUrl: './component-overview.component.html',
})
// React
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
Web Components are native to the browser. That brings upsides and tradeoffs:
Upsides
- Work in every framework because they’re just DOM
- Encapsulation via Shadow DOM keeps styles from leaking
- No framework needed to create them (though helpers exist)
- Easy to integrate into Storybook and design systems
Downsides
- No out‑of‑the‑box routing
- No built‑in state management
- IDEs don’t always autocomplete attributes/styles for custom elements
The simplest example
Web Components can be written with plain JavaScript, no tooling required.
class HelloWorld extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<span class="hello">Hello World!</span>`;
}
}
customElements.define('hello-world', HelloWorld);
Use it in HTML and you will see the span saying ‘Hello World!’:
<body>
<hello-world></hello-world>
<script src="hello-world.js"></script>
</body>
Without any frameworks, you’ve created a new HTML‑like tag.
Shadow DOM in 30 seconds
Shadow DOM encapsulates structure, style, and behavior so components don’t leak into the rest of the page.
console.log(document.querySelector('.hi'));
// <span class="hi">Hello World!</span>
console.log(document.querySelector('hello-world'));
// <hello-world></hello-world>
What you don’t see from the outside is the element’s internal Shadow DOM tree.
Useful patterns
Rendering HTML and CSS
class AwesomeStuff extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
css() {
return `
img { width: 150px; }
`;
}
render() {
this.shadowRoot.innerHTML = `
<img src="https://cataas.com/cat" />
<style>${this.css()}</style>
`;
}
}
customElements.define('awesome-stuff', AwesomeStuff);
Attributes (inputs)
Use attributes to pass data into a component.
<awesome-stuff data-name="Randy"></awesome-stuff>
class AwesomeStuff extends HTMLElement {
static observedAttributes = ['name'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.name = 'World';
this.render();
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (attrName === 'name') {
this.name = newValue;
this.render();
}
}
render() {
this.shadowRoot.innerHTML = `<h1>Hello ${this.name}!</h1>`;
}
}
customElements.define('awesome-stuff', AwesomeStuff);
Both name and data-name can be used. Some frameworks strictly validate attributes; data-* is always valid. See MDN: https://developer.mozilla.org/docs/Learn/HTML/Howto/Use_data_attributes
Events (outputs)
Components can emit custom events.
class AwesomeStuff extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
connectedCallback() {
const btn = this.shadowRoot.querySelector('#hello-button');
btn.addEventListener('click', () =>
this.dispatchEvent(new CustomEvent('custom-event-hello', { detail: 'Data from hello button' }))
);
}
render() {
this.shadowRoot.innerHTML = `<button id="hello-button">Hello!</button>`;
}
}
customElements.define('awesome-stuff', AwesomeStuff);
function setupEventListeners() {
const el = document.querySelector('awesome-stuff');
el.addEventListener('custom-event-hello', (e) => {
alert('Custom event triggered: ' + e.detail);
});
}
Slots (composition)
class SlottedThing extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<slot><p>Default content.</p></slot>`;
}
}
customElements.define('slotted-thing', SlottedThing);
<slotted-thing>I am now inside of slotted-thing</slotted-thing>
The default content only renders if the consumer doesn’t provide any content (not even whitespace).
A more complex example: the 3D dice
Let’s build a reusable 3D dice. First, a bit of HTML:
<div id="die">
<div class="face face-1">1</div>
<div class="face face-2">2</div>
<div class="face face-3">3</div>
<div class="face face-4">4</div>
<div class="face face-5">5</div>
<div class="face face-6">6</div>
</div>
Then some CSS to size the faces and show a spinning cube so we can see the 3D effect:
#die {
position: relative;
width: 50px;
height: 50px;
font-size: 25px;
transform-style: preserve-3d;
animation: rotate 5s infinite linear;
}
.face {
position: absolute;
width: 50px;
height: 50px;
background: orange;
text-align: center;
font-size: 50px;
line-height: 50px;
}
/* Rotate each face the right way (rotate) and push them half the die size away (translate) from the center */
.face-1 { transform: translateZ(25px); }
.face-2 { transform: translateZ(25px); rotateX(270deg) }
.face-3 { transform: translateZ(25px); rotateY(90deg) }
.face-4 { transform: translateZ(25px); rotateY(270deg) }
.face-5 { transform: translateZ(25px); rotateX(90deg) }
.face-6 { transform: translateZ(25px); rotateY(180deg) }
@keyframes rotate {
0% { transform: rotateX(0deg) rotateY(0deg); }
100% { transform: rotateX(360deg) rotateY(360deg); }
}
This looks awesome already!
Now let’s turn it into a custom element and let consumers choose the face using a value attribute:
class SimpleDie extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
static get observedAttributes() {
return ['value'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value') this.updateFace(newValue);
}
updateFace(value) {
const die = this.shadowRoot.querySelector('#die');
if (!die || !value) return;
switch (String(value)) {
case '1': die.style.transform = 'rotateX(0deg) rotateY(0deg)'; break;
case '2': die.style.transform = 'rotateX(90deg) rotateY(0deg)'; break;
case '3': die.style.transform = 'rotateX(0deg) rotateY(-90deg)'; break;
case '4': die.style.transform = 'rotateX(0deg) rotateY(90deg)'; break;
case '5': die.style.transform = 'rotateX(-90deg) rotateY(0deg)'; break;
case '6': die.style.transform = 'rotateX(0deg) rotateY(-180deg)'; break;
}
}
render() {
this.shadowRoot.innerHTML = `
<div id="die">
<div class="face face-1">1</div>
<div class="face face-2">2</div>
<div class="face face-3">3</div>
<div class="face face-4">4</div>
<div class="face face-5">5</div>
<div class="face face-6">6</div>
</div>
<style>${this.css()}</style>
`;
}
css() {
return `
#die {
--die-size: 50px;
--face-offset: calc(var(--die-size) / 2);
position: relative;
width: var(--die-size);
aspect-ratio: 1 / 1;
font-size: var(--face-offset);
transform-style: preserve-3d;
transition: transform 1s ease-out;
}
.face {
position: absolute;
width: var(--die-size);
aspect-ratio: 1 / 1;
background: orange;
text-align: center;
font-size: var(--die-size);
line-height: var(--die-size);
}
.face-1 { transform: rotateY(0deg) translateZ(var(--face-offset)); }
.face-2 { transform: rotateX(270deg) translateZ(var(--face-offset)); }
.face-3 { transform: rotateY(90deg) translateZ(var(--face-offset)); }
.face-4 { transform: rotateY(270deg) translateZ(var(--face-offset)); }
.face-5 { transform: rotateX(90deg) translateZ(var(--face-offset)); }
.face-6 { transform: rotateY(180deg) translateZ(var(--face-offset)); }
`;
}
}
customElements.define('simple-die', SimpleDie);
const setValue = v => document.querySelector('simple-die').setAttribute('value', v);
<div class="flex flex-col items-center gap-4">
<div class="text-center mb-2">Choose a number:</div>
<div class="flex gap-2">
<button onclick="setValue('1')">1</button>
<button onclick="setValue('2')">2</button>
<button onclick="setValue('3')">3</button>
<button onclick="setValue('4')">4</button>
<button onclick="setValue('5')">5</button>
<button onclick="setValue('6')">6</button>
</div>
<simple-die value="1"></simple-die>
</div>
Why aren’t Web Components mainstream?
They’re widely used in some large companies and design systems. But big frameworks don’t natively steer you toward building them, and many projects prefer framework‑specific patterns for routing/state/testing. WC aren’t a replacement for frameworks; they’re a great foundation for reusable, encapsulated UI.
Conclusion
Web Components are awesome for low‑level, reusable building blocks: custom buttons, inputs, and isolated flows like wizards or forms that appear across apps. Build once, reuse everywhere—framework or not.
Recommendation: if you plan a full app with Web Components, consider a micro‑framework like Lit for nicer lifecycle hooks, data management, and testability. It’s tiny and close to the native spec—everything above still applies.