Frontend No. 073

Web Components skill for Claude Code

Custom elements that behave like native ones. Claude designs a custom element's public API first — attributes, properties, events, slots, parts and CSS variables — then implements it with idempotent lifecycle hooks, composed events and ElementInternals for roles, states and form participation. It handles the traps that waste days: properties set before upgrade, ARIA references that cannot cross shadow roots, and Lit fields that shadow reactive accessors.

  • nameweb-components
  • size237 lines · ≈2,500 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds framework-agnostic custom elements with correct lifecycle, attribute/property reflection, shadow DOM styling (parts, custom properties, adoptedStyleSheets), slots, events that cross shadow boundaries, form-associated controls via ElementInternals, declarative shadow DOM for SSR, and Lit 3. Covers interop with React 19, Vue, Angular, and Svelte. Use when creating a custom element or design-system component library, wrapping widgets for multiple frameworks, making a custom input participate in forms, or debugging styles, events, or ARIA that do not cross the shadow root.

Try asking

  • Build a star-rating input as a web component that works inside a normal form
  • Our Lit component stopped re-rendering after the TypeScript upgrade
  • Package our design-system buttons so React, Vue and plain HTML pages can all use them

Use it when

  • Building a component library shared across frameworks or CMS templates
  • A custom input must submit, reset and validate with its form
  • Styles, events or ARIA labels do not cross a shadow root
  • Integrating custom elements into React, Vue or Angular

Skip it when

How to check the result

The element works from plain HTML attributes, keeps values set before definition, submits and resets inside a form, and announces role and state in a screen reader.

The common mistake it prevents

Pointing aria-labelledby or label for at an element across the shadow boundary, which silently leaves the control without an accessible name.

~/.claude/skills/web-components/SKILL.md

--- name: web-components description: Builds framework-agnostic custom elements with correct lifecycle, attribute/property reflection, shadow DOM styling (parts, custom… license: MIT ---

A good custom element behaves like a native one: it works from HTML attributes and JS properties, emits composed events, exposes styling hooks (custom properties and ::part) instead of leaking internals, participates in forms and accessibility like a built-in control, and cleans up everything it attaches.

Decide first

QuestionRecommendation
One framework app, no reuse elsewhereUse the framework’s components; custom elements add friction
Design system consumed by several frameworks or CMS/templatesCustom elements (Lit or vanilla)
Need style encapsulationShadow DOM
Must inherit page styles (prose, global utilities) or be styled by Tailwind classesLight DOM (no shadow root)
More than a couple of reactive propertiesLit 3 instead of hand-rolled rendering
Server-rendered first paintDeclarative shadow DOM (<template shadowrootmode="open">) or Lit SSR

Workflow

- [ ] 1. Define the public API: attributes, properties, events, slots, parts, CSS custom properties
- [ ] 2. Implement lifecycle with cleanup
- [ ] 3. Reflect state to attributes where CSS or HTML authors need it
- [ ] 4. Style through :host, parts, and custom properties
- [ ] 5. Accessibility: roles, focus, labels (ElementInternals where possible)
- [ ] 6. Forms: formAssociated + ElementInternals for inputs
- [ ] 7. Types, manifest, framework wrappers
- [ ] 8. Test in a real browser and in each consuming framework

Vanilla element skeleton

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: inline-flex; gap: var(--x-toggle-gap, 0.5rem); }
  :host([disabled]) { opacity: 0.5; pointer-events: none; }
  :host(:state(on)) [part="track"] { background: var(--x-toggle-on, #2563eb); }
  [part="track"] { inline-size: 2.5rem; block-size: 1.5rem; border-radius: 999px; background: #94a3b8; }
`);

class XToggle extends HTMLElement {
  static observedAttributes = ["checked", "disabled"];
  #internals = this.attachInternals();

  constructor() {
    super();
    const root = this.attachShadow({ mode: "open", delegatesFocus: true });
    root.adoptedStyleSheets = [sheet];
    root.innerHTML = `<span part="track"></span><slot></slot>`;
    this.#internals.role = "switch";
  }

  connectedCallback() {
    this.#upgrade("checked");
    if (!this.hasAttribute("tabindex")) this.tabIndex = 0;
    this.addEventListener("click", this.#toggle);
    this.addEventListener("keydown", this.#onKey);
    this.#sync();
  }
  disconnectedCallback() {
    this.removeEventListener("click", this.#toggle);
    this.removeEventListener("keydown", this.#onKey);
  }
  attributeChangedCallback() { this.#sync(); }

  get checked() { return this.hasAttribute("checked"); }
  set checked(v) { this.toggleAttribute("checked", Boolean(v)); }

  #toggle = () => {
    this.checked = !this.checked;
    this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
  };
  #onKey = (e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); this.#toggle(); } };
  #sync() {
    this.#internals.ariaChecked = String(this.checked);
    this.#internals.ariaDisabled = String(this.hasAttribute("disabled"));
    this.checked ? this.#internals.states.add("on") : this.#internals.states.delete("on");
  }
  // A property set before the element was defined shadows the accessor; re-apply it.
  #upgrade(prop) {
    if (Object.prototype.hasOwnProperty.call(this, prop)) {
      const v = this[prop]; delete this[prop]; this[prop] = v;
    }
  }
}
customElements.define("x-toggle", XToggle);

Lifecycle rules

  • Constructor: attach shadow root, set up internal DOM. Do not read attributes or children or add attributes to the host — the element may not be parsed yet, and document.createElement throws if the constructor adds attributes.
  • connectedCallback can run multiple times (moves in the DOM). Make setup idempotent and undo it in disconnectedCallback (listeners on window/document, observers, timers).
  • attributeChangedCallback fires only for names in observedAttributes, including before connectedCallback for parsed attributes.
  • Tag names must contain a hyphen; prefix them (acme-) to avoid collisions. Guard double registration in micro-frontends: if (!customElements.get("x-toggle")) customElements.define(...).
  • Hide undefined elements to avoid flashes: x-toggle:not(:defined) { visibility: hidden; }.

Attributes vs. properties

DataAPI
Strings, numbers, booleans authors set in HTMLAttribute + property, reflected
Objects, arrays, functionsProperty only (never serialize JSON into attributes for large data)
State CSS needs to selectReflected attribute or custom state (:state(name))

Boolean attributes are presence-based: checked="false" is still checked.

Styling

  • Inside: :host, :host([attr]), :host(:state(x)), ::slotted(selector) (top-level slotted nodes only).
  • Outside hooks: CSS custom properties (inherit through the shadow boundary) for tokens; part="label" + x-toggle::part(label) for structural styling; exportparts to forward parts from nested components.
  • Page styles do not apply inside shadow roots (except inherited properties like color, font). Share a constructable stylesheet across instances via adoptedStyleSheets instead of <style> per instance.
  • @font-face must be declared in the document, not inside the shadow root.

Events

  • Use bubbles: true, composed: true for events consumers should hear outside the component. Use CustomEvent with a detail for payloads.
  • Name events in lowercase without the on prefix (value-change). Avoid reusing native names with different semantics.
  • Events from inside the shadow root are retargeted: event.target outside is the host. Use event.composedPath() for the original node.

Accessibility

  • ID references (aria-labelledby, for, aria-describedby) cannot cross shadow boundaries. Keep labels inside the same root, accept a label attribute and render it internally, or set semantics on the host via ElementInternals (role, ariaLabel, ariaChecked).
  • delegatesFocus: true forwards host focus to the first focusable child and makes :focus-visible work on the host.
  • Prefer native elements inside the shadow root (<button>, <input>) over recreating their behavior.

Form-associated custom elements

class XRating extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  #value = "";

  get value() { return this.#value; }
  set value(v) {
    this.#value = String(v);
    this.#internals.setFormValue(this.#value);
    if (this.hasAttribute("required") && !this.#value) {
      this.#internals.setValidity({ valueMissing: true }, "Choose a rating", this.shadowRoot?.querySelector("button") ?? undefined);
    } else {
      this.#internals.setValidity({});
    }
  }
  get form() { return this.#internals.form; }
  get validity() { return this.#internals.validity; }
  checkValidity() { return this.#internals.checkValidity(); }

  formResetCallback() { this.value = this.getAttribute("value") ?? ""; }
  formDisabledCallback(disabled) { this.toggleAttribute("aria-disabled", disabled); }
  formStateRestoreCallback(state) { this.value = state; }
}

The element now submits with FormData, resets with the form, participates in :invalid, and works with <label for> pointing at the host.

Lit 3

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

@customElement("x-counter")
export class XCounter extends LitElement {
  static styles = css`:host { display: inline-flex; gap: 0.5rem; }`;
  @property({ type: Number, reflect: true }) accessor count = 0;   // standard decorators need `accessor`
  @state() private accessor busy = false;

  render() {
    return html`<button part="button" ?disabled=${this.busy} @click=${this.#inc}>+</button><output>${this.count}</output>`;
  }
  #inc() {
    this.count++;
    this.dispatchEvent(new CustomEvent("count-change", { detail: this.count, bubbles: true, composed: true }));
  }
}
declare global { interface HTMLElementTagNameMap { "x-counter": XCounter } }
  • With TypeScript experimentalDecorators: true, omit accessor and set useDefineForClassFields: false; otherwise class fields shadow the reactive accessors and updates silently stop.
  • Never mutate an object/array property in place and expect a re-render; assign a new value.

Framework interop

FrameworkNotes
React 19Passes props as properties when the element defines them, and wires on… props to custom events; verify event name casing, or attach listeners through a ref. React 18 and earlier stringifies everything to attributes — use wrappers (@lit/react)
Vue 3Mark tags as custom (compilerOptions.isCustomElement: tag => tag.startsWith("x-")); use .prop modifier for objects; v-model needs a wrapper or explicit :value/@change
AngularAdd CUSTOM_ELEMENTS_SCHEMA; property binding [count] works
Svelte 5Works natively; sets properties when they exist

Publish a Custom Elements Manifest (npx @custom-elements-manifest/analyzer analyze) so tooling can generate docs, types, and framework wrappers.

Verification

  1. Load the element in plain HTML with attributes only — it renders and functions.
  2. Set properties before customElements.define runs (lazy-loaded script) — values are not lost.
  3. Keyboard and screen reader check: role, name, and state announced; focus visible.
  4. Inside a <form>: value submits, form.reset() restores, required validation blocks submit.
  5. Remove the element from the DOM in a test and confirm global listeners are gone (no leak warnings, handlers do not fire).
  6. Render it in each consuming framework’s test app; Playwright CSS locators pierce open shadow roots for end-to-end tests.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc