web-components
Native Web Components, custom elements API, Shadow DOM, HTML templates, slots, lifecycle callbacks, and framework-agnostic design patterns
Best use case
web-components is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Native Web Components, custom elements API, Shadow DOM, HTML templates, slots, lifecycle callbacks, and framework-agnostic design patterns
Teams using web-components should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/web-components/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How web-components Compares
| Feature / Agent | web-components | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Native Web Components, custom elements API, Shadow DOM, HTML templates, slots, lifecycle callbacks, and framework-agnostic design patterns
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Web Components Domain Skill
## Purpose
Web Components are browser-native, framework-agnostic UI primitives. They work in React, Vue, Angular, Svelte, or plain HTML without any build step. This skill covers building production-quality custom elements with Shadow DOM, slots, and lifecycle management -- focusing on the platform APIs directly, with Lit as the recommended abstraction for complex components.
## When to Use Web Components
| Scenario | Web Components? | Why |
|----------|----------------|-----|
| Shared design system across frameworks | Yes | Framework-agnostic, works everywhere |
| Micro-frontends with different frameworks | Yes | Encapsulated, no style conflicts |
| Embeddable widgets (chat, payments) | Yes | Shadow DOM isolates from host page |
| Single-framework app (all React) | Usually no | React components are simpler within React |
| Simple leaf components (button, badge) | Yes | Portable, no dependencies |
| Complex stateful apps | No | Frameworks handle state/routing better |
## Key Concepts
### The Three Standards
```
1. Custom Elements API
- Define new HTML tags: <my-button>, <user-card>
- Lifecycle callbacks: connectedCallback, disconnectedCallback, attributeChangedCallback
- Must contain a hyphen: <my-thing> (single-word names reserved for future HTML)
2. Shadow DOM
- Encapsulated DOM tree attached to an element
- Styles don't leak in or out
- DOM queries from outside cannot reach shadow internals
- open mode: accessible via element.shadowRoot
- closed mode: shadowRoot returns null (rarely needed)
3. HTML Templates & Slots
- <template>: inert HTML parsed but not rendered until cloned
- <slot>: insertion points for light DOM children (like React children)
- Named slots: <slot name="header"> for multi-point composition
```
## Patterns
### 1. Basic Custom Element (Vanilla)
```typescript
// src/components/my-counter.ts
class MyCounter extends HTMLElement {
static observedAttributes = ['count', 'min', 'max'];
private _count = 0;
private _min = -Infinity;
private _max = Infinity;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
// Called when element is added to the DOM
connectedCallback() {
this.render();
this.shadowRoot!.querySelector('#decrement')!
.addEventListener('click', () => this.decrement());
this.shadowRoot!.querySelector('#increment')!
.addEventListener('click', () => this.increment());
}
// Called when element is removed from the DOM
disconnectedCallback() {
// Event listeners on shadow DOM children are auto-cleaned
// Clean up external subscriptions here if any
}
// Called when an observed attribute changes
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
switch (name) {
case 'count':
this._count = Number(value) || 0;
break;
case 'min':
this._min = value !== null ? Number(value) : -Infinity;
break;
case 'max':
this._max = value !== null ? Number(value) : Infinity;
break;
}
this.updateDisplay();
}
get count() { return this._count; }
set count(val: number) {
this._count = Math.min(this._max, Math.max(this._min, val));
this.setAttribute('count', String(this._count));
this.dispatchEvent(new CustomEvent('count-change', {
detail: { count: this._count },
bubbles: true,
composed: true, // Crosses shadow DOM boundary
}));
}
private increment() { this.count = this._count + 1; }
private decrement() { this.count = this._count - 1; }
private updateDisplay() {
const display = this.shadowRoot?.querySelector('#display');
if (display) display.textContent = String(this._count);
}
private render() {
this.shadowRoot!.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: system-ui, sans-serif;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.625rem;
height: 2.625rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: #ffffff;
font-size: 1.25rem;
cursor: pointer;
transition: all 200ms;
color: #374151;
}
button:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
#display {
min-width: 2.625rem;
text-align: center;
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #111827;
}
</style>
<button id="decrement" aria-label="Decrease count">-</button>
<span id="display" role="status" aria-live="polite">${this._count}</span>
<button id="increment" aria-label="Increase count">+</button>
`;
}
}
// Register the element
customElements.define('my-counter', MyCounter);
// Usage in HTML:
// <my-counter count="5" min="0" max="10"></my-counter>
// TypeScript declaration for use in frameworks
declare global {
interface HTMLElementTagNameMap {
'my-counter': MyCounter;
}
}
```
### 2. Slots and Composition
```typescript
// src/components/app-card.ts
class AppCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow: hidden;
background: #ffffff;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
transition: box-shadow 200ms;
}
:host(:hover) {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.header {
padding: 1.5rem 1.5rem 0;
}
.body {
padding: 1rem 1.5rem;
}
.footer {
padding: 1rem 1.5rem 1.5rem;
border-top: 1px solid #f3f4f6;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Hide footer section if no slot content provided */
.footer:not(:has(::slotted(*))) {
display: none;
}
::slotted([slot="title"]) {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
::slotted([slot="subtitle"]) {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
</style>
<div class="header">
<slot name="title"></slot>
<slot name="subtitle"></slot>
</div>
<div class="body">
<slot></slot> <!-- Default slot for main content -->
</div>
<div class="footer">
<slot name="actions"></slot>
</div>
`;
}
}
customElements.define('app-card', AppCard);
// Usage:
// <app-card>
// <h2 slot="title">Card Title</h2>
// <p slot="subtitle">Some subtitle text</p>
// <p>This goes in the default slot (body)</p>
// <button slot="actions">Cancel</button>
// <button slot="actions">Save</button>
// </app-card>
```
### 3. Reactive Properties with Lit
```typescript
// For complex components, Lit removes boilerplate while staying close to the platform
// npm install lit
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('user-card')
export class UserCard extends LitElement {
static styles = css`
:host {
display: block;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
background: #ffffff;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
font-family: system-ui, sans-serif;
}
.header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.avatar {
width: 3rem;
height: 3rem;
border-radius: 50%;
object-fit: cover;
}
.name {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.role {
font-size: 0.875rem;
color: #6b7280;
margin: 0.125rem 0 0;
}
.bio {
font-size: 0.9375rem;
color: #374151;
line-height: 1.625;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
button {
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 200ms;
border: 1px solid transparent;
}
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.primary {
background: #2563eb;
color: #ffffff;
}
.primary:hover {
background: #1d4ed8;
}
.secondary {
background: #ffffff;
color: #374151;
border-color: #d1d5db;
}
.secondary:hover {
background: #f9fafb;
}
.loading {
opacity: 0.6;
pointer-events: none;
}
`;
// Reflected attributes (appear in HTML, trigger attributeChangedCallback)
@property({ type: String }) name = '';
@property({ type: String }) role = '';
@property({ type: String, attribute: 'avatar-url' }) avatarUrl = '';
@property({ type: String }) bio = '';
@property({ type: Boolean }) following = false;
// Internal state (not reflected to attributes)
@state() private _loading = false;
render() {
return html`
<div class="header">
${this.avatarUrl
? html`<img class="avatar" src=${this.avatarUrl} alt=${this.name} />`
: html`<div class="avatar" style="background: #e5e7eb"></div>`
}
<div>
<p class="name">${this.name}</p>
<p class="role">${this.role}</p>
</div>
</div>
${this.bio ? html`<p class="bio">${this.bio}</p>` : ''}
<div class="actions">
<button
class="primary ${this._loading ? 'loading' : ''}"
@click=${this._handleFollow}
>
${this.following ? 'Unfollow' : 'Follow'}
</button>
<button class="secondary" @click=${this._handleMessage}>
Message
</button>
</div>
`;
}
private async _handleFollow() {
this._loading = true;
this.following = !this.following;
this.dispatchEvent(new CustomEvent('follow-change', {
detail: { following: this.following, userId: this.getAttribute('user-id') },
bubbles: true,
composed: true,
}));
this._loading = false;
}
private _handleMessage() {
this.dispatchEvent(new CustomEvent('message-click', {
detail: { userId: this.getAttribute('user-id') },
bubbles: true,
composed: true,
}));
}
}
// TypeScript declarations
declare global {
interface HTMLElementTagNameMap {
'user-card': UserCard;
}
}
// Usage:
// <user-card
// name="Alice Johnson"
// role="Senior Engineer"
// avatar-url="/avatars/alice.jpg"
// bio="Building things that matter."
// user-id="u_123"
// ></user-card>
```
### 4. Framework Integration
```tsx
// React wrapper for web components
// React 19+ handles custom elements natively. For React 18 and below:
import { useRef, useEffect } from 'react';
interface UserCardProps {
name: string;
role: string;
avatarUrl?: string;
bio?: string;
following?: boolean;
onFollowChange?: (detail: { following: boolean; userId: string }) => void;
onMessageClick?: (detail: { userId: string }) => void;
}
export function UserCard({
name, role, avatarUrl, bio, following, onFollowChange, onMessageClick,
}: UserCardProps) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handleFollow = (e: Event) => {
onFollowChange?.((e as CustomEvent).detail);
};
const handleMessage = (e: Event) => {
onMessageClick?.((e as CustomEvent).detail);
};
el.addEventListener('follow-change', handleFollow);
el.addEventListener('message-click', handleMessage);
return () => {
el.removeEventListener('follow-change', handleFollow);
el.removeEventListener('message-click', handleMessage);
};
}, [onFollowChange, onMessageClick]);
return (
<user-card
ref={ref}
name={name}
role={role}
avatar-url={avatarUrl}
bio={bio}
following={following || undefined}
/>
);
}
// For React 19+, this works directly:
// <user-card name="Alice" onfollow-change={handleFollow} />
```
```html
<!-- Vue integration -- works out of the box -->
<template>
<user-card
:name="user.name"
:role="user.role"
:avatar-url="user.avatarUrl"
@follow-change="handleFollow"
@message-click="handleMessage"
/>
</template>
<script setup lang="ts">
// Vue handles custom events and properties natively
function handleFollow(e: CustomEvent) {
console.log('Follow:', e.detail);
}
function handleMessage(e: CustomEvent) {
console.log('Message:', e.detail);
}
</script>
```
### 5. CSS Custom Properties for Theming
```typescript
// Expose CSS custom properties for external theming without piercing Shadow DOM
class AppButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
:host {
display: inline-block;
}
button {
/* Theming via CSS custom properties -- consumers can override these */
--_bg: var(--app-button-bg, #2563eb);
--_color: var(--app-button-color, #ffffff);
--_radius: var(--app-button-radius, 0.5rem);
--_padding-x: var(--app-button-padding-x, 1.5rem);
--_padding-y: var(--app-button-padding-y, 1rem);
--_font-size: var(--app-button-font-size, 1rem);
background: var(--_bg);
color: var(--_color);
border: none;
border-radius: var(--_radius);
padding: var(--_padding-y) var(--_padding-x);
font-size: var(--_font-size);
font-weight: 600;
cursor: pointer;
transition: all 200ms;
font-family: inherit;
line-height: 1;
min-height: 2.625rem;
}
button:hover {
filter: brightness(0.9);
}
button:focus-visible {
outline: 2px solid var(--_bg);
outline-offset: 2px;
}
button:active {
transform: scale(0.98);
}
:host([variant="secondary"]) button {
--_bg: var(--app-button-secondary-bg, #ffffff);
--_color: var(--app-button-secondary-color, #374151);
border: 1px solid #d1d5db;
}
:host([variant="danger"]) button {
--_bg: var(--app-button-danger-bg, #dc2626);
--_color: var(--app-button-danger-color, #ffffff);
}
:host([size="sm"]) button {
--_padding-x: 1rem;
--_padding-y: 0.5rem;
--_font-size: 0.875rem;
}
:host([size="lg"]) button {
--_padding-x: 2rem;
--_padding-y: 1.25rem;
--_font-size: 1.125rem;
}
:host([disabled]) button {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
</style>
<button part="button">
<slot></slot>
</button>
`;
}
}
customElements.define('app-button', AppButton);
// Consumer theming via CSS:
// app-button {
// --app-button-bg: #059669;
// --app-button-radius: 9999px;
// }
```
### 6. Form-Associated Custom Elements
```typescript
// Custom elements that participate in HTML forms natively
class AppInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['value', 'name', 'required', 'placeholder', 'type'];
private internals: ElementInternals;
private input!: HTMLInputElement;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
:host {
display: block;
}
.wrapper {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 1rem;
min-height: 2.75rem;
transition: all 200ms;
font-family: inherit;
color: #111827;
background: #ffffff;
}
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.15);
}
input:invalid {
border-color: #ef4444;
}
.error {
font-size: 0.8125rem;
color: #ef4444;
min-height: 1.25rem;
}
</style>
<div class="wrapper">
<label><slot name="label"></slot></label>
<input />
<div class="error" role="alert"></div>
</div>
`;
this.input = this.shadowRoot!.querySelector('input')!;
this.input.addEventListener('input', () => {
this.internals.setFormValue(this.input.value);
this.validate();
});
}
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
if (!this.input) return;
switch (name) {
case 'value': this.input.value = value ?? ''; break;
case 'placeholder': this.input.placeholder = value ?? ''; break;
case 'type': this.input.type = value ?? 'text'; break;
case 'required': this.input.required = value !== null; break;
}
}
get value() { return this.input?.value ?? ''; }
set value(val: string) {
this.input.value = val;
this.internals.setFormValue(val);
}
private validate() {
if (this.input.validity.valueMissing) {
this.internals.setValidity(
{ valueMissing: true },
'This field is required',
this.input
);
} else {
this.internals.setValidity({});
}
}
// Form lifecycle callbacks
formResetCallback() {
this.input.value = '';
this.internals.setFormValue('');
}
formStateRestoreCallback(state: string) {
this.input.value = state;
this.internals.setFormValue(state);
}
}
customElements.define('app-input', AppInput);
// Works in standard HTML forms:
// <form>
// <app-input name="email" required placeholder="you@example.com">
// <span slot="label">Email</span>
// </app-input>
// <button type="submit">Submit</button>
// </form>
```
## Best Practices
1. **Always include a hyphen in tag names** -- `<my-button>` not `<mybutton>`; single-word names are reserved
2. **Dispatch composed events** -- set `composed: true` so events cross shadow DOM boundaries
3. **Use CSS custom properties for theming** -- they pierce Shadow DOM; this is the designed API for external styling
4. **Expose `part` attributes** -- `::part(button)` lets consumers style specific internal elements
5. **Make attributes reflect properties** -- keep HTML attributes and JS properties in sync
6. **Use `observedAttributes` sparingly** -- only observe attributes that affect rendering
7. **Register elements once** -- guard with `if (!customElements.get('my-el'))` to avoid duplicate registration errors
8. **Keep shadow DOM optional for simple components** -- not everything needs style encapsulation
9. **Support SSR with Declarative Shadow DOM** -- use `<template shadowrootmode="open">` for server rendering
10. **Use Lit for complex components** -- vanilla API is fine for simple elements, but Lit removes significant boilerplate for reactive rendering
## Common Pitfalls
| Pitfall | Impact | Fix |
|---------|--------|-----|
| Setting properties in constructor | Crash: DOM not ready | Use `connectedCallback` for DOM setup |
| Not calling `super()` first in constructor | TypeError | Always `super()` before anything else |
| Missing `composed: true` on events | Events stop at shadow boundary | Set `composed: true` and `bubbles: true` |
| Styling with external CSS | Styles blocked by Shadow DOM | Use CSS custom properties or `::part()` |
| Using `this.innerHTML` with Shadow DOM | Overwrites light DOM (slot content) | Use `this.shadowRoot.innerHTML` |
| Registering same tag name twice | `DOMException: already defined` | Guard with `customElements.get()` check |
| React 18 not passing events to custom elements | Event handlers silently ignored | Use ref + addEventListener, or upgrade to React 19 |
| Heavy work in `connectedCallback` | Blocks rendering, called on every DOM move | Defer heavy work with `requestAnimationFrame` or `setTimeout` |
| Not cleaning up in `disconnectedCallback` | Memory leaks from subscriptions/intervals | Clear intervals, remove global listeners, close connections |
| Assuming `adoptedCallback` is common | Rarely fires, only on `document.adoptNode()` | Do not rely on it for initialization logic |Related Skills
ultrathink
UltraThink Workflow OS — 4-layer skill mesh with persistent memory and privacy hooks for complex engineering tasks. Routes prompts through intent detection to activate the right domain skills automatically.
ultrathink_review
Multi-pass code review powered by UltraThink's quality gate — checks correctness, security (OWASP), performance, readability, and project conventions in a single structured pass.
ultrathink_memory
Persistent memory system for UltraThink — search, save, and recall project context, decisions, and patterns across sessions using Postgres-backed fuzzy search with synonym expansion.
ui-design
Comprehensive UI design system: 230+ font pairings, 48 themes, 65 design systems, 23 design languages, 30 UX laws, 14 color systems, Swiss grid, Gestalt principles, Pencil.dev workflow. Inherits ui-ux-pro-max (99 UX rules) + impeccable-frontend-design (anti-AI-slop). Triggers on any design, UI, layout, typography, color, theme, or styling task.
Zod
> TypeScript-first schema validation with static type inference.
webinar-registration-page
Build a webinar or live event registration page as a self-contained HTML file with countdown timer, speaker bio, agenda, and registration form. Triggers on: "build a webinar registration page", "create a webinar sign-up page", "event registration landing page", "live training registration page", "workshop sign-up page", "create a webinar page", "build an event page", "free webinar landing page", "live demo registration page", "online event page", "create a registration page for my webinar", "build a training event page".
webhooks
Webhook design patterns — delivery, retry with exponential backoff, HMAC signature verification, payload validation, idempotency keys
web-workers
Offload heavy computation from the main thread using Web Workers, SharedWorkers, and Comlink — structured messaging, transferable objects, and off-main-thread architecture patterns
web-vitals
Core Web Vitals monitoring (LCP, FID, CLS, INP, TTFB), measurement with web-vitals library, reporting to analytics, and optimization strategies for Next.js
wasm
WebAssembly integration — Rust to WASM with wasm-pack/wasm-bindgen, WASI, browser usage, server-side WASM, and performance considerations
vue
Vue 3 Composition API, Nuxt patterns, reactivity system, component architecture, and production development practices
Vitest
> Blazing fast unit testing powered by Vite — Jest-compatible API, native ESM, TypeScript.