customElements
& beyond

Pixu

Emiliano Pisu

Father of 2 lovely "monsters"

D&D master, player ... whatever

Senior Design Engineer, Sensei & Co-Host
@ Dev Dojo IT

pixu1980@linkedin linkedin.com/in/pixu1980
pixu1980@github github.com/pixu1980
pixu1980@codepen codepen.io/pixu1980
devdojo@linkedin linkedin.com/company/dev-dojo-it/
devdojo@youtube youtube.com/@devdojo_it
devdojo@twitch twitch.tv/devdojo_it
devdojo@telegram @devdojo_it

How I hope you feel after this talk?

John McClane having an epiphany

🚨 Bold Opinion Alert 🚨

After years of "experimentation" in UI Libraries and Frameworks, their APIs are becoming canonical

Hans Gruber from Die Hard feeling a shiver

The web standards are
coming back into the field
(honestly... they've always been here)

Simon Peter Gruber saying: 'Somebody had some fun 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"

Note: unfortunately not supported in Safari...
but a blazing fast Polyfill is available
Let's dive in
Let's dive in Let's dive in Let's dive in Let's dive in Let's dive in

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>
      
    
✅ Inherits: click events, form submission, keyboard support, disabled state

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

No date selected yet!
✅ Inherits: form integration, validation, value property, focus management

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
💡 Just an advice: Always extend the most semantic element, the closest element to your use case

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 🔥!

Hans Gruber saying: 'Enough jokes'

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!
  
    
John McClane saying: 'Oops'

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!
  
    
John McClane saying: 'Oops'

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!
      
    
John McClane saying: 'Oops'

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!
      
    
John McClane saying: 'Oops'

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

Slides
Slides
Slides Repo on Github
Slides Repo
© 1987-2025 - Warner Bros. Entertainment Inc.
Pixu

Grazie ❤️!

Dev Dojo IT Logo