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õ}:
| Callback | Khi nào chạy | Dùng để |
|---|---|---|
constructor() | Khi instance được tạo | Khởi tạo state, attach shadow root |
connectedCallback() | Mỗi lần element được gắn vào DOM | Render, add event listener, fetch data |
disconnectedCallback() | Khi element bị gỡ khỏi DOM | Cleanup: 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ác | Hiế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}:
connectedCallbackcan fire more than once {connectedCallbackcó 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 indisconnectedCallback{Mỗi setup phải có teardown tương ứng trongdisconnectedCallback}.attributeChangedCallbackonly fires for attributes listed inobservedAttributes{attributeChangedCallbackchỉ chạy cho attribute có trongobservedAttributes}. 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ừaHTMLElement, 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 implementis=, so it’s rarely used in practice {nhưng Safari từ chối implementis=, 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 withslot="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}. slotchangeevent {Sự kiệnslotchange} — fires when the assigned nodes change {kích hoạt khi các node được gán thay đổi}; read them withslot.assignedElements(){đọc bằngslot.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 withgetAttribute/setAttribute{Đọc bằnggetAttribute/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}: iffalse, the event stops at the shadow boundary and the outside page never hears it {nếufalse, event dừng ở ranh giới shadow và trang bên ngoài không nghe thấy}. Set bothtruefor events meant to be public API {Đặt cả haitruecho 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ướng | Dùng để |
|---|---|---|
:host / :host() | Trong → chính host | Style container của component |
:host-context() | Ngoài → ảnh hưởng trong | Theme theo tổ tiên (rtl, dark) |
::slotted() | Light DOM trong slot | Style nội dung user (giới hạn top-level) |
::part() + part="…" | Ngoài → vào trong | Cho phép user style phần được chỉ định |
| CSS custom properties | Xuyên thẳng | Theming 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"> và 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-describedbyhistorically can’t reference IDs in a different tree {về lịch sử không thể tham chiếu ID ở cây khác}. UseElementInternalsARIA props, or keep related nodes in the same root {Dùng các thuộc tính ARIA củaElementInternals, 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 usedelegatesFocus: trueinattachShadowso focusing the host moves focus to the first focusable inside {đặttabindex, xử lý bàn phím, và dùngdelegatesFocus: truetrongattachShadowđể 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 theconstructor{Đụngattributes/children trongconstructor} — too early; do it inconnectedCallback{quá sớm; hãy làm trongconnectedCallback}. - Forgetting
composed: true{Quêncomposed: true} → events die at the shadow boundary {event chết ở ranh giới shadow}. - No cleanup in
disconnectedCallback{Không cleanup trongdisconnectedCallback} → 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
innerHTMLper instance {innerHTMLnặ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}: