Skip to main content Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/InugamiDev/ultrathink-oss --skill web-componentsThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... More from this repository Unified design foundations — design system architecture, tokens, component specs, visual principles, creative vision, figma integration, plus brand design system loader (66 real brands via DESIGN.md). Absorbs design, design-system, design-systems, design-principles, design-router, creative-vision, figma, design-md.
Render, summarize, and present markdown documents and structured content in multiple output modes
Ultra UI skill - combines Google's DESIGN.md spec (machine-readable design tokens) with the ui-ux-pro-max knowledge base (91 styles, 161 palettes, 73 font pairings, 161 products, 104 UX guidelines, 25 chart types). Generates lint-clean DESIGN.md files, validates token references and WCAG contrast, exports Tailwind/DTCG tokens, and diffs design systems version-over-version.
Related occupations SOC
Based on SOC occupation classification
name web-components description Native Web Components, custom elements API, Shadow DOM, HTML templates, slots, lifecycle callbacks, and framework-agnostic design patterns layer domain category frontend triggers ["web component","web components","custom element","custom elements","shadow dom","html template","slots","shadow root","framework agnostic","lit element","web component library"] inputs ["Component requirements (behavior, styling, API surface)","Framework interop needs (React, Vue, Angular, vanilla)","Browser support targets","Whether to use Shadow DOM or light DOM"] outputs ["Custom element implementations","Shadow DOM styling strategies","Slot-based composition patterns","Framework wrapper code","Testing setup"] linksTo ["css-architecture","design-systems","typescript-frontend","testing-patterns"] linkedFrom ["react","vue","svelte"] preferredNextSkills ["design-systems","testing-patterns"] fallbackSkills ["css-architecture","typescript-frontend"] riskLevel low memoryReadPolicy selective memoryWritePolicy none sideEffects ["May add custom element definitions to global registry","May add Lit or other web component libraries"]
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)
class MyCounter extends HTMLElement {
static observedAttributes = ['count' , 'min' , 'max' ];
_count = ;
_min = - ;
_max = ;
( ) {
();
. ({ : });
}
( ) {
. ();
. !. ( )!
. ( , . ());
. !. ( )!
. ( , . ());
}
( ) {
}
( ) {
(name) {
:
. = (value) || ;
;
:
. = value !== ? (value) : - ;
;
:
. = value !== ? (value) : ;
;
}
. ();
}
() { . ; }
( ) {
. = . ( . , . ( . , val));
. ( , ( . ));
. ( ( , {
: { : . },
: ,
: ,
}));
}
( ) { . = . + ; }
( ) { . = . - ; }
( ) {
display = . ?. ( );
(display) display. = ( . );
}
( ) {
. !. = ;
}
}
customElements. ( , );
{
{
: ;
}
}
private
0
private
Infinity
private
Infinity
constructor
super
this
attachShadow
mode
'open'
connectedCallback
this
render
this
shadowRoot
querySelector
'#decrement'
addEventListener
'click'
() =>
this
decrement
this
shadowRoot
querySelector
'#increment'
addEventListener
'click'
() =>
this
increment
disconnectedCallback
attributeChangedCallback
name : string , _old : string | null , value : string | null
switch
case
'count'
this
_count
Number
0
break
case
'min'
this
_min
null
Number
Infinity
break
case
'max'
this
_max
null
Number
Infinity
break
this
updateDisplay
get
count
return
this
_count
set
count
val : number
this
_count
Math
min
this
_max
Math
max
this
_min
this
setAttribute
'count'
String
this
_count
this
dispatchEvent
new
CustomEvent
'count-change'
detail
count
this
_count
bubbles
true
composed
true
private
increment
this
count
this
_count
1
private
decrement
this
count
this
_count
1
private
updateDisplay
const
this
shadowRoot
querySelector
'#display'
if
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>
`
define
'my-counter'
MyCounter
declare
global
interface
HTMLElementTagNameMap
'my-counter'
MyCounter
2. Slots and Composition
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 );
3. Reactive Properties with 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;
}
` ;
@property ({ type : String }) name = '' ;
@property ({ type : String }) role = '' ;
@property ({ type : String , attribute : 'avatar-url' }) avatarUrl = '' ;
@property ({ type : String }) bio = '' ;
@property ({ type : Boolean }) following = false ;
@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 ,
}));
}
}
declare global {
interface HTMLElementTagNameMap {
'user-card' : UserCard ;
}
}
4. Framework Integration
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 }
/>
);
}
<template >
<user-card
:name ="user.name"
:role ="user.role"
:avatar-url ="user.avatarUrl"
@follow-change ="handleFollow"
@message-click ="handleMessage"
/>
</template >
<script setup lang ="ts" >
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
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 );
6. Form-Associated Custom Elements
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 ({});
}
}
formResetCallback ( ) {
this .input .value = '' ;
this .internals .setFormValue ('' );
}
formStateRestoreCallback (state : string ) {
this .input .value = state;
this .internals .setFormValue (state);
}
}
customElements.define ('app-input' , AppInput );
Best Practices
Always include a hyphen in tag names -- <my-button> not <mybutton>; single-word names are reserved
Dispatch composed events -- set composed: true so events cross shadow DOM boundaries
Use CSS custom properties for theming -- they pierce Shadow DOM; this is the designed API for external styling
Expose part attributes -- ::part(button) lets consumers style specific internal elements
Make attributes reflect properties -- keep HTML attributes and JS properties in sync
Use observedAttributes sparingly -- only observe attributes that affect rendering
Register elements once -- guard with if (!customElements.get('my-el')) to avoid duplicate registration errors
Keep shadow DOM optional for simple components -- not everything needs style encapsulation
Support SSR with Declarative Shadow DOM -- use <template shadowrootmode="open"> for server rendering
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 definedGuard 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