---
id: frontend-web-components
title: Web Components — Custom Element / Shadow DOM
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [frontend, web-components, vibe-coding]
tech_stack: { language: "TS / HTML", applicable_to: ["Frontend"] }
applied_in: []
aliases: [Web Components, Custom Element, Shadow DOM, slot, Lit, declarative shadow DOM]
---

# Web Components

> Browser native component. **Custom Element + Shadow DOM + Template + Slot**. Framework agnostic. Lit / Stencil 가 friendly.

## 📖 핵심 개념
- Custom Element: `<my-component>` 정의.
- Shadow DOM: scoped CSS / DOM.
- Template: 재사용 HTML.
- Slot: child 삽입 point.

## 💻 코드 패턴

### Vanilla custom element
```ts
class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot!.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
          border-radius: 8px;
          padding: 16px;
        }
        h2 { margin: 0 0 8px; }
      </style>
      <h2>${this.getAttribute('title') ?? ''}</h2>
      <slot></slot>
    `;
  }
  
  static observedAttributes = ['title'];
  
  attributeChangedCallback(name: string, old: string | null, value: string | null) {
    if (name === 'title' && this.shadowRoot) {
      const h2 = this.shadowRoot.querySelector('h2');
      if (h2) h2.textContent = value ?? '';
    }
  }
}

customElements.define('my-card', MyCard);
```

```html
<my-card title="Hello">
  <p>Card content</p>
</my-card>
```

### Lifecycle
```
connectedCallback:    DOM 에 추가
disconnectedCallback: 제거
attributeChangedCallback: attribute 변경
adoptedCallback:      다른 document 로 이동
```

### Shadow DOM (scoped CSS)
```ts
this.attachShadow({ mode: 'open' });  // 외부 access OK
this.attachShadow({ mode: 'closed' }); // 외부 access X (드물게)

this.shadowRoot.innerHTML = `
  <style>
    /* 이 component 만 — 다른 곳 영향 X */
    p { color: red; }
  </style>
  <p>Scoped paragraph</p>
`;
```

### CSS 변수 (theme)
```html
<style>
  my-button {
    --button-color: blue;
    --button-bg: white;
  }
</style>
```

```ts
class MyButton extends HTMLElement {
  connectedCallback() {
    this.shadowRoot!.innerHTML = `
      <style>
        button {
          color: var(--button-color, black);
          background: var(--button-bg, white);
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}
```

→ CSS variable 가 Shadow boundary 통과.

### Slot
```ts
this.shadowRoot.innerHTML = `
  <header>
    <slot name="title"></slot>
  </header>
  <main>
    <slot></slot>  <!-- default -->
  </main>
  <footer>
    <slot name="actions"></slot>
  </footer>
`;
```

```html
<my-card>
  <h2 slot="title">Title</h2>
  <p>Body content</p>
  <button slot="actions">OK</button>
</my-card>
```

### Lit (modern WC framework)
```bash
yarn add lit
```

```ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('my-card')
export class MyCard extends LitElement {
  static styles = css`
    :host { display: block; padding: 16px; }
    h2 { margin: 0; }
  `;
  
  @property({ type: String })
  cardTitle = '';
  
  @property({ type: Number })
  count = 0;
  
  render() {
    return html`
      <h2>${this.cardTitle}</h2>
      <p>Count: ${this.count}</p>
      <button @click=${() => this.count++}>+</button>
      <slot></slot>
    `;
  }
}
```

```html
<my-card card-title="Hello">
  <p>Slotted content</p>
</my-card>
```

→ React 같은 declarative + reactive.

### Lit + signals (modern)
```ts
import { LitElement, html } from 'lit';
import { signal, SignalWatcher } from '@lit-labs/signals';

const count = signal(0);

@customElement('my-counter')
class MyCounter extends SignalWatcher(LitElement) {
  render() {
    return html`<button @click=${() => count.set(count.get() + 1)}>${count}</button>`;
  }
}
```

### Stencil (큰 design system)
```bash
npm init stencil
```

```ts
import { Component, Prop, State, h } from '@stencil/core';

@Component({ tag: 'my-card', styleUrl: 'my-card.css', shadow: true })
export class MyCard {
  @Prop() title: string;
  @State() count = 0;
  
  render() {
    return (
      <div>
        <h2>{this.title}</h2>
        <button onClick={() => this.count++}>{this.count}</button>
        <slot />
      </div>
    );
  }
}
```

→ Compile-time. 작은 bundle.

### Declarative Shadow DOM (SSR)
```html
<my-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; padding: 16px; }
    </style>
    <h2>Server-rendered</h2>
    <slot></slot>
  </template>
  <p>Slotted</p>
</my-card>
```

→ Server 가 shadow DOM 직접 render. JS 없어도 styled.

### Form-associated (FACE)
```ts
class MyInput extends HTMLElement {
  static formAssociated = true;
  internals_: ElementInternals;
  
  constructor() {
    super();
    this.internals_ = this.attachInternals();
  }
  
  set value(v: string) {
    this.internals_.setFormValue(v);
  }
}
```

→ `<form>` 안 native form value.

### React 안 사용
```tsx
import 'my-card.js';

function App() {
  return (
    <my-card card-title="Hello" onClick={() => console.log('clicked')}>
      <p>Content</p>
    </my-card>
  );
}
```

→ React 가 unknown element 그대로 render. 단 event 가 camelCase 충돌 — wrapper.

```tsx
// React wrapper
import { createComponent } from '@lit/react';
import { MyCard } from './my-card';

const MyCardReact = createComponent({
  tagName: 'my-card',
  elementClass: MyCard,
  react: React,
  events: { onChange: 'change' },
});
```

### Vue / Svelte / Solid 안 사용
```vue
<template>
  <my-card :card-title="title" @change="handle">
    <p>Content</p>
  </my-card>
</template>
```

→ 거의 다 native 호환.

### Bundle / size
```
Lit:     ~5 KB
Stencil: 컴파일 시 작음
Vanilla: 0 dependency

→ 작은 widget = vanilla / Lit.
```

### Distribution
```ts
// npm package
"main": "dist/my-card.js",
"types": "dist/my-card.d.ts",
"customElements": "dist/custom-elements.json"

// CDN
<script type="module" src="https://unpkg.com/my-card@1.0.0/dist/my-card.js"></script>
```

→ 어디서나 사용.

### Use cases
```
✅ Design system (cross-team / cross-framework)
✅ Embeddable widget (3rd party)
✅ Shadow DOM 의 isolation 필요
✅ Long-lived component (framework migration 안전)

❌ Heavy app component (React/Solid 가 빠름)
❌ Frequent re-render
❌ Complex state (better with framework)
```

### 함정
```
1. Style 격리 — 외부 CSS 안 영향 X (의도).
2. SSR 지원 약함 (Declarative Shadow DOM 가 해결 중).
3. Form integration 어려움 (FACE 가 해결).
4. A11y — 직접 ARIA 추가.
5. Bundle 더 큼 (한 component 의 표준 < 큰 framework).
```

### Polyfill
```
Modern browser = native 지원.
옛 IE11 = 큰 polyfill.

→ 무시.
```

### vs React component
```
Web Component:
+ Framework agnostic
+ Browser native
+ Long-lived
- Less ecosystem

React component:
+ Familiar
+ 큰 ecosystem
+ Server / streaming
- React 만
```

### Atomic / Compound
```html
<my-tabs>
  <my-tab name="home">Home</my-tab>
  <my-tab name="about">About</my-tab>
</my-tabs>
```

```ts
class MyTabs extends LitElement {
  @state() activeTab = '';
  
  firstUpdated() {
    const tabs = this.querySelectorAll('my-tab');
    this.activeTab = tabs[0]?.getAttribute('name') ?? '';
  }
  
  // ...
}
```

### Custom event
```ts
this.dispatchEvent(new CustomEvent('select', {
  detail: { id: '...' },
  bubbles: true,
  composed: true,  // shadow DOM 통과
}));
```

```ts
document.querySelector('my-card').addEventListener('select', (e) => {
  console.log(e.detail.id);
});
```

### shadow vs light DOM
```
Shadow: scoped, encapsulated.
Light: 일반 child — 외부 CSS 영향.

→ Slot 가 light 의 일부.
   ::slotted(p) { color: red; } — slotted content 일부 styling.
```

### Adoption
```
Apple Music web app:    Web Components
GitHub:                  많은 web component (custom element)
Microsoft FAST:          UI library
Salesforce LWC:          Large-scale web components
Google Material:        web component 형태
```

→ 큰 회사 가 design system 으로 사용.

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Cross-framework design system | Web Components (Lit) |
| Embeddable widget | Web Components |
| Single framework app | React / Solid 등 native |
| Shadow DOM 격리 critical | Web Components |
| 작은 widget | Lit |
| 큰 design system | Stencil |

## ❌ 안티패턴
- **모든 거 web component 강제**: 작은 app 가 의미 없음.
- **A11y 안 신경**: native 보다 더 많은 일.
- **CSS 가 외부 못 customize**: design token / part API.
- **Lifecycle 잘못**: connectedCallback 가 매 attach.
- **No SSR**: 큰 site = 빈 page first paint.
- **Bundle 큰 framework + 작은 widget**: vanilla 또는 Lit.

## 🤖 LLM 활용 힌트
- Lit = modern + light.
- Cross-framework design system 의 답.
- Declarative Shadow DOM = SSR.
- Custom event + composed: true.

## 🔗 관련 문서
- [[Frontend_Tailwind_Architecture]]
- [[Frontend_Design_Tokens]]
- [[React_Headless_UI_Patterns]]
