jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Components toàn tập — Custom Elements, Shadow DOM, Slots & ElementInternals

Đào sâu 3 trụ cột của Web Components: Custom Elements (lifecycle), Shadow DOM (encapsulation, slots, ::part), attributes vs properties, events, styling, form-associated elements và Declarative Shadow DOM cho SSR.

Web Components are the browser’s native answer to reusable UI {Web Components là câu trả lời gốc của trình duyệt cho UI tái sử dụng}. No build step, no framework, no runtime to ship — they run on the platform itself and outlive any framework’s hype cycle {Không build step, không framework, không runtime phải gửi đi — chúng chạy ngay trên nền tảng và sống lâu hơn mọi chu kỳ thời thượng của framework}.

This post goes deep on the three pillars and the modern APIs around them {Bài này đào sâu ba trụ cột và các API hiện đại quanh chúng}: Custom Elements, Shadow DOM, and HTML Templates — plus events, styling, form integration, and server-side rendering {Custom Elements, Shadow DOM, và HTML Templates — cộng thêm events, styling, tích hợp form, và render phía server}.

Ba trụ cột {The three pillars}

 ┌──────────────────────────────────────────────────────────────┐
 │                       WEB COMPONENTS                          │
 ├────────────────────┬───────────────────┬─────────────────────┤
 │  Custom Elements   │   Shadow DOM       │  HTML Templates     │
 │  ────────────────  │   ─────────────    │  ────────────────   │
 │  Định nghĩa thẻ    │  DOM + CSS         │  <template> &       │
 │  HTML riêng + vòng │  đóng gói, cô lập  │  <slot>: markup     │
 │  đời (lifecycle)   │  khỏi phần còn lại │  tái dùng, chèn nội │
 │                    │  của trang         │  dung (projection)  │
 └────────────────────┴───────────────────┴─────────────────────┘

Each pillar is useful on its own, but together they let you ship a tag like <user-card> that is fully self-contained {Mỗi trụ cột tự nó đã hữu ích, nhưng khi kết hợp chúng cho phép bạn tạo một thẻ như <user-card> hoàn toàn khép kín}: its own markup, its own styles that don’t leak, and its own behavior {markup riêng, style riêng không rò rỉ, và hành vi riêng}.


1. Custom Elements — định nghĩa thẻ HTML của riêng bạn {Custom Elements — define your own HTML tags}

A custom element is a class extending HTMLElement, registered with a tag name {Một custom element là một class kế thừa HTMLElement, đăng ký với một tên thẻ}. The tag name must contain a hyphen — that’s how the parser tells your element apart from native ones and avoids future clashes {Tên thẻ bắt buộc có dấu gạch ngang — đó là cách parser phân biệt thẻ của bạn với thẻ gốc và tránh xung đột trong tương lai}.

class GreetingBox extends HTMLElement {
  constructor() {
    super();
    // Chỉ khởi tạo state ở đây. KHÔNG đụng attributes/children/DOM
    // ngoài — lúc constructor chạy, element có thể chưa được gắn vào cây.
  }
}

customElements.define('greeting-box', GreetingBox);
<greeting-box></greeting-box>

1.1. Lifecycle callbacks — vòng đời của element {The lifecycle callbacks}

The browser calls these methods at well-defined moments {Trình duyệt gọi các method này tại những thời điểm xác định rõ}:

CallbackKhi nào chạyDùng để
constructor()Khi instance được tạoKhởi tạo state, attach shadow root
connectedCallback()Mỗi lần element được gắn vào DOMRender, add event listener, fetch data
disconnectedCallback()Khi element bị gỡ khỏi DOMCleanup: remove listener, hủy timer
attributeChangedCallback(name, old, new)Khi một observed attribute đổiĐồng bộ attribute → state/UI
adoptedCallback()Khi element bị move sang document khácHiếm dùng (iframe, document.adoptNode)
class CountdownTimer extends HTMLElement {
  static observedAttributes = ['seconds'];

  #timerId = 0; // private field

  connectedCallback() {
    // connectedCallback có thể chạy NHIỀU lần (nếu element bị gỡ rồi
    // gắn lại). Luôn cleanup tương ứng ở disconnectedCallback.
    this.#render();
    this.#timerId = window.setInterval(() => this.#tick(), 1000);
  }

  disconnectedCallback() {
    // Bắt buộc cleanup — nếu không, timer rò rỉ khi element bị gỡ.
    clearInterval(this.#timerId);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'seconds' && oldValue !== newValue) this.#render();
  }

  #tick() { /* ... */ }
  #render() {
    this.textContent = `${this.getAttribute('seconds') ?? 0}s`;
  }
}
customElements.define('countdown-timer', CountdownTimer);

Two rules that save hours of debugging {Hai quy tắc giúp tiết kiệm hàng giờ debug}:

  • connectedCallback can fire more than once {connectedCallback có thể chạy nhiều lần} — every time the element is re-inserted {mỗi lần element được chèn lại}. Pair every setup with matching teardown in disconnectedCallback {Mỗi setup phải có teardown tương ứng trong disconnectedCallback}.
  • attributeChangedCallback only fires for attributes listed in observedAttributes {attributeChangedCallback chỉ chạy cho attribute có trong observedAttributes}. Forget to list it and your callback silently never runs {Quên liệt kê thì callback im lặng không bao giờ chạy}.

1.2. Upgrade — khi element gặp definition của nó {Upgrade — when an element meets its definition}

HTML can contain <greeting-box> before the JS that defines it loads {HTML có thể chứa <greeting-box> trước khi JS định nghĩa nó được tải}. Until then it’s an “undefined” element rendered as an inert generic element {Cho tới lúc đó nó là element “chưa định nghĩa”, render như một element generic trơ}. When customElements.define() runs, the browser upgrades all matching elements already in the DOM {Khi customElements.define() chạy, trình duyệt upgrade mọi element khớp đã có sẵn trong DOM}.

// Chờ tới khi element đã được định nghĩa và upgrade xong.
await customElements.whenDefined('greeting-box');

// Style cho element CHƯA upgrade để tránh "flash of unstyled element".
// :defined chỉ khớp element đã được định nghĩa.
greeting-box:not(:defined) { visibility: hidden; }
greeting-box:defined { visibility: visible; }

1.3. Autonomous vs Customized built-in {Autonomous vs Customized built-in}

Two flavors exist {Có hai loại}:

  • Autonomous {Tự thân} — extends HTMLElement, brand-new tag (<user-card>) {kế thừa HTMLElement, thẻ hoàn toàn mới}. Supported everywhere {Hỗ trợ ở mọi nơi}.
  • Customized built-in {Mở rộng thẻ gốc} — extends a native element (e.g. class FancyButton extends HTMLButtonElement), used as <button is="fancy-button"> {kế thừa một thẻ gốc, dùng dạng <button is="...">}. You inherit the native element’s behavior and a11y for free {Bạn thừa hưởng hành vi và a11y của thẻ gốc miễn phí}, but Safari has refused to implement is=, so it’s rarely used in practice {nhưng Safari từ chối implement is=, nên thực tế hiếm dùng}.
class FancyButton extends HTMLButtonElement {
  connectedCallback() { this.classList.add('fancy'); }
}
// Tham số thứ 3 khai báo nó mở rộng thẻ <button>.
customElements.define('fancy-button', FancyButton, { extends: 'button' });

2. Shadow DOM — đóng gói thật sự {Shadow DOM — true encapsulation}

Without Shadow DOM, a component’s CSS leaks out and the page’s CSS leaks in {Không có Shadow DOM, CSS của component rò ra ngoài và CSS của trang rò vào trong}. Shadow DOM gives each element a private DOM subtree with scoped styles {Shadow DOM cho mỗi element một cây DOM riêng với style cô lập}.

class UserCard extends HTMLElement {
  constructor() {
    super();
    // mode:'open' → truy cập được qua element.shadowRoot từ bên ngoài.
    // mode:'closed' → shadowRoot = null từ ngoài, chỉ giữ tham chiếu nội bộ.
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        /* Style NÀY chỉ áp dụng bên trong shadow root — không rò ra trang. */
        p { color: rebeccapurple; font: 600 14px system-ui; }
      </style>
      <p>Tôi sống trong shadow DOM.</p>
    `;
  }
}
customElements.define('user-card', UserCard);

2.1. Light DOM vs Shadow DOM {Light DOM vs Shadow DOM}

 <user-card>                      ← host element

   ├─ shadow root (shadow DOM)     ← DOM riêng, style cô lập
   │    └─ <style> + <p>…          ← "shadow tree"

   └─ <span>Nội dung người dùng</span>  ← "light DOM": con thật của host,
                                          hiển thị qua <slot> (xem mục 3)
  • Shadow tree {Cây shadow} — internal markup you create; styles here are isolated {markup nội bộ bạn tạo; style ở đây bị cô lập}.
  • Light DOM {Light DOM} — the children the user of your component writes between the tags; projected via slots {các con mà người dùng component viết giữa thẻ; được chiếu qua slot}.

2.2. open vs closed {open vs closed}

closed looks more “secure” but is mostly an illusion {closed trông “an toàn” hơn nhưng phần lớn là ảo giác}: anyone who can run code in the page can patch attachShadow to capture your root {ai chạy được code trong trang đều có thể vá attachShadow để bắt root của bạn}. It also blocks legitimate access (testing, accessibility tools) {Nó còn chặn cả truy cập chính đáng (test, công cụ a11y)}. Default to open unless you have a specific reason {Mặc định dùng open trừ khi có lý do cụ thể}.


3. HTML Templates & Slots — markup tái dùng + projection {Reusable markup + projection}

3.1. <template> — markup “ngủ đông” {<template> — inert markup}

Content inside <template> is parsed but not rendered: no images load, no scripts run, until you clone it {Nội dung trong <template> được parse nhưng không render: không tải ảnh, không chạy script, cho tới khi bạn clone nó}. Perfect for stamping out repeated structure cheaply {Hoàn hảo để “dập khuôn” cấu trúc lặp lại một cách rẻ}.

const tpl = document.createElement('template');
tpl.innerHTML = `<style>…</style><div class="card"><slot></slot></div>`;

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // Clone 1 lần parse, dùng lại cho mọi instance → nhanh hơn innerHTML mỗi lần.
    this.shadowRoot.appendChild(tpl.content.cloneNode(true));
  }
}

3.2. <slot> — chèn nội dung của người dùng {<slot> — project user content}

A <slot> is a placeholder inside the shadow tree where light DOM children appear {Một <slot> là chỗ giữ trong cây shadow nơi các con light DOM xuất hiện}.

shadow.innerHTML = `
  <style>
    .name { font-weight: 700; }
    /* Slot mặc định hiện khi user không cung cấp nội dung */
  </style>
  <div class="card">
    <span class="name"><slot name="title">Người dùng ẩn danh</slot></span>
    <div class="body"><slot>Chưa có mô tả.</slot></div>
  </div>
`;
<user-card>
  <span slot="title">vinxi Nguyen</span>
  <p>Senior frontend engineer.</p>   <!-- vào slot mặc định -->
</user-card>
  • Named slot {Slot có tên} — <slot name="title"> receives children with slot="title" {nhận các con có slot="title"}.
  • Default slot {Slot mặc định} — <slot> (no name) catches everything else {(không tên) hứng mọi thứ còn lại}.
  • Fallback content {Nội dung dự phòng} — text inside the <slot> shows only when nothing is projected {chữ bên trong <slot> chỉ hiện khi không có gì được chiếu vào}.
  • slotchange event {Sự kiện slotchange} — fires when the assigned nodes change {kích hoạt khi các node được gán thay đổi}; read them with slot.assignedElements() {đọc bằng slot.assignedElements()}.

Important: slotted content stays in the light DOM {Quan trọng: nội dung được slot vẫn nằm ở light DOM}. It’s only rendered in the slot’s position; the user’s page CSS still styles it {Nó chỉ hiển thị ở vị trí slot; CSS trang của người dùng vẫn style nó}.


4. Attributes vs Properties — điểm gây nhầm lẫn lớn nhất {Attributes vs Properties — the biggest source of confusion}

This trips up nearly everyone {Điều này làm gần như ai cũng vấp}:

  • Attributes {Attribute} live in the HTML markup and are always strings {nằm trong markup HTML và luôn là chuỗi} — <my-el count="5">. Read with getAttribute/setAttribute {Đọc bằng getAttribute/setAttribute}.
  • Properties {Property} live on the JS object and can be any type {nằm trên object JS và có thể là bất kỳ kiểu nào} — el.count = 5, el.user = obj. Objects and arrays can only be passed as properties {Object và array chỉ có thể truyền qua property}.

Reflection {Phản chiếu} is keeping the two in sync {là việc giữ hai cái đồng bộ}. The common pattern {Mẫu thường dùng}:

class ToggleSwitch extends HTMLElement {
  static observedAttributes = ['checked'];

  // Getter/setter property phản chiếu xuống attribute boolean.
  get checked() { return this.hasAttribute('checked'); }
  set checked(value) {
    // Boolean attribute: hiện diện = true, vắng mặt = false.
    this.toggleAttribute('checked', Boolean(value));
  }

  attributeChangedCallback(name) {
    if (name === 'checked') this.#render();
  }

  #render() { /* cập nhật UI theo this.checked */ }
}

Rule of thumb {Quy tắc}: reflect primitive config (string/number/boolean) to attributes so they work from HTML and CSS {phản chiếu config kiểu nguyên thủy xuống attribute để dùng được từ HTML và CSS}; keep rich data (objects, arrays, functions) as properties only {giữ dữ liệu phức tạp (object, array, function) chỉ ở property}.


5. Events — giao tiếp ra ngoài {Events — talking to the outside}

Components communicate outward by dispatching CustomEvent {Component giao tiếp ra ngoài bằng cách phát CustomEvent}. Two flags decide how far the event travels {Hai cờ quyết định event đi xa tới đâu}:

this.dispatchEvent(new CustomEvent('toggle', {
  detail: { checked: this.checked }, // payload tùy ý
  bubbles: true,    // nổi lên cây tổ tiên
  composed: true,   // VƯỢT QUA ranh giới shadow DOM ra ngoài host
}));
  • bubbles — without it, the event fires only on the target {không có nó, event chỉ kích hoạt trên target}.
  • composed — the critical one for Web Components {cái then chốt với Web Components}: if false, the event stops at the shadow boundary and the outside page never hears it {nếu false, event dừng ở ranh giới shadow và trang bên ngoài không nghe thấy}. Set both true for events meant to be public API {Đặt cả hai true cho event là API công khai}.
// Bên ngoài lắng nghe như event thường:
document.querySelector('toggle-switch')
  .addEventListener('toggle', (e) => console.log(e.detail.checked));

A subtle point {Một điểm tinh tế}: when an event with composed: true crosses the boundary, event.target is retargeted to the host element, hiding your shadow internals {khi event composed: true vượt ranh giới, event.target bị retarget thành host element, ẩn nội bộ shadow của bạn}. Use event.composedPath() if you really need the original inner target {Dùng event.composedPath() nếu bạn thực sự cần target gốc bên trong}.


6. Styling — tô màu xuyên (và không xuyên) ranh giới {Styling — across (and not across) the boundary}

Shadow DOM isolation means normal page CSS can’t reach inside {Cô lập của Shadow DOM nghĩa là CSS trang thường không với vào trong được}. There’s a dedicated toolkit for the boundary {Có một bộ công cụ riêng cho ranh giới}:

/* Bên trong shadow root: */

:host { display: block; }              /* chính host element */
:host([checked]) { background: lime; } /* host khi có attribute checked */
:host(.dark) { color: white; }         /* host khi khớp selector */
:host-context(body.rtl) { direction: rtl; } /* theo tổ tiên ngoài shadow */

::slotted(p) { margin: 0; }            /* style node được slot (chỉ top-level) */

::part(label) { font-weight: 700; }    /* style "part" mà component expose */
Cơ chếHướngDùng để
:host / :host()Trong → chính hostStyle container của component
:host-context()Ngoài → ảnh hưởng trongTheme theo tổ tiên (rtl, dark)
::slotted()Light DOM trong slotStyle nội dung user (giới hạn top-level)
::part() + part="…"Ngoài → vào trongCho phép user style phần được chỉ định
CSS custom propertiesXuyên thẳngTheming token (--card-bg)

CSS custom properties pierce the shadow boundary — the main theming channel {CSS custom properties xuyên thẳng qua ranh giới shadow — kênh theming chính}:

/* Trang ngoài đặt token: */
user-card { --card-bg: #111; }
/* Trong shadow dùng token đó: */
.card { background: var(--card-bg, white); }

6.1. Constructable Stylesheets — chia sẻ CSS, không lặp <style> {Constructable Stylesheets — share CSS without repeating <style>}

Putting a <style> in every instance re-parses CSS each time {Đặt <style> trong mỗi instance khiến CSS bị parse lại mỗi lần}. A shared CSSStyleSheet is parsed once and adopted by many roots {Một CSSStyleSheet dùng chung được parse một lần và adopt bởi nhiều root}:

// Parse 1 lần ở module scope.
const sheet = new CSSStyleSheet();
sheet.replaceSync(`.card { padding: 16px; border-radius: 12px; }`);

class UserCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    // Mọi instance share cùng 1 stylesheet object → tiết kiệm bộ nhớ + parse.
    shadow.adoptedStyleSheets = [sheet];
    shadow.innerHTML = `<div class="card"><slot></slot></div>`;
  }
}

7. Form-associated custom elements — hòa nhập với <form> {Form-associated custom elements — integrate with <form>}

A plain custom element is invisible to a <form>: it doesn’t submit a value, doesn’t participate in validation {Một custom element thường vô hình với <form>: không submit giá trị, không tham gia validation}. ElementInternals fixes this {ElementInternals khắc phục điều đó}:

class RatingInput extends HTMLElement {
  // Bật chế độ form-associated.
  static formAssociated = true;

  #internals;
  #value = '';

  constructor() {
    super();
    // attachInternals() trả về cầu nối với form + accessibility.
    this.#internals = this.attachInternals();
  }

  get value() { return this.#value; }
  set value(v) {
    this.#value = v;
    // Đẩy giá trị vào form — sẽ xuất hiện trong FormData khi submit.
    this.#internals.setFormValue(v);
    // Cập nhật trạng thái validity.
    if (!v) {
      this.#internals.setValidity({ valueMissing: true }, 'Vui lòng chọn số sao', this);
    } else {
      this.#internals.setValidity({});
    }
  }

  // Các callback form-associated:
  formResetCallback() { this.value = ''; }
  formDisabledCallback(disabled) { /* mờ UI khi fieldset disabled */ }
  formStateRestoreCallback(state) { this.value = state; } // autofill/bfcache
}
customElements.define('rating-input', RatingInput);

Now <rating-input name="stars"> inside a <form> submits its value in FormData, participates in :invalid styling, and resets correctly {Giờ <rating-input name="stars"> trong <form> sẽ submit giá trị qua FormData, tham gia style :invalid, và reset đúng}. ElementInternals also exposes ARIA properties (role, ariaChecked…) for accessibility without polluting attributes {ElementInternals còn expose các thuộc tính ARIA cho a11y mà không làm bẩn attribute}.


8. Declarative Shadow DOM — Web Components chạy được SSR {Declarative Shadow DOM — SSR-able Web Components}

For years, shadow DOM could only be created with JavaScript, so server-rendered HTML couldn’t include it — bad for performance and SEO {Nhiều năm liền, shadow DOM chỉ tạo được bằng JavaScript, nên HTML render từ server không thể chứa nó — xấu cho hiệu năng và SEO}. Declarative Shadow DOM (DSD) lets the server emit a shadow root as pure HTML {Declarative Shadow DOM (DSD) cho phép server phát ra một shadow root dưới dạng HTML thuần}:

<user-card>
  <template shadowrootmode="open">
    <style>.card { padding: 16px; }</style>
    <div class="card"><slot></slot></div>
  </template>
  <span slot="title">vinxi</span>
</user-card>

The browser sees <template shadowrootmode="open"> and attaches it as a real shadow root during HTML parsing — no JS required for first paint {Trình duyệt thấy <template shadowrootmode="open">gắn nó thành shadow root thật ngay khi parse HTML — không cần JS cho lần vẽ đầu}. The component then “hydrates” when its JS loads {Sau đó component “hydrate” khi JS của nó tải xong}.

To read DSD on the server or hydrate carefully {Để đọc DSD trên server hoặc hydrate cẩn thận}, check whether a shadow root already exists before calling attachShadow {kiểm tra xem shadow root đã tồn tại chưa trước khi gọi attachShadow}:

connectedCallback() {
  // Nếu DSD đã tạo sẵn shadow root từ server → tái dùng, đừng tạo lại.
  if (!this.shadowRoot) {
    this.attachShadow({ mode: 'open' }).innerHTML = TEMPLATE;
  }
  this.#bind(); // chỉ gắn event listener / logic
}

9. Accessibility — đừng đánh mất khi đóng gói {Accessibility — don’t lose it in encapsulation}

Encapsulation is a double-edged sword for a11y {Đóng gói là con dao hai lưỡi với a11y}:

  • ARIA across shadow boundaries is limited {ARIA qua ranh giới shadow bị giới hạn}: aria-labelledby/aria-describedby historically can’t reference IDs in a different tree {về lịch sử không thể tham chiếu ID ở cây khác}. Use ElementInternals ARIA props, or keep related nodes in the same root {Dùng các thuộc tính ARIA của ElementInternals, hoặc giữ các node liên quan trong cùng một root}.
  • Focus management {Quản lý focus}: set tabindex, handle keyboard, and use delegatesFocus: true in attachShadow so focusing the host moves focus to the first focusable inside {đặt tabindex, xử lý bàn phím, và dùng delegatesFocus: true trong attachShadow để focus vào host sẽ chuyển focus tới phần tử focus được đầu tiên bên trong}.
  • Prefer extending semantics {Ưu tiên kế thừa ngữ nghĩa}: when wrapping interactive controls, project a real <button>/<input> through a slot instead of reinventing roles {khi bọc các control tương tác, hãy chiếu một <button>/<input> thật qua slot thay vì tự chế lại role}.

10. Khi nào dùng, framework interop, và pitfalls {When to use, interop, and pitfalls}

Dùng Web Components khi {Use Web Components when}: building a design system shared across frameworks, embeddable widgets (third-party), or framework-agnostic primitives that must outlive a stack migration {xây design system chia sẻ giữa nhiều framework, widget nhúng được (bên thứ ba), hoặc các primitive độc lập framework cần sống qua một lần migrate stack}.

Cân nhắc kỹ khi {Think twice when}: the whole app is already React/Vue and you need rich app state, routing, or fine-grained reactivity — frameworks do that better {cả app đã là React/Vue và bạn cần state phức tạp, routing, hay reactivity tinh — framework làm tốt hơn}.

Framework interop {Tương tác framework}: most frameworks pass strings as attributes by default {phần lớn framework truyền chuỗi qua attribute mặc định}. React ≤18 sets everything as attributes and listens poorly to custom events {React ≤18 đặt mọi thứ thành attribute và lắng nghe custom event kém}; React 19 added proper custom element support (properties + events) {React 19 đã thêm hỗ trợ custom element đầy đủ (property + event)}. In Vue/Angular use the property-binding syntax (:prop / [prop]) for non-string data {Trong Vue/Angular dùng cú pháp bind property cho dữ liệu không phải chuỗi}.

Pitfalls hay gặp {Common pitfalls}:

  • Touching attributes/children in the constructor {Đụng attributes/children trong constructor} — too early; do it in connectedCallback {quá sớm; hãy làm trong connectedCallback}.
  • Forgetting composed: true {Quên composed: true} → events die at the shadow boundary {event chết ở ranh giới shadow}.
  • No cleanup in disconnectedCallback {Không cleanup trong disconnectedCallback} → leaked timers/listeners {rò rỉ timer/listener}.
  • Passing an object via attribute {Truyền object qua attribute} → you get "[object Object]" {bạn nhận "[object Object]"}; use a property {dùng property}.
  • Heavy innerHTML per instance {innerHTML nặng mỗi instance} → use <template> + Constructable Stylesheets {dùng <template> + Constructable Stylesheets}.

11. Ví dụ hoàn chỉnh — <toggle-switch> {Full example — <toggle-switch>}

Putting every pillar together: a reusable, accessible, form-associated toggle {Ghép mọi trụ cột lại: một toggle tái dùng, accessible, form-associated}.

// toggle-switch.js
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: inline-block; cursor: pointer; --w: 44px; --h: 24px; }
  :host([disabled]) { opacity: .5; pointer-events: none; }
  .track {
    width: var(--w); height: var(--h); border-radius: 999px;
    background: var(--off, #ccc); transition: background .2s;
  }
  :host([checked]) .track { background: var(--on, #22c55e); }
  .thumb {
    width: calc(var(--h) - 4px); height: calc(var(--h) - 4px);
    margin: 2px; border-radius: 50%; background: #fff;
    transform: translateX(0); transition: transform .2s;
  }
  :host([checked]) .thumb { transform: translateX(calc(var(--w) - var(--h))); }
`);

const template = document.createElement('template');
template.innerHTML = `<div class="track"><div class="thumb"></div></div>`;

class ToggleSwitch extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['checked', 'disabled'];

  #internals;

  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
    shadow.adoptedStyleSheets = [sheet];
    shadow.appendChild(template.content.cloneNode(true));
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    // Vai trò + bàn phím cho accessibility.
    if (!this.hasAttribute('role')) this.setAttribute('role', 'switch');
    if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
    this.#sync();
    this.addEventListener('click', this.#toggle);
    this.addEventListener('keydown', this.#onKey);
  }

  disconnectedCallback() {
    // Cleanup tương ứng với connectedCallback.
    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 = () => {
    if (this.hasAttribute('disabled')) return;
    this.checked = !this.checked;
    this.#internals.setFormValue(this.checked ? 'on' : '');
    // Event công khai: bubbles + composed để ra khỏi shadow.
    this.dispatchEvent(new CustomEvent('change', {
      detail: { checked: this.checked }, bubbles: true, composed: true,
    }));
  };

  #onKey = (e) => {
    if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); this.#toggle(); }
  };

  #sync() {
    // Đồng bộ ARIA state với attribute checked.
    this.#internals.ariaChecked = String(this.checked);
  }
}
customElements.define('toggle-switch', ToggleSwitch);
<form>
  <label>Nhận thông báo
    <toggle-switch name="notify" checked></toggle-switch>
  </label>
</form>
<script type="module" src="/toggle-switch.js"></script>
<style>
  /* Theming từ ngoài qua custom properties + ::part nếu expose thêm. */
  toggle-switch { --on: #6366f1; }
</style>

This single tag works in plain HTML, React, Vue, or Angular, ships zero framework code, and degrades gracefully {Thẻ duy nhất này chạy trong HTML thuần, React, Vue, hay Angular, không gửi kèm code framework nào, và suy giảm duyên dáng}.


Kết luận {Conclusion}

Web Components are a platform primitive, not a framework {Web Components là một primitive của nền tảng, không phải framework}. They give you encapsulation, reusability, and longevity for free {Chúng cho bạn đóng gói, tái dùng, và tuổi thọ miễn phí}. The raw API is verbose — that’s exactly the gap libraries like Lit fill {API thô khá dài dòng — đó chính là khoảng trống mà các thư viện như Lit lấp vào} (the topic of the next post) {(chủ đề của bài kế tiếp)}.

Tham khảo {References}: