mgd34msu/goodvibes-gemini

web-components

Creates reusable custom HTML elements using Web Components standards including Custom Elements, Shadow DOM, templates, and slots. Use when building framework-agnostic components, creating design systems, or when user mentions web components, custom elements, or shadow DOM.

First seen Mar 29, 2026

Installation

$ npx skills add mgd34msu/goodvibes-gemini --skill web-components

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from mgd34msu/goodvibes-gemini · top by installs.

npx skills add mgd34msu/goodvibes-gemini

Browse all from mgd34msu/goodvibes-gemini

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Also listed on

Alternate registries and mirrors of this skill.

Repository health

Stars 4
License MIT
Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 17,344 B
  • docs SUMMARY.md 295 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 2 installs

SKILL.md

Web Components

Native browser APIs for creating reusable, encapsulated custom HTML elements.

Quick Start

<!-- Use the component -->
<user-card name="Alice" role="Admin"></user-card>

<script>
class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .card { padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
        h2 { margin: 0 0 0.5rem; }
        .role { color: #666; font-size: 0.9rem; }
      </style>
      <div class="card">
        <h2>${this.getAttribute('name')}</h2>
        <span class="role">${this.getAttribute('role')}</span>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);
</script>

Custom Elements

Basic Definition

class MyButton extends HTMLElement {
  // Called when element is created
  constructor() {
    super();
    // Initialize state, attach shadow DOM
  }

  // Called when element is added to DOM
  connectedCallback() {
    console.log('Element added to page');
    this.render();
  }

  // Called when element is removed from DOM
  disconnectedCallback() {
    console.log('Element removed from page');
    this.cleanup();
  }

  // Called when element is moved to new document
  adoptedCallback() {
    console.log('Element moved to new document');
  }

  // Called when observed attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
    this.render();
  }

  // List of attributes to observe
  static get observedAttributes() {
    return ['variant', 'disabled', 'size'];
  }
}

customElements.define('my-button', MyButton);

Extending Built-in Elements

class FancyButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', this.handleClick);
  }

  handleClick() {
    this.classList.add('clicked');
  }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });

// Usage: <button is="fancy-button">Click me</button>

Shadow DOM

Open vs Closed Mode

class OpenComponent extends HTMLElement {
  constructor() {
    super();
    // Accessible via element.shadowRoot
    this.attachShadow({ mode: 'open' });
  }
}

class ClosedComponent extends HTMLElement {
  #shadow;

  constructor() {
    super();
    // Not accessible from outside
    this.#shadow = this.attachShadow({ mode: 'closed' });
  }
}

Style Encapsulation

class StyledComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        /* Styles only apply inside shadow DOM */
        :host {
          display: block;
          padding: 1rem;
          border: 1px solid #ccc;
        }

        /* Style based on host attributes */
        :host([variant="primary"]) {
          background: #007bff;
          color: white;
        }

        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }

        /* Style when host matches selector */
        :host-context(.dark-theme) {
          background: #333;
          color: white;
        }

        /* Internal elements */
        .title {
          font-size: 1.5rem;
          font-weight: bold;
        }

        /* Style slotted content */
        ::slotted(p) {
          margin: 0.5rem 0;
        }

        ::slotted(*) {
          color: inherit;
        }
      </style>

      <div class="title"><slot name="title"></slot></div>
      <div class="content"><slot></slot></div>
    `;
  }
}

CSS Parts for External Styling

class PartComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        .header { padding: 1rem; }
        .body { padding: 1rem; }
      </style>

      <div class="header" part="header">
        <slot name="header"></slot>
      </div>
      <div class="body" part="body">
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('part-component', PartComponent);
/* External CSS can style parts */
part-component::part(header) {
  background: #f5f5f5;
  border-bottom: 1px solid #ddd;
}

part-component::part(body) {
  background: white;
}

CSS Custom Properties (Theming)

class ThemableComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          /* Defaults with fallbacks */
          --button-bg: var(--theme-primary, #007bff);
          --button-color: var(--theme-text, white);
          --button-radius: var(--theme-radius, 4px);
        }

        button {
          background: var(--button-bg);
          color: var(--button-color);
          border-radius: var(--button-radius);
          border: none;
          padding: 0.5rem 1rem;
          cursor: pointer;
        }
      </style>

      <button><slot></slot></button>
    `;
  }
}
/* Theme the component from outside */
:root {
  --theme-primary: #6366f1;
  --theme-text: white;
  --theme-radius: 8px;
}

Templates and Slots

HTML Template

<template id="user-card-template">
  <style>
    .card {
      display: flex;
      align-items: center;
      gap: 1rem;
      padding: 1rem;
      border: 1px solid #e5e7eb;
      border-radius: 8px;
    }
    .avatar {
      width: 48px;
      height: 48px;
      border-radius: 50%;
      object-fit: cover;
    }
    .info h3 {
      margin: 0;
      font-size: 1rem;
    }
    .info p {
      margin: 0.25rem 0 0;
      color: #6b7280;
      font-size: 0.875rem;
    }
  </style>

  <div class="card">
    <img class="avatar" src="" alt="">
    <div class="info">
      <h3><slot name="name">Unknown User</slot></h3>
      <p><slot name="email">No email</slot></p>
    </div>
    <div class="actions">
      <slot name="actions"></slot>
    </div>
  </div>
</template>

<script>
class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    const template = document.getElementById('user-card-template');
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }

  connectedCallback() {
    const img = this.shadowRoot.querySelector('.avatar');
    img.src = this.getAttribute('avatar') || '/default-avatar.png';
    img.alt = this.getAttribute('name') || 'User avatar';
  }
}

customElements.define('user-card', UserCard);
</script>

<!-- Usage -->
<user-card avatar="/alice.jpg">
  <span slot="name">Alice Johnson</span>
  <span slot="email">[email protected]</span>
  <button slot="actions">Follow</button>
</user-card>

Named Slots

class TabPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        .tabs {
          display: flex;
          border-bottom: 1px solid #ddd;
        }
        .panels {
          padding: 1rem;
        }
      </style>

      <div class="tabs">
        <slot name="tab"></slot>
      </div>
      <div class="panels">
        <slot name="panel"></slot>
      </div>
    `;
  }
}

// Usage:
// <tab-panel>
//   <button slot="tab">Tab 1</button>
//   <button slot="tab">Tab 2</button>
//   <div slot="panel">Content 1</div>
//   <div slot="panel">Content 2</div>
// </tab-panel>

Slot Events

class SlotContainer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<slot></slot>`;

    const slot = this.shadowRoot.querySelector('slot');
    slot.addEventListener('slotchange', (e) => {
      const assigned = slot.assignedElements();
      console.log('Slotted elements:', assigned);

      // React to content changes
      this.updateLayout(assigned);
    });
  }

  updateLayout(elements) {
    elements.forEach((el, i) => {
      el.style.order = i;
    });
  }
}

Reactive Properties

Property/Attribute Reflection

class ReactiveElement extends HTMLElement {
  static get observedAttributes() {
    return ['count', 'disabled'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._count = 0;
  }

  // Reflect property to attribute
  get count() {
    return this._count;
  }

  set count(value) {
    this._count = Number(value);
    this.setAttribute('count', this._count);
    this.render();
  }

  // Boolean attribute
  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'count' && oldValue !== newValue) {
      this._count = Number(newValue);
      this.render();
    }
  }

  render() {
    this.shadowRoot.innerHTML = `
      <button ${this.disabled ? 'disabled' : ''}>
        Count: ${this.count}
      </button>
    `;
  }
}

Form-Associated Custom Elements

class CustomInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        input {
          padding: 0.5rem;
          border: 1px solid #ccc;
          border-radius: 4px;
        }
        input:focus {
          outline: 2px solid #007bff;
        }
        :host(:invalid) input {
          border-color: red;
        }
      </style>
      <input type="text">
    `;

    this.input = this.shadowRoot.querySelector('input');
    this.input.addEventListener('input', () => this.handleInput());
  }

  handleInput() {
    this.internals.setFormValue(this.input.value);

    // Validation
    if (this.hasAttribute('required') && !this.input.value) {
      this.internals.setValidity(
        { valueMissing: true },
        'This field is required',
        this.input
      );
    } else {
      this.internals.setValidity({});
    }
  }

  // Form lifecycle
  formAssociatedCallback(form) {
    console.log('Associated with form:', form);
  }

  formDisabledCallback(disabled) {
    this.input.disabled = disabled;
  }

  formResetCallback() {
    this.input.value = '';
    this.internals.setFormValue('');
  }

  // Expose validity state
  get validity() { return this.internals.validity; }
  get validationMessage() { return this.internals.validationMessage; }
  checkValidity() { return this.internals.checkValidity(); }
  reportValidity() { return this.internals.reportValidity(); }
}

customElements.define('custom-input', CustomInput);

// Usage in form
// <form>
//   <custom-input name="username" required></custom-input>
//   <button type="submit">Submit</button>
// </form>

Event Handling

Custom Events

class CounterElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._count = 0;
    this.render();
  }

  increment() {
    this._count++;
    this.render();

    // Dispatch custom event (bubbles through shadow DOM)
    this.dispatchEvent(new CustomEvent('count-changed', {
      detail: { count: this._count },
      bubbles: true,
      composed: true  // Crosses shadow DOM boundary
    }));
  }

  render() {
    this.shadowRoot.innerHTML = `
      <button id="btn">Count: ${this._count}</button>
    `;
    this.shadowRoot.getElementById('btn')
      .addEventListener('click', () => this.increment());
  }
}

// Listen from outside
document.querySelector('counter-element')
  .addEventListener('count-changed', (e) => {
    console.log('New count:', e.detail.count);
  });

Event Retargeting

class EventDemo extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <button id="inner">Click me</button>
    `;

    // Event target inside shadow DOM
    this.shadowRoot.getElementById('inner')
      .addEventListener('click', (e) => {
        console.log('Inside shadow:', e.target); // <button>
      });
  }
}

// Outside shadow DOM, target is retargeted to host
document.querySelector('event-demo')
  .addEventListener('click', (e) => {
    console.log('Outside shadow:', e.target); // <event-demo>
    console.log('Composed path:', e.composedPath()); // Full path
  });

Complete Example: Modal Component

class AppModal extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
        }

        :host([open]) {
          display: block;
        }

        .backdrop {
          position: fixed;
          inset: 0;
          background: rgba(0, 0, 0, 0.5);
          display: flex;
          align-items: center;
          justify-content: center;
          z-index: 1000;
        }

        .modal {
          background: white;
          border-radius: 8px;
          max-width: 500px;
          width: 90%;
          max-height: 90vh;
          overflow: auto;
          box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
        }

        .header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          padding: 1rem;
          border-bottom: 1px solid #e5e7eb;
        }

        .close-btn {
          background: none;
          border: none;
          font-size: 1.5rem;
          cursor: pointer;
          padding: 0.25rem;
        }

        .body {
          padding: 1rem;
        }

        .footer {
          padding: 1rem;
          border-top: 1px solid #e5e7eb;
          display: flex;
          justify-content: flex-end;
          gap: 0.5rem;
        }
      </style>

      <div class="backdrop" part="backdrop">
        <div class="modal" part="modal" role="dialog" aria-modal="true">
          <div class="header" part="header">
            <slot name="title"><h2>Modal</h2></slot>
            <button class="close-btn" aria-label="Close">&times;</button>
          </div>
          <div class="body" part="body">
            <slot></slot>
          </div>
          <div class="footer" part="footer">
            <slot name="footer"></slot>
          </div>
        </div>
      </div>
    `;

    this.backdrop = this.shadowRoot.querySelector('.backdrop');
    this.closeBtn = this.shadowRoot.querySelector('.close-btn');

    this.closeBtn.addEventListener('click', () => this.close());
    this.backdrop.addEventListener('click', (e) => {
      if (e.target === this.backdrop) this.close();
    });
  }

  get open() {
    return this.hasAttribute('open');
  }

  set open(value) {
    if (value) {
      this.setAttribute('open', '');
    } else {
      this.removeAttribute('open');
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'open') {
      if (newValue !== null) {
        this.trapFocus();
        document.body.style.overflow = 'hidden';
      } else {
        document.body.style.overflow = '';
      }
    }
  }

  show() {
    this.open = true;
    this.dispatchEvent(new CustomEvent('modal-open', { bubbles: true, composed: true }));
  }

  close() {
    this.open = false;
    this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true }));
  }

  trapFocus() {
    const focusable = this.shadowRoot.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    first?.focus();

    this.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        this.close();
      }
      if (e.key === 'Tab') {
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    });
  }
}

customElements.define('app-modal', AppModal);
<!-- Usage -->
<button onclick="document.querySelector('app-modal').show()">
  Open Modal
</button>

<app-modal>
  <h2 slot="title">Confirm Action</h2>
  <p>Are you sure you want to proceed?</p>
  <div slot="footer">
    <button onclick="this.closest('app-modal').close()">Cancel</button>
    <button onclick="handleConfirm()">Confirm</button>
  </div>
</app-modal>

Reference Files

  • [lit-integration.md](references/lit-integration.md) - Using Lit for simpler web components
  • [testing.md](references/testing.md) - Testing web components