Build web components using Custom Elements v1 API with Declarative Shadow DOM, attribute-driven state, handleEvent pattern, and zero DOM selection...
This skill enforces a strict architectural pattern for web components:
querySelector, querySelectorAll, or getElementByIdthis: Use this.addEventListener and the handleEvent pattern<template shadowrootmode="open"> for instant renderingThis skill defines the architectural pattern for building web components. When implementing components, combine this skill with javascript-pragmatic-rules for production-quality code:
This skill provides the WHAT (component architecture):
javascript-pragmatic-rules provides the HOW (implementation quality):
Example: Building an <async-button> component:
// Architecture from web-components-architecture skill
class AsyncButton extends HTMLButtonElement {
#controller = null; // Private field for cleanup
connectedCallback() {
this.addEventListener('click', this);
}
// Using handleEvent pattern from web-components-architecture
async handleEvent(e) {
if (e.type === 'click') {
// Rule 2 from javascript-pragmatic-rules: Timeout async operations
this.#controller = new AbortController();
const timeoutId = setTimeout(() => this.#controller.abort(), 5_000);
try {
const response = await fetch(this.getAttribute('data-url'), {
signal: this.#controller.signal
});
clearTimeout(timeoutId);
// Handle response...
} catch (error) {
// Rule 1 from javascript-pragmatic-rules: Handle rejections
if (error.name === 'AbortError') {
console.warn('Request timed out');
} else {
throw new Error('Request failed', { cause: error });
}
}
}
}
// Rule 4 from javascript-pragmatic-rules: Clean up resources
disconnectedCallback() {
this.removeEventListener('click', this);
if (this.#controller) this.#controller.abort();
}
}
Key Integration Points:
connectedCallback with javascript-pragmatic-rules Rule 4 (cleanup)handleEvent with javascript-pragmatic-rules Rules 1-2 (async safety)javascript-pragmatic-rules Rule 5 (immutability)See javascript-pragmatic-rules skill for comprehensive JavaScript best practices.
Extend native HTML elements to preserve built-in accessibility and behavior.
class AsyncAction extends HTMLButtonElement {
connectedCallback() {
// Principle: Event Delegation on Self
this.addEventListener('click', this);
}
// Principle: HandleEvent pattern avoids binding 'this'
async handleEvent(e) {
if (e.type === 'click') {
// Principle: Attribute-Driven State (Input)
this.setAttribute('aria-busy', 'true');
this.disabled = true;
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 1_500));
this.removeAttribute('aria-busy');
this.disabled = false;
// Principle: Events are the ONLY output
this.dispatchEvent(new CustomEvent('action-complete', {
bubbles: true,
detail: { originalEvent: e }
}));
}
}
disconnectedCallback() {
this.removeEventListener('click', this);
}
}
// Register with { extends: 'button' }
customElements.define('async-action', AsyncAction, { extends: 'button' });
Usage in HTML:
<button is="async-action" aria-label="Add to cart">
Add to Cart
</button>
Use for layout containers and composite components.
class ProductCard extends HTMLElement {
// Purely declarative - no logic needed if using DSD
}
customElements.define('product-card', ProductCard);
Use <template shadowrootmode="open"> for instant rendering without JavaScript.
<product-card>
<template shadowrootmode="open">
<style>
:host {
display: block;
max-width: 300px;
}
/* Attribute-Driven Styling */
button[aria-busy="true"] {
opacity: 0.7;
cursor: wait;
}
.card {
background: var(--card-bg, white);
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.footer {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
<article class="card" part="container">
<slot name="header"></slot>
<slot></slot>
<div class="footer">
<slot name="price">Free</slot>
<button is="async-action" part="action-btn">
Add to Cart
</button>
</div>
</article>
</template>
<h3 slot="header">Product Title</h3>
<p>Product description goes here.</p>
<strong slot="price">$450.00</strong>
</product-card>
State flows IN via attributes and OUT via events.
class DataDisplay extends HTMLElement {
connectedCallback() {
// Read initial state from attributes
const apiUrl = this.getAttribute('data-url');
const refreshInterval = this.getAttribute('refresh-interval') || 5_000;
this.setAttribute('status', 'loading');
this.loadData(apiUrl);
}
async loadData(url) {
try {
const response = await fetch(url);
const data = await response.json();
// State change via attribute
this.setAttribute('status', 'loaded');
this.setAttribute('data-count', data.items.length);
// Output via event
this.dispatchEvent(new CustomEvent('data-loaded', {
bubbles: true,
detail: { data }
}));
} catch (error) {
this.setAttribute('status', 'error');
this.dispatchEvent(new CustomEvent('data-error', {
bubbles: true,
detail: { error: error.message }
}));
}
}
// Observe attribute changes
static get observedAttributes() {
return ['data-url', 'refresh-interval'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === 'data-url' && newValue) {
this.loadData(newValue);
}
}
}
// CORRECT: Dispatch events for output
this.dispatchEvent(new CustomEvent('value-changed', {
bubbles: true,
composed: true, // Cross shadow DOM boundaries
detail: { value: newValue }
}));
// WRONG: Never modify external DOM
// document.querySelector('.result').textContent = value; // β FORBIDDEN
Use the handleEvent interface to avoid .bind(this) and maintain clean memory management.
class MultiEventHandler extends HTMLElement {
connectedCallback() {
// Single listener for multiple event types
this.addEventListener('click', this);
this.addEventListener('keydown', this);
this.addEventListener('focus', this);
}
handleEvent(e) {
// Route by event type
switch(e.type) {
case 'click':
this.handleClick(e);
break;
case 'keydown':
this.handleKeydown(e);
break;
case 'focus':
this.handleFocus(e);
break;
}
}
handleClick(e) {
this.setAttribute('last-interaction', 'click');
}
handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
this.setAttribute('last-interaction', 'keyboard');
}
}
handleFocus(e) {
this.setAttribute('focused', 'true');
}
disconnectedCallback() {
this.removeEventListener('click', this);
this.removeEventListener('keydown', this);
this.removeEventListener('focus', this);
}
}
/* Consumer defines theme */
:root {
--primary-color: #6200ea;
--bg-color: #ffffff;
--spacing: 1rem;
}
product-card {
--card-bg: var(--bg-color);
--card-padding: var(--spacing);
}
<template shadowrootmode="open">
<style>
.internal-element {
/* internal styles */
}
</style>
<!-- Expose parts for external styling -->
<div class="internal-element" part="container">
<button part="action-btn">Click me</button>
</div>
</template>
/* External styling via ::part() */
product-card::part(container) {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
product-card::part(action-btn) {
background-color: var(--primary-color);
color: white;
}
<style>
/* Style the host element */
:host {
display: block;
font-family: system-ui, sans-serif;
}
/* State-based styling */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* Context-based styling */
:host-context(.dark-mode) {
background: #1a1a1a;
color: #ffffff;
}
</style>
class LifecycleExample extends HTMLElement {
constructor() {
super();
// ONLY: Initialize instance variables
// DO NOT: Access attributes, children, or DOM
}
connectedCallback() {
// Element added to DOM
// DO: Set up event listeners, start timers, fetch data
this.addEventListener('click', this);
const initialValue = this.getAttribute('value');
if (initialValue) {
this.initialize(initialValue);
}
}
disconnectedCallback() {
// Element removed from DOM
// DO: Clean up event listeners, timers, connections
this.removeEventListener('click', this);
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
attributeChangedCallback(name, oldValue, newValue) {
// Observed attribute changed
if (oldValue === newValue) return;
switch(name) {
case 'value':
this.updateValue(newValue);
break;
case 'disabled':
this.updateDisabled(newValue !== null);
break;
}
}
adoptedCallback() {
// Element moved to new document
// Rarely needed - usually for iframes
}
static get observedAttributes() {
return ['value', 'disabled', 'theme'];
}
}
Use ElementInternals API for form-associated custom elements.
class CustomInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
}
connectedCallback() {
this.addEventListener('input', this);
this.updateValue(this.getAttribute('value') || '');
}
handleEvent(e) {
if (e.type === 'input') {
const value = e.target.value;
this.updateValue(value);
}
}
updateValue(value) {
// Update form value
this.internals.setFormValue(value);
// Validate
if (!value) {
this.internals.setValidity(
{ valueMissing: true },
'This field is required'
);
} else {
this.internals.setValidity({});
}
// Emit event
this.dispatchEvent(new CustomEvent('value-changed', {
bubbles: true,
detail: { value }
}));
}
// Form lifecycle callbacks
formResetCallback() {
this.updateValue('');
}
formDisabledCallback(disabled) {
this.setAttribute('aria-disabled', disabled);
}
}
customElements.define('custom-input', CustomInput);
class AccessibleToggle extends HTMLButtonElement {
connectedCallback() {
this.addEventListener('click', this);
// Set initial ARIA state
if (!this.hasAttribute('aria-pressed')) {
this.setAttribute('aria-pressed', 'false');
}
}
handleEvent(e) {
if (e.type === 'click') {
const isPressed = this.getAttribute('aria-pressed') === 'true';
this.setAttribute('aria-pressed', String(!isPressed));
this.dispatchEvent(new CustomEvent('toggle', {
bubbles: true,
detail: { pressed: !isPressed }
}));
}
}
}
customElements.define('accessible-toggle', AccessibleToggle, { extends: 'button' });
class KeyboardNav extends HTMLElement {
connectedCallback() {
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '0');
}
this.addEventListener('keydown', this);
}
handleEvent(e) {
if (e.type === 'keydown') {
switch(e.key) {
case 'Enter':
case ' ':
e.preventDefault();
this.activate();
break;
case 'Escape':
this.deactivate();
break;
}
}
}
activate() {
this.setAttribute('active', 'true');
this.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
}
deactivate() {
this.removeAttribute('active');
this.dispatchEvent(new CustomEvent('deactivated', { bubbles: true }));
}
}
/* async-action.js */
class AsyncAction extends HTMLButtonElement {
connectedCallback() {
this.addEventListener('click', this);
}
async handleEvent(e) {
if (e.type === 'click') {
// Prevent double-clicks
if (this.getAttribute('aria-busy') === 'true') {
return;
}
// Set loading state
this.setAttribute('aria-busy', 'true');
this.disabled = true;
const originalText = this.textContent;
const loadingText = this.getAttribute('loading-text') || 'Loading...';
this.textContent = loadingText;
try {
// Dispatch event for external handler
const actionEvent = new CustomEvent('async-action', {
bubbles: true,
cancelable: true,
detail: {
button: this,
originalEvent: e
}
});
const shouldContinue = this.dispatchEvent(actionEvent);
if (shouldContinue) {
// Simulate async work (in real usage, parent handles this)
await new Promise((resolve) => setTimeout(resolve, 1_500));
// Success state
this.setAttribute('aria-busy', 'false');
this.removeAttribute('aria-busy');
this.dispatchEvent(new CustomEvent('action-complete', {
bubbles: true,
detail: { success: true }
}));
}
} catch (error) {
// Error state
this.setAttribute('aria-busy', 'false');
this.removeAttribute('aria-busy');
this.dispatchEvent(new CustomEvent('action-error', {
bubbles: true,
detail: { error: error.message }
}));
} finally {
// Reset state
this.textContent = originalText;
this.disabled = false;
}
}
}
disconnectedCallback() {
this.removeEventListener('click', this);
}
}
customElements.define('async-action', AsyncAction, { extends: 'button' });
HTML Usage:
<button
is="async-action"
loading-text="Saving..."
aria-label="Save changes">
Save
</button>
<script>
document.addEventListener('async-action', async (e) => {
// Handle the action
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ data: 'example' })
});
if (!response.ok) {
throw new Error('Save failed');
}
});
</script>
// WRONG
connectedCallback() {
const button = this.querySelector('button'); // β
button.addEventListener('click', ...);
}
// WRONG
handleClick() {
document.getElementById('result').textContent = 'Done'; // β
}
// WRONG
updateContent(data) {
this.innerHTML = `<div>${data}</div>`; // β
}
// WRONG (use Declarative Shadow DOM instead)
constructor() {
super();
this.attachShadow({ mode: 'open' }); // β
this.shadowRoot.innerHTML = '<div>...</div>'; // β
}
// WRONG
let globalState = {}; // β
class MyComponent extends HTMLElement {
connectedCallback() {
globalState.value = 'bad'; // β
}
}
// test.html
<!DOCTYPE html>
<html>
<head>
<script type="module" src="./async-action.js"></script>
</head>
<body>
<button is="async-action" id="test-btn">Test</button>
<script type="module">
const btn = document.getElementById('test-btn');
// Test attribute-driven state
btn.addEventListener('action-complete', (e) => {
console.log('β Action completed', e.detail);
});
// Programmatically trigger
btn.click();
// Verify state
console.assert(
btn.getAttribute('aria-busy') === 'true',
'Should be busy during action'
);
</script>
</body>
</html>
Components should degrade gracefully without JavaScript:
<!-- Works as regular button if JS fails -->
<button is="async-action" formaction="/submit" formmethod="post">
Submit Form
</button>
<!-- Works as regular link if JS fails -->
<a is="spa-link" href="/page">
Navigate
</a>
When implementing web components:
handleEvent() for event delegationquerySelector or similar methods::part()components/
βββ async-action/
β βββ async-action.js
β βββ async-action.test.html
β βββ README.md
βββ product-card/
β βββ product-card.js
β βββ product-card.test.html
β βββ README.md
βββ index.js (exports all components)