1. Pengenalan Web Components
Web Components adalah sekumpulan API web standar yang memungkinkan Anda membuat elemen HTML kustom yang dapat digunakan kembali (reusable), dengan fungsionalitas terenkapsulasi. Berbeda dengan framework seperti React atau Vue, Web Components menggunakan standar web bawaan yang bekerja di semua browser modern tanpa perlu library tambahan.
Tiga teknologi utama yang membentuk Web Components adalah: Custom Elements (mendefinisikan elemen baru), Shadow DOM (enkapsulasi markup dan style), dan HTML Templates (template yang tidak dirender sampai diaktifkan).
Mengapa Menggunakan Web Components?
| Keunggulan | Penjelasan |
|---|---|
| Framework-Agnostic | Bekerja di React, Vue, Angular, Svelte, atau vanilla JS â tanpa lock-in |
| Standar Web | Didukung langsung oleh browser, tidak perlu bundler atau compiler |
| Enkapsulasi | Shadow DOM mencegah konflik CSS dan JS antar komponen |
| Reusabilitas | Sekali dibuat, bisa dipakai di mana saja â bahkan di halaman HTML statis |
| Longevity | Karena ini standar, kode Anda tidak akan obsolete dengan pergantian framework |
| Interoperabilitas | Tim berbeda bisa pakai framework berbeda tapi berbagi komponen yang sama |
Web Components vs Framework Components
| Aspek | Web Components | React Components | Vue Components |
|---|---|---|---|
| Standar | W3C Web Standards | Library API | Framework API |
| Enkapsulasi | Shadow DOM (native) | CSS Modules / CSS-in-JS | Scoped styles |
| Bundle Size | 0 KB (native) | ~42 KB (React) | ~33 KB (Vue) |
| Reactivity | Manual / Attribute API | Virtual DOM | Reactive Proxy |
| Ecosystem | Kecil tapi tumbuh | Sangat besar | Besar |
4. HTML Templates & Slots
<template> dan <slot> adalah elemen HTML khusus yang sangat berguna untuk Web Components. <template> mendefinisikan fragmen HTML yang tidak dirender sampai diaktifkan oleh JavaScript. <slot> menyediakan placeholder di dalam komponen untuk konten yang di-supply dari luar.
Menggunakan HTML Template
<!-- Definisikan template di HTML -->
<template id="alert-template">
<style>
.alert {
padding: 16px 20px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
font-family: system-ui, sans-serif;
}
.alert-success { background: #1a3a2a; color: #a6e3a1; border: 1px solid #2a5a3a; }
.alert-warning { background: #3a3a1a; color: #f9e2af; border: 1px solid #5a5a2a; }
.alert-error { background: #3a1a1a; color: #f38ba8; border: 1px solid #5a2a2a; }
.alert-info { background: #1a2a3a; color: #89b4fa; border: 1px solid #2a3a5a; }
.icon { font-size: 20px; }
.close-btn {
margin-left: auto;
background: none;
border: none;
color: inherit;
cursor: pointer;
opacity: 0.6;
font-size: 18px;
}
.close-btn:hover { opacity: 1; }
</style>
<div class="alert">
<span class="icon"></span>
<span class="message"></span>
<button class="close-btn">â</button>
</div>
</template>
<script>
// Ambil template dari DOM
const template = document.getElementById('alert-template');
class AlertBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Clone template content ke shadow DOM
const content = template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
}
connectedCallback() {
const type = this.getAttribute('type') || 'info';
const pesan = this.getAttribute('message') || 'Ini pesan alert';
const dismissible = this.hasAttribute('dismissible');
const icons = {
success: 'â
',
warning: 'â ī¸',
error: 'â',
info: 'âšī¸'
};
const alertDiv = this.shadowRoot.querySelector('.alert');
alertDiv.classList.add(`alert-${type}`);
this.shadowRoot.querySelector('.icon').textContent = icons[type] || icons.info;
this.shadowRoot.querySelector('.message').textContent = pesan;
const closeBtn = this.shadowRoot.querySelector('.close-btn');
if (!dismissible) {
closeBtn.style.display = 'none';
} else {
closeBtn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('alert-dismissed', {
bubbles: true,
composed: true,
}));
this.remove();
});
}
}
}
customElements.define('alert-box', AlertBox);
</script>
<!-- Penggunaan -->
<alert-box type="success" message="Data berhasil disimpan!" dismissible></alert-box>
<alert-box type="error" message="Gagal menghubungi server" dismissible></alert-box>
<alert-box type="warning" message="Sesi akan berakhir dalam 5 menit"></alert-box>
<alert-box type="info" message="Versi baru tersedia, silakan refresh"></alert-box>
Named Slots & Default Slot
// ====== Modal Component dengan Named Slots ======
class ModalDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: none; }
:host([open]) { display: block; }
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 16px;
padding: 0;
min-width: 400px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 24px 48px rgba(0,0,0,0.4);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #313244;
}
.modal-header h2 {
margin: 0;
color: #cdd6f4;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
color: #6c7086;
font-size: 24px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.close-btn:hover { background: #313244; color: #cdd6f4; }
.modal-body {
padding: 24px;
overflow-y: auto;
max-height: 50vh;
color: #bac2de;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #313244;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Slot styling */
::slotted([slot="header"]) {
margin: 0;
color: #cdd6f4;
}
::slotted([slot="footer"]) {
margin: 0;
}
</style>
<div class="overlay">
<div class="modal">
<div class="modal-header">
<!-- Named slot: "header" -->
<slot name="header"><h2>Modal Title</h2></slot>
<button class="close-btn">â</button>
</div>
<div class="modal-body">
<!-- Default slot: konten tanpa nama -->
<slot>Konten modal di sini.</slot>
</div>
<div class="modal-footer">
<!-- Named slot: "footer" -->
<slot name="footer"></slot>
</div>
</div>
</div>
`;
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
this.close();
});
this.shadowRoot.querySelector('.overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) this.close();
});
}
open() { this.setAttribute('open', ''); }
close() { this.removeAttribute('open'); }
}
customElements.define('modal-dialog', ModalDialog);
// Penggunaan:
// <modal-dialog id="myModal">
// <h2 slot="header">Konfirmasi Hapus</h2>
// <p>Apakah Anda yakin ingin menghapus item ini?</p>
// <p>Tindakan ini tidak bisa dibatalkan.</p>
// <div slot="footer">
// <button onclick="myModal.close()">Batal</button>
// <button onclick="hapusItem()">Hapus</button>
// </div>
// </modal-dialog>
5. Lifecycle Callbacks
Custom Elements memiliki serangkaian lifecycle callbacks yang dipanggil secara otomatis oleh browser pada titik-titik penting dalam kehidupan elemen. Memahami lifecycle ini sangat penting untuk membuat komponen yang berperilaku benar.
// ====== Semua Lifecycle Callbacks ======
class LifecycleDemo extends HTMLElement {
// 1. Constructor â saat elemen dibuat (new LifecycleDemo() atau parsing HTML)
constructor() {
super();
console.log('1ī¸âŖ Constructor: Elemen dibuat');
// â
Boleh: setup shadow DOM, bind methods, inisialisasi state
this.attachShadow({ mode: 'open' });
this._state = { count: 0 };
// â Jangan: akses attributes, children, atau parent di sini
// this.getAttribute('data'); // Kemungkinan null karena belum dipasang
}
// 2. connectedCallback â saat elemen ditambahkan ke DOM
connectedCallback() {
console.log('2ī¸âŖ Connected: Elemen ditambahkan ke DOM');
// â
Boleh: baca attributes, setup event listeners, fetch data
const nama = this.getAttribute('nama') || 'World';
this.render(nama);
// Setup event listener
this._handleClick = () => {
this._state.count++;
this.updateCounter();
};
this.shadowRoot.querySelector('button')
?.addEventListener('click', this._handleClick);
// Fetch data
this.loadData();
}
// 3. disconnectedCallback â saat elemen dihapus dari DOM
disconnectedCallback() {
console.log('3ī¸âŖ Disconnected: Elemen dihapus dari DOM');
// â
Cleanup: hapus event listeners, stop timers, abort fetch
this.shadowRoot.querySelector('button')
?.removeEventListener('click', this._handleClick);
if (this._timer) clearInterval(this._timer);
if (this._abortController) this._abortController.abort();
}
// 4. adoptedCallback â saat elemen dipindahkan ke dokumen baru
adoptedCallback() {
console.log('4ī¸âŖ Adopted: Elemen dipindahkan ke dokumen baru');
// Jarang digunakan, tapi berguna untuk iframe atau document.adoptNode()
const newDoc = document;
console.log('Dipindahkan ke dokumen baru:', newDoc.title);
}
// 5. attributeChangedCallback â saat attribute diamati berubah
static get observedAttributes() {
return ['nama', 'count', 'disabled'];
}
attributeChangedCallback(name, oldVal, newVal) {
console.log(`5ī¸âŖ Attribute "${name}" berubah: "${oldVal}" â "${newVal}"`);
// â
Update UI berdasarkan attribute baru
if (name === 'nama' && this.isConnected) {
this.render(newVal);
}
if (name === 'disabled') {
const btn = this.shadowRoot.querySelector('button');
if (btn) btn.disabled = newVal !== null;
}
}
// Helper methods
render(nama) {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; padding: 16px; font-family: system-ui; }
p { color: #cdd6f4; }
button { padding: 8px 16px; border-radius: 6px; cursor: pointer; }
</style>
<p>Halo, ${nama}!</p>
<p>Klik: <strong id="count">${this._state.count}</strong> kali</p>
<button>Klik Saya</button>
`;
}
updateCounter() {
const el = this.shadowRoot.getElementById('count');
if (el) el.textContent = this._state.count;
}
async loadData() {
this._abortController = new AbortController();
try {
// const res = await fetch('/api/data', { signal: this._abortController.signal });
} catch (e) {
if (e.name !== 'AbortError') console.error(e);
}
}
}
customElements.define('lifecycle-demo', LifecycleDemo);
// Urutan lifecycle saat parsing HTML:
// 1. constructor()
// 2. attributeChangedCallback() (jika ada attribute)
// 3. connectedCallback()
// Urutan saat update attribute:
// 1. attributeChangedCallback()
// Urutan saat remove:
// 1. disconnectedCallback()
6. Observed Attributes & Properties
Custom Elements mendukung dua cara untuk mengkonfigurasi: attributes (HTML attributes, string) dan properties (JavaScript properties, tipe apapun). Keduanya perlu disinkronkan untuk pengalaman pengembang yang baik.
// ====== Sinkronisasi Attribute â Property ======
class RatingStars extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._value = 0;
this._max = 5;
this._readonly = false;
}
// Daftar attribute yang diamati
static get observedAttributes() {
return ['value', 'max', 'readonly'];
}
// Getter & Setter untuk properties (JavaScript API)
get value() { return this._value; }
set value(val) {
this._value = Math.max(0, Math.min(Number(val), this._max));
// Sinkronkan ke attribute
this.setAttribute('value', this._value);
this._render();
}
get max() { return this._max; }
set max(val) {
this._max = Number(val) || 5;
this.setAttribute('max', this._max);
this._render();
}
get readonly() { return this._readonly; }
set readonly(val) {
this._readonly = val !== null && val !== false;
if (this._readonly) {
this.setAttribute('readonly', '');
} else {
this.removeAttribute('readonly');
}
this._render();
}
// Callback saat attribute berubah (dari HTML atau setAttribute)
attributeChangedCallback(name, oldVal, newVal) {
switch (name) {
case 'value':
this._value = Number(newVal) || 0;
break;
case 'max':
this._max = Number(newVal) || 5;
break;
case 'readonly':
this._readonly = newVal !== null;
break;
}
if (this.isConnected) this._render();
}
connectedCallback() {
this._render();
}
_render() {
let stars = '';
for (let i = 1; i <= this._max; i++) {
const filled = i <= this._value ? 'â
' : 'â';
const color = i <= this._value ? '#f9e2af' : '#585b70';
stars += `<span class="star" data-value="${i}"
style="color:${color};cursor:${this._readonly?'default':'pointer'};font-size:24px">
${filled}
</span>`;
}
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; align-items: center; }
.star { transition: transform 0.1s; user-select: none; }
.star:hover { transform: scale(1.2); }
</style>
${stars}
<span style="margin-left:8px;color:#a6adc8;font-size:14px">
${this._value}/${this._max}
</span>
`;
if (!this._readonly) {
this.shadowRoot.querySelectorAll('.star').forEach(star => {
star.addEventListener('click', (e) => {
this.value = Number(e.target.dataset.value);
this.dispatchEvent(new CustomEvent('rating-change', {
detail: { value: this._value },
bubbles: true,
composed: true,
}));
});
});
}
}
}
customElements.define('rating-stars', RatingStars);
// ====== Penggunaan ======
// Via HTML (attributes â semua string):
// <rating-stars value="4" max="5"></rating-stars>
// <rating-stars value="3" max="10" readonly></rating-stars>
// Via JavaScript (properties â tipe apapun):
// const stars = document.querySelector('rating-stars');
// stars.value = 4; // number
// stars.max = 5; // number
// stars.readonly = true; // boolean
7. Custom Events & Communication
Web Components berkomunikasi dengan komponen lain melalui Custom Events. Ini adalah pola yang sama dengan event DOM native â dan memastikan komponen tetap loosely coupled.
// ====== Component yang mengirim event ======
class SearchInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
input {
width: 100%;
padding: 12px 16px;
border: 2px solid #313244;
border-radius: 8px;
background: #181825;
color: #cdd6f4;
font-size: 16px;
outline: none;
box-sizing: border-box;
}
input:focus { border-color: #6366f1; }
</style>
<input type="search" placeholder="Cari...">
`;
}
connectedCallback() {
const input = this.shadowRoot.querySelector('input');
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// Dispatch custom event dengan data
this.dispatchEvent(new CustomEvent('search', {
detail: {
query: e.target.value,
timestamp: Date.now(),
},
bubbles: true, // Event naik ke parent
composed: true, // Event melewati shadow boundary
}));
}, 300); // Debounce 300ms
});
}
}
customElements.define('search-input', SearchInput);
// ====== Component yang mendengarkan event ======
class SearchResults extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<div class="results"></div>`;
}
connectedCallback() {
// Dengarkan event dari search-input
document.addEventListener('search', (e) => {
this.handleSearch(e.detail.query);
});
}
async handleSearch(query) {
if (!query.trim()) {
this.shadowRoot.querySelector('.results').innerHTML = '<p>Ketik untuk mulai mencari</p>';
return;
}
// Simulasi pencarian
const results = [
'React.js Tutorial', 'Vue.js Guide', 'Svelte Basics',
'Web Components', 'Angular Framework'
].filter(item => item.toLowerCase().includes(query.toLowerCase()));
this.shadowRoot.querySelector('.results').innerHTML = results.length > 0
? results.map(r => `<div class="result-item">${r}</div>`).join('')
: '<p>Tidak ditemukan</p>';
}
}
customElements.define('search-results', SearchResults);
// ====== Penggunaan ======
// <search-input></search-input>
// <search-results></search-results>
//
// Event "search" dari search-input bubbles up dan di-dengar
// oleh search-results melalui document listener
8. Styling Web Components
Styling Web Components membutuhkan pemahaman khusus karena Shadow DOM menyediakan enkapsulasi. Ada beberapa cara untuk mengontrol styling dari dalam maupun dari luar komponen.
// ====== CSS Custom Properties (CSS Variables) â tembus Shadow DOM ======
// CSS Custom Properties bisa menembus Shadow DOM!
class ThemedCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
border-radius: 12px;
/* Gunakan CSS custom properties dari luar */
background: var(--card-bg, #1e1e2e);
color: var(--card-text, #cdd6f4);
border: 1px solid var(--card-border, #313244);
font-family: var(--card-font, system-ui, sans-serif);
}
h3 {
color: var(--card-title, #89b4fa);
font-size: var(--card-title-size, 20px);
}
p { color: var(--card-text, #a6adc8); }
</style>
<h3><slot name="title">Judul</slot></h3>
<p><slot>Konten...</slot></p>
`;
}
}
customElements.define('themed-card', ThemedCard);
// ====== Penggunaan dengan CSS Variables dari luar ======
/*
<style>
/* Override tema untuk semua themed-card */
themed-card {
--card-bg: #11111b;
--card-title: #f9e2af;
--card-text: #bac2de;
--card-border: #45475a;
}
/* Override khusus untuk satu card */
.featured-card {
--card-bg: #1e1e3e;
--card-title: #f5c2e7;
--card-border: #6366f1;
}
</style>
<themed-card>
<span slot="title">Tutorial Web</span>
Belajar Web Components dengan mudah.
</themed-card>
<themed-card class="featured-card">
<span slot="title">â Featured</span>
Komponen dengan tema kustom!
</themed-card>
*/
// ====== ::part() â Mengekspos bagian spesifik ======
class PartDemo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.wrapper { padding: 20px; background: #1e1e2e; border-radius: 12px; }
.header { color: #cdd6f4; }
.content { color: #a6adc8; }
.footer { color: #6c7086; }
</style>
<div class="wrapper">
<h2 part="header" class="header">Judul</h2>
<div part="content" class="content"><slot></slot></div>
<footer part="footer" class="footer">Footer</footer>
</div>
`;
}
}
customElements.define('part-demo', PartDemo);
// Dari luar, bisa style bagian yang di-ekspos via ::part():
/*
part-demo::part(header) {
color: #f9e2af;
font-size: 28px;
}
part-demo::part(content) {
padding: 16px;
background: #313244;
border-radius: 8px;
}
part-demo::part(footer) {
border-top: 1px solid #45475a;
padding-top: 12px;
}
*/
9. Teknik Lanjutan
Berikut beberapa teknik lanjutan untuk membuat Web Components yang lebih powerful dan profesional.
// ====== 1. Form-Associated Custom Elements ======
// Komponen bisa berpartisipasi dalam form submission
class MyCheckbox extends HTMLElement {
static formAssociated = true; // Aktifkan form association
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Akses internals untuk form integration
this.internals = this.attachInternals();
this._checked = false;
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
.box {
width: 20px; height: 20px; border: 2px solid #6366f1;
border-radius: 4px; display: flex; align-items: center;
justify-content: center; transition: background 0.2s;
}
.box.checked { background: #6366f1; }
label { color: #cdd6f4; user-select: none; }
</style>
<div class="box"></div>
<label><slot>Checkbox</slot></label>
`;
this.addEventListener('click', () => this.toggle());
this._render();
}
toggle() {
this._checked = !this._checked;
// Update form value
this.internals.setFormValue(this._checked ? 'on' : null);
this.internals.setValidity({});
this._render();
this.dispatchEvent(new Event('change', { bubbles: true }));
}
get checked() { return this._checked; }
set checked(val) { this._checked = val; this._render(); }
// Form reset callback
formResetCallback() {
this._checked = false;
this._render();
}
_render() {
const box = this.shadowRoot.querySelector('.box');
if (box) box.classList.toggle('checked', this._checked);
}
}
customElements.define('my-checkbox', MyCheckbox);
// <form>
// <my-checkbox name="agree">Saya setuju</my-checkbox>
// <button type="submit">Submit</button>
// </form>
// ====== 2. Autonomous mixin pattern ======
const ResizeMixin = (superclass) => class extends superclass {
connectedCallback() {
super.connectedCallback?.();
this._resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
this.onResize?.(entry.contentRect);
}
});
this._resizeObserver.observe(this);
}
disconnectedCallback() {
super.disconnectedCallback?.();
this._resizeObserver?.disconnect();
}
};
const IntersectionMixin = (superclass) => class extends superclass {
connectedCallback() {
super.connectedCallback?.();
this._intersectionObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) this.onVisible?.();
else this.onHidden?.();
}
});
this._intersectionObserver.observe(this);
}
disconnectedCallback() {
super.disconnectedCallback?.();
this._intersectionObserver?.disconnect();
}
};
// Gunakan mixin untuk komponen yang responsif & lazy
class ResponsiveChart extends ResizeMixin(IntersectionMixin(HTMLElement)) {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isVisible = false;
}
onResize(rect) {
console.log(`Chart resized: ${rect.width}x${rect.height}`);
// Re-render chart sesuai ukuran baru
}
onVisible() {
this._isVisible = true;
console.log('Chart visible â mulai animasi');
}
onHidden() {
this._isVisible = false;
console.log('Chart hidden â pause animasi');
}
}
customElements.define('responsive-chart', ResponsiveChart);
10. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Web Components: