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.
- name
web-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
- Components are used only inside one React app — use react-patterns
- You need token and theming strategy for the system — use design-system-tokens
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.
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
| Question | Recommendation |
|---|---|
| One framework app, no reuse elsewhere | Use the framework’s components; custom elements add friction |
| Design system consumed by several frameworks or CMS/templates | Custom elements (Lit or vanilla) |
| Need style encapsulation | Shadow DOM |
| Must inherit page styles (prose, global utilities) or be styled by Tailwind classes | Light DOM (no shadow root) |
| More than a couple of reactive properties | Lit 3 instead of hand-rolled rendering |
| Server-rendered first paint | Declarative 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.createElementthrows if the constructor adds attributes. connectedCallbackcan run multiple times (moves in the DOM). Make setup idempotent and undo it indisconnectedCallback(listeners onwindow/document, observers, timers).attributeChangedCallbackfires only for names inobservedAttributes, including beforeconnectedCallbackfor 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
| Data | API |
|---|---|
| Strings, numbers, booleans authors set in HTML | Attribute + property, reflected |
| Objects, arrays, functions | Property only (never serialize JSON into attributes for large data) |
| State CSS needs to select | Reflected 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;exportpartsto 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 viaadoptedStyleSheetsinstead of<style>per instance. @font-facemust be declared in the document, not inside the shadow root.
Events
- Use
bubbles: true, composed: truefor events consumers should hear outside the component. UseCustomEventwith adetailfor payloads. - Name events in lowercase without the
onprefix (value-change). Avoid reusing native names with different semantics. - Events from inside the shadow root are retargeted:
event.targetoutside is the host. Useevent.composedPath()for the original node.
Accessibility
- ID references (
aria-labelledby,for,aria-describedby) cannot cross shadow boundaries. Keep labels inside the same root, accept alabelattribute and render it internally, or set semantics on the host viaElementInternals(role,ariaLabel,ariaChecked). delegatesFocus: trueforwards host focus to the first focusable child and makes:focus-visiblework 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, omitaccessorand setuseDefineForClassFields: 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
| Framework | Notes |
|---|---|
| React 19 | Passes 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 3 | Mark tags as custom (compilerOptions.isCustomElement: tag => tag.startsWith("x-")); use .prop modifier for objects; v-model needs a wrapper or explicit :value/@change |
| Angular | Add CUSTOM_ELEMENTS_SCHEMA; property binding [count] works |
| Svelte 5 | Works 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
- Load the element in plain HTML with attributes only — it renders and functions.
- Set properties before
customElements.defineruns (lazy-loaded script) — values are not lost. - Keyboard and screen reader check: role, name, and state announced; focus visible.
- Inside a
<form>: value submits,form.reset()restores, required validation blocks submit. - Remove the element from the DOM in a test and confirm global listeners are gone (no leak warnings, handlers do not fire).
- 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.