# Web Components

> When to activate: web components, custom elements, Shadow DOM, HTML templates, slots, lit-element, custom HTML tags

- Skill: `mattakushi432/web-components` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mattakushi432/web-components`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mattakushi432/web-components/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Mattakushi432 (https://skillmd.com/u/mattakushi432)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mattakushi432/web-components

---

# Web Components

## Custom Elements

```js
class MyButton extends HTMLElement {
  static observedAttributes = ['disabled', 'variant'];

  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('button').addEventListener('click', this.#onClick);
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button')?.removeEventListener('click', this.#onClick);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }

  #onClick = (e) => {
    this.dispatchEvent(new CustomEvent('my-click', { bubbles: true, composed: true }));
  };

  render() {
    if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        button { padding: 0.5em 1em; border-radius: 4px; cursor: pointer; }
        :host([disabled]) button { opacity: 0.5; pointer-events: none; }
      </style>
      <button ${this.hasAttribute('disabled') ? 'disabled' : ''}>
        <slot></slot>
      </button>
    `;
  }
}
customElements.define('my-button', MyButton);
```

## Shadow DOM & Slots

```html
<!-- Definition -->
<template id="card-tmpl">
  <style>
    :host { display: block; border-radius: 8px; overflow: hidden; }
    .body { padding: 1rem; }
  </style>
  <slot name="image"></slot>
  <div class="body">
    <slot name="title"></slot>
    <slot><!-- default slot --></slot>
  </div>
</template>

<!-- Usage -->
<my-card>
  <img slot="image" src="/photo.jpg" alt="">
  <h2 slot="title">Card Title</h2>
  <p>Card body content goes in the default slot.</p>
</my-card>
```

```js
class MyCard extends HTMLElement {
  connectedCallback() {
    const tmpl = document.getElementById('card-tmpl');
    this.attachShadow({ mode: 'open' }).append(tmpl.content.cloneNode(true));
  }
}
customElements.define('my-card', MyCard);
```

## Lit Element

```js
import { LitElement, html, css } from 'lit';
import { property, state } from 'lit/decorators.js';

class CounterEl extends LitElement {
  static styles = css`
    :host { display: flex; gap: 0.5rem; align-items: center; }
    button { min-width: 2rem; }
  `;

  @property({ type: Number }) initial = 0;
  @state() count = 0;

  connectedCallback() {
    super.connectedCallback();
    this.count = this.initial;
  }

  render() {
    return html`
      <button @click=${() => this.count--}>-</button>
      <span>${this.count}</span>
      <button @click=${() => this.count++}>+</button>
    `;
  }
}
customElements.define('counter-el', CounterEl);
```

## adoptedStyleSheets (Shared Styles)

```js
const sheet = new CSSStyleSheet();
sheet.replaceSync(':host { box-sizing: border-box; }');

class MyEl extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sheet]; // shared, not cloned
  }
}
```

## Custom Events Pattern

```js
// Emit from component
this.dispatchEvent(new CustomEvent('value-change', {
  detail: { value: this.value },
  bubbles: true,
  composed: true, // crosses shadow boundary
}));

// Listen from outside
document.querySelector('my-input').addEventListener('value-change', e => {
  console.log(e.detail.value);
});
```

## Form Association

```js
class MyInput extends HTMLElement {
  static formAssociated = true;
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set value(v) {
    this.#internals.setFormValue(v);
  }
}
customElements.define('my-input', MyInput);
```

