customElements
& beyond
Emiliano Pisu
Father of 2 lovely "monsters"
D&D master, player ... whatever
Senior Design Engineer, Sensei & Co-Host
@ Dev Dojo IT
linkedin.com/in/pixu1980
github.com/pixu1980
codepen.io/pixu1980
linkedin.com/company/dev-dojo-it/
youtube.com/@devdojo_it
twitch.tv/devdojo_it
@devdojo_it
How I hope you feel after this talk?
🚨 Bold Opinion Alert 🚨
After years of "experimentation" in UI Libraries and Frameworks, their APIs are becoming canonical
The web standards are
coming back into the field
(honestly... they've always been here)
customElements === Web Components?
!true
Web Components is a common term used to describe a set of 3 web platform APIs
In this talk we will focus only
on customElements API
In particular the customElements v1 API, also known as "customized build-in elements"
but a blazing fast Polyfill is available
Defining a customElement
// standard way to define custom elements
class PixButtonStd extends HTMLButtonElement {
constructor() {
super();
this.setAttribute('type', 'button');
}
}
// register in the standard way
customElements.define(
'pix-button-std', PixButtonStd
);
// usage
<pix-button-std>
Click me!
</pix-button-std>
// runtime
<pix-button-std type="button">
Click me!
</pix-button-std>
// extends pattern way to define custom elements
class PixButton extends HTMLButtonElement {
constructor() {
super();
this.setAttribute('type', 'button');
}
}
// register with the extends option
customElements.define(
'pix-button', PixButton,
{ extends: 'button' }
);
// usage
<button is="pix-button">
Click me!
</button>
// runtime
<button is="pix-button" type="button">
Click me!
</button>
Can you see the difference?
- Both are defined using class syntax
- Both are registered with customElements.define
- The extends pattern keeps the native element's behavior (and a11y/SEO), differently than the standard pattern
Building a Click counter
Custom Element
Step 1: Define the class
class CounterButton extends HTMLButtonElement {
constructor() {
super();
this.count = 0;
}
connectedCallback() {
this.addEventListener('click', this.handleClick);
}
handleClick() {
this.count++;
this.updateText();
};
updateText() {
this.textContent = `Clicked ${this.count} times`;
}
}
Step 2: Register the element
customElements.define('counter-button', CounterButton, {
extends: 'button'
});
Step 3: Use in HTML
<button is="counter-button">Click me</button>
Live Demo:
Lifecycle Callbacks
constructor()
Called when element is created
class PixButton extends HTMLButtonElement {
constructor() {
super();
// Initialize state
this.count = 0;
}
...
}
connectedCallback()
Called when element is inserted into DOM
class PixButton extends HTMLButtonElement {
...
// Element added to DOM
connectedCallback() {
// Initial render
this.render();
// Attach event listeners
this.addEventListener('click', this.handleClick);
// Safe to access DOM and attributes here
console.log(this.getAttribute('value'));
}
...
}
disconnectedCallback()
Called when element is removed from DOM
class PixButton extends HTMLButtonElement {
...
// Element removed from DOM
disconnectedCallback() {
// Remove event listeners
this.removeEventListener('click', this.handleClick);
// Clean up resources, timers, observers
this.interval && clearInterval(this.interval);
}
...
}
attributeChangedCallback()
Called when observed attributes change
class PixButton extends HTMLButtonElement {
...
// Specify observed attributes
static get observedAttributes() {
return ['value', 'disabled'];
}
// Called when observed attributes change
attributeChangedCallback(name, oldValue, newValue) {
name === 'value' && this.updateValue(newValue);
}
}
Lifecycle Timeline
1️⃣ constructor() - Element created
2️⃣ connectedCallback() - Added to DOM
3️⃣ attributeChangedCallback() - Attributes change
4️⃣ disconnectedCallback() - Removed from DOM
Attributes vs Properties
Understanding the difference is key to effective customElements
Attributes (HTML)
<button is="my-button" value="42" disabled>
Click me
</button>
- Always strings (or null)
- Defined in HTML
- Accessible via getAttribute()
- Trigger attributeChangedCallback()
Properties (JavaScript)
const button = document.querySelector('[is="my-button"]');
button.value = 42; // Number
button.disabled = true; // Boolean
button.data = { foo: 'bar' }; // Object
- Can be any JavaScript type
- Accessed in JavaScript
- Don't trigger callbacks automatically
Reflection Pattern
class MyButton extends HTMLButtonElement {
get value() {
return this.getAttribute('value');
}
set value(val) {
this.setAttribute('value', val);
}
}
Sync properties with attributes for consistency whenever is possible/needed
Observed Attributes & Reactivity
Make your elements respond to attribute changes
Declaring observed attributes
class ColorButton extends HTMLButtonElement {
static get observedAttributes() {
return ['color', 'size'];
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`${name} changed: ${oldValue} → ${newValue}`);
name === 'color' &&
(this.style.backgroundColor = newValue);
name === 'size' &&
(this.style.fontSize = newValue);
}
}
Example
class ValidatedInput extends HTMLInputElement {
static get observedAttributes() {
return ['pattern', 'required'];
}
validate() {
const isValid = this.checkValidity();
this.classList.toggle('invalid', !isValid);
this.setAttribute('aria-invalid', String(!isValid));
}
connectedCallback() {
this.addEventListener('input', this.validate);
this.validate();
}
disconnectedCallback() {
this.removeEventListener('input', this.validate);
}
attributeChangedCallback(name, oldValue, newValue) {
this.isConnected && this.validate();
}
}
Usage
<input
is="validated-input"
type="email"
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
required
/>
Changes to pattern or required attributes automatically re-validate
Performance tip
Only observe attributes you actually need to react to
// ❌ Don't do this
static get observedAttributes() {
return ['id', 'class', 'data-foo', 'data-bar', ...];
}
// ✅ Do this
static get observedAttributes() {
return ['value', 'disabled']; // Only what you need
}
Best Practices
- Use attributes for simple, serializable values
- Use properties for complex objects or functions
- Reflect simple properties to attributes when possible
- Prefer native attributes element (e.g., disabled, value)
Extending Different Native Elements
Choose the right base element for your needs
Extending Buttons
class IconButton extends HTMLButtonElement {
connectedCallback() {
const icon = this.getAttribute('icon');
this.innerHTML = `
<span class="icon">${icon}</span>
${this.textContent}
`;
}
}
customElements.define('icon-button', IconButton, {
extends: 'button'
});
Extending Buttons
<button is="icon-button" icon="🚀">Launch</button>
<button is="icon-button" icon="✨">Shine</button>
Extending Inputs
class DateInput extends HTMLInputElement {
connectedCallback() {
this.type = 'date';
this.setAttribute('aria-label', 'Select date');
this.addEventListener('change', () => {
console.log('Date selected:', this.value);
});
}
}
customElements.define('date-input', DateInput, {
extends: 'input'
});
const input = document.querySelector(
'input[is="date-input"]'
);
const output = document.querySelector(
'output#date-demo-out'
);
input.addEventListener('change', () => {
output.textContent = input.value
? `Selected: ${input.value}`
: '';
});
Extending Inputs
Extending Semantic Elements
class CollapsiblePanel extends HTMLDetailsElement {
connectedCallback() {
this.setAttribute('role', 'region');
const header = this.querySelector('[slot="header"]');
header?.addEventListener('click', () => {
this.classList.toggle('collapsed');
const expanded = !this.classList.contains('collapsed');
this.setAttribute('aria-expanded', String(expanded));
});
}
}
customElements.define('collapsible-panel', CollapsiblePanel, {
extends: 'details'
});
Extending Semantic Elements
Toggle
This content is shown or hidden when you press Toggle.Extending Links
class SafeLink extends HTMLAnchorElement {
connectedCallback() {
if (this.target === '_blank') {
const rel = new Set((this.getAttribute('rel') || '')
.split(/\s+/).filter(Boolean));
rel.add('noopener');
rel.add('noreferrer');
this.setAttribute('rel', Array.from(rel).join(' '));
}
const confirmMsg = this.getAttribute('data-confirm');
(confirmMsg) &&
this.addEventListener('click', (e) => {
(!confirm(confirmMsg)) && e.preventDefault();
});
}
}
customElements.define('safe-link', SafeLink, {
extends: 'a'
});
Extending Links
<a is="safe-link" href="https://example.com"
target="_blank"
data-confirm="Open external site?">
External link
</a>
External link
Choosing the right base
| Element | Use when you need |
|---|---|
<button> |
Clickable actions, form submission |
<input> |
User input, form data, validation |
<div> |
Generic container, layout |
<a> |
Navigation, links |
<img> |
Image display with enhancements |
Styling Extended Elements
Practical CSS without Shadow DOM: custom properties, @layer, @scope
Targeting extended elements
/* Low specificity selectors */
button[is="icon-button"] {
display: inline-flex;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
}
/* States (hover, disabled) */
button[is="icon-button"]:hover {
transform: scale(1.05);
}
button[is="icon-button"][disabled] {
opacity: 0.5;
cursor: not-allowed;
}
✅ Use attribute selectors and keep specificity low
Custom properties for theming
button[is="themed-button"] {
background: var(--button-bg, #007bff);
color: var(--button-color, white);
border-radius: var(--button-radius, 4px);
padding: var(--button-padding, 0.5rem 1rem);
}
.theme-dark {
--button-bg: #1a1a1a;
--button-color: #ffffff;
}
.theme-light {
--button-bg: #f0f0f0;
--button-color: #000000;
}
<div class="theme-dark">
<button is="themed-button">Dark</button>
</div>
<div class="theme-light">
<button is="themed-button">Light</button>
</div>
Design tokens and component contract
:root {
--color-primary: #007bff;
--space-sm: 0.5rem;
--space-md: 1rem;
--radius-sm: 4px;
--font-size-base: 1rem;
}
button[is="themed-button"] {
background: var(--button-bg, var(--color-primary));
color: var(--button-color, white);
padding: var(--button-padding,
var(--space-sm) var(--space-md)
);
border-radius:
var(--button-radius, var(--radius-sm));
font-size:
var(--button-font-size, var(--font-size-base));
filter: var(--button-filter, none);
}
Organize with @layer
@layer reset, base, components, utilities, themes;
/* reset */
@layer reset {
*,*::before,*::after { box-sizing: border-box; }
}
/* components */
@layer components {
button[is="themed-button"] {
padding: var(--space-sm) var(--space-md);
}
}
/* themes come last to win over components */
@layer themes {
:root[data-theme="dark"] {
--button-bg: #1a1a1a; --button-color: #ffffff;
}
}
/* base */
@layer base {
:root { --space-sm: 0.5rem; --space-md: 1rem; }
}
✅ Layers control cascade without specificity hacks
Encapsulate with @scope
<div class="card">
<h3>Title</h3>
<button is="themed-button">Action</button>
</div>
@scope (.card) {
:scope {
padding: 1rem; border: 1px solid #ddd;
}
h3 {
margin: 0 0 0.5rem; font-size: 1.25rem;
}
button[is="themed-button"] {
--button-bg: #e8f0ff;
}
}
✅ @scope styles === encapsulation
with no boundaries
State driven styling
/* data-* states */
button[is="toggle-button"][data-state="active"] {
background: var(--active-bg);
}
/* ARIA states */
button[is="menu-button"][aria-expanded="true"] {
transform: rotate(180deg);
}
/* Pseudo-classes */
input[is="validated-input"]:invalid {
border-color: var(--error-color);
}
input[is="validated-input"]:focus {
outline: 2px solid var(--focus-color); outline-offset: 2px;
}
Expected Question #1:
"Nice, so do I have to write
all this `boilerplate` code? every time??"
Of course not!
Expected Question #2:
"So it's possible to replicate or mimic the most common features of Lit, Angular, React, Vue??"
Well, yes!
Expected Question #3:
"Do you mean we can implement metaprogramming, static initialization blocks, mixins, decorator pattern, lifecycle hooks,
two way data binding, reactivity??
in vanillaJS 😱?"
Hell, yeah 🔥!
First of all, extend native HTML elements
export class PixButton extends HTMLButtonElement {}
export class PixInput extends HTMLInputElement {}
export class PixDialog extends HTMLDialogElement {}
export class PixDetails extends HTMLDetailsElement {}
Yes, no base classes === no centralization
So how can we centralize common features like registration, attributes handling, events handling, lifecycle methods... without base classes?
Static Initialization Blocks (MDN)
export class Button extends HTMLButtonElement {
static name = "pix-button";
...
/**
* static initialization
*
* @static
* @memberof Button
*/
static {
componentDecorator("Button", Button);
}
...
}
// import '@pix-ui/button';
// Yippie Kai Yay, motherf...
// ehm... static initialization blocks!
Decorators? Mixins?
export function componentDecorator(componentName, component) {
assertMetaKey(component, "name");
defineCustomElement(component);
applyMixinsToComponent(component);
exposeComponent(componentName, component);
}
export function defineCustomElement(component) {
!customElements?.get(component.name) &&
customElements.define(
component.name,
component,
buildExtendOptions(component)
);
}
export function buildExtendOptions(mixins) {
return mixins.extends ? { extends: mixins.extends } : undefined;
// this allows to
// instead of
}
export function applyMixins(component) {
Object.assign(
component.prototype,
{ componentName: component.name },
component.attributes && { ...buildAttributeHandlers(component.attributes) },
component.events && { ...buildEventHandlers(component.events) },
buildLifecycleMethods(component)
);
component.attributes &&
Object.defineProperty(component, "observedAttributes", {
get: () => Object.keys(component.attributes),
});
}
// Yippie Kai Yay... motherf...
// ehm... vanilla decorators and mixins!
Metaprogramming!
/**
* PixUI Button class
*
* @export
* @class PixButton
* @extends {HTMLButtonElement}
*/
export class PixButton extends HTMLButtonElement {
static name = "pix-button";
static extends = "button";
static attributes = {
disabled: (oldValue, newValue) => {
console.log("disabled changed", oldValue, newValue, this.textContent);
}
}
static events = {
click: function (e) {
console.log("button clicked", this.textContent);
},
}
...
}
// The instance will have
class Button extends HTMLButtonElement {
...
handleDisabledAttributeChanged(oldValue, newValue) => {
console.log("disabled changed", oldValue, newValue, this.textContent);
}
handleClickEvent(e) {
console.log("button clicked", this.textContent);
}
...
}
// Yippie Kai Yay... motherf...
// ehm... vanilla metaprogramming!
Lifecycle methods?
export function buildLifecycleMethods(component) {
return {
handleEvent(e) {
this[`handle${pascalize(e.type)}Event`]?.(e);
},
attributeChangedCallback(name, oldValue, newValue) {
this[`handle${pascalize(name)}AttributeChanged`]?.(oldValue, newValue);
},
connectedCallback() {
for (const event of Object.keys(component.events)) {
this.addEventListener(event, this);
}
},
disconnectedCallback() {
for (const event of Object.keys(component.events)) {
this.removeEventListener(event, this);
}
},
};
}
// Yippie Kai Yay... motherf...
// ehm... vanilla lifecycle methods!
Reactivity & two-way data binding
// Two way binding with customized built-ins and a tiny store
function createStore(initial) {
const listeners = new Map(); // key => Set(callback)
const notify = (k, v) => {
const set = listeners.get(k);
if (!set) return;
for (const fn of set) fn(v);
};
const state = new Proxy({ ...initial }, {
set(target, key, value) {
if (target[key] === value) return true;
target[key] = value;
notify(key, value);
return true;
}
});
return {
state,
subscribe(key, fn) {
const set = listeners.get(key) || new Set();
set.add(fn);
listeners.set(key, set);
fn(state[key]); // initial push
return () => set.delete(fn);
}
};
}
Reactivity & two-way data binding
// Customized built-in input that binds to a store key
class BoundInput extends HTMLInputElement {
connectedCallback() {
this._key = this.getAttribute('data-bind') || '';
if (!this._key) return;
this._unsub = store.subscribe(this._key, (v) => {
if (this.value !== String(v ?? '')) this.value = String(v ?? '');
});
this.addEventListener('input', this);
}
handleEvent(e) {
if (e.type === 'input' && this._key) store.state[this._key] = this.value;
}
disconnectedCallback() {
this.removeEventListener('input', this);
this._unsub?.();
}
}
Reactivity & two-way data binding
// Customized built-in span that reflects a store key
class BoundText extends HTMLSpanElement {
connectedCallback() {
this._key = this.getAttribute('data-bind') || '';
if (!this._key) return;
this._unsub = store.subscribe(this._key, (v) => {
this.textContent = String(v ?? '');
});
}
disconnectedCallback() { this._unsub?.(); }
}
Reactivity & two-way data binding
<label>
Name:
<input is="bound-input" type="text" data-bind="name" placeholder="Type your name" />
</label>
<label>
Nick:
<input is="bound-input" type="text" data-bind="nick" placeholder="Type your nick" />
</label>
<p>Hello, <span is="bound-text" data-bind="name"></span> aka <span is="bound-text" data-bind="nick"></span>!</p>
// Store singleton for the page
const store = createStore({ name: '', nick: '' });
customElements.define('bound-input', BoundInput, { extends: 'input' });
customElements.define('bound-text', BoundText, { extends: 'span' });
// Yippie Kai Yay... motherf...
// vanilla reactivity & two-way data binding!
Hello, aka !
Resources and Links
Call to action
Build on the platform.
Extend, don't replace.
- Extend native elements with the {extends} pattern
- Design clear contracts: attributes, properties, events
- Prefer progressive enhancement and accessibility
- Ship ESM + optional adapters for frameworks
Slides Links
Grazie ❤️!