Write your first
Web Component.
A Web Component is a custom HTML element you define yourself. No library. No framework. Just the browser.
This tutorial builds a Web Component one step at a time. Each step adds exactly one new concept. By the end you will have written a fully working component from scratch.
You only need a text editor and a browser. No npm. No install.
Create an index.html and a
my-component.js and you are ready.
Extend HTMLElement
Every Web Component starts the same way: a JavaScript class that
extends HTMLElement, then registered with
customElements.define().
The tag name must contain a hyphen. This is how the browser tells the difference between your custom elements and native HTML elements.
// Step 1: Create a class that extends HTMLElement class MyComponent extends HTMLElement { // Empty for now — we will fill this in } // Step 2: Register the tag name // The name MUST contain a hyphen customElements.define("my-component", MyComponent)
<!doctype html> <html> <body> <!-- Use it like any normal HTML tag --> <my-component></my-component> <script type="module" src="./my-component.js"></script> </body> </html>
- extends HTMLElement — your class inherits all browser element behaviour
- customElements.define() — tells the browser "when you see this tag, use this class"
- type="module" — required so the script runs after the DOM is ready
The component exists but renders nothing yet. That comes next.
connectedCallback
connectedCallback is a
lifecycle method — a function the browser calls
automatically when your element is inserted into the page.
This is where you render your HTML for the first time.
class MyComponent extends HTMLElement { // The browser calls this automatically // when the element is added to the page connectedCallback() { this.innerHTML = ` <p>Hello, I am a Web Component!</p> ` } } customElements.define("my-component", MyComponent)
- connectedCallback() — runs once, when the element enters the DOM
-
this — refers to the element itself, just like
document.querySelector("my-component") - this.innerHTML — sets the inner HTML of your element
attachShadow
Right now your component's styles and the page's styles can
interfere with each other. attachShadow() creates a
private DOM tree inside your element — completely isolated from the
outside world.
This is called the Shadow DOM. Styles inside it cannot leak out. Styles outside cannot leak in.
class MyComponent extends HTMLElement { constructor() { super() // always call super() first // Create the Shadow DOM // mode: "open" means JS outside can still query it this.attachShadow({ mode: "open" }) } connectedCallback() { // Now write to shadowRoot instead of this this.shadowRoot.innerHTML = ` <style> p { color: #c8f542; font-family: monospace; font-size: 18px; padding: 16px; border: 1px solid #c8f542; border-radius: 6px; } </style> <p>I have my own private styles!</p> ` } } customElements.define("my-component", MyComponent)
- constructor() — runs when the element is created, before it enters the DOM
- super() — required first line in every Web Component constructor
-
attachShadow() — creates the private DOM,
accessible via
this.shadowRoot - The CSS inside only affects this component — it will never leak to the page
Attributes & Props
A component that always shows the same thing is not very useful. Use
HTML attributes to pass data in from outside — just
like <input type="text"> or
<img src="...">.
class MyComponent extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) } connectedCallback() { // Read the attribute from HTML // <my-component name="Alice"></my-component> const name = this.getAttribute("name") || "World" this.shadowRoot.innerHTML = ` <style> p { color: #c8f542; font-family: monospace; font-size: 18px; } </style> <p>Hello, ${name}!</p> ` } } customElements.define("my-component", MyComponent)
<!-- Each instance gets different data --> <my-component name="Alice"></my-component> <my-component name="Bob"></my-component> <my-component></my-component> <!-- falls back to "World" -->
- getAttribute("name") — reads the HTML attribute value
- || "World" — provides a fallback if the attribute is missing
- Each instance of the component is independent with its own data
Lifecycle Methods
Web Components have four lifecycle methods the browser calls automatically. Together they control the full life of your element.
class MyComponent extends HTMLElement { // 1. Called when the element is CREATED // (before it's added to the page) constructor() { super() this.attachShadow({ mode: "open" }) console.log("1. constructor — element created") } // 2. Called when the element ENTERS the DOM connectedCallback() { this.render() console.log("2. connectedCallback — now on the page") } // 3. Called when the element LEAVES the DOM // Use this to clean up timers, event listeners, etc. disconnectedCallback() { console.log("3. disconnectedCallback — removed from page") } // 4. Called when an observed attribute CHANGES // Must declare which attributes to watch static get observedAttributes() { return ["name"] // watch these attributes } attributeChangedCallback(attr, oldValue, newValue) { console.log(`4. attribute "${attr}" changed: ${oldValue} → ${newValue}`) this.render() // re-render when attribute changes } render() { const name = this.getAttribute("name") || "World" this.shadowRoot.innerHTML = `<p>Hello, ${name}!</p>` } } customElements.define("my-component", MyComponent)
- constructor() — element is created, set up your Shadow DOM here
- connectedCallback() — element enters the DOM, render and start timers here
- disconnectedCallback() — element leaves the DOM, clean up timers here
- attributeChangedCallback() — an observed attribute changed, re-render here
disconnectedCallback is critical if you use
setInterval or addEventListener on
window. Always clean up, or you will create memory
leaks.
Events
Use event delegation — attach one listener to the
Shadow Root instead of one listener per button. Use
data-action attributes to identify what each element
does.
class MyComponent extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) this.count = 0 } connectedCallback() { this.render() // One listener on the shadowRoot // handles ALL clicks inside the component this.shadowRoot.addEventListener("click", (e) => { const action = e.target.dataset.action if (action === "increment") { this.count += 1 this.render() } if (action === "decrement") { this.count -= 1 this.render() } }) } render() { this.shadowRoot.innerHTML = ` <style> :host { display: flex; align-items: center; gap: 16px; } button { background: #1e1e1e; color: #c8f542; border: 1px solid #c8f542; border-radius: 4px; padding: 6px 14px; font-size: 18px; cursor: pointer; } button:hover { background: #c8f542; color: #000; } span { font-family: monospace; font-size: 24px; color: #e2e2dc; } </style> <button data-action="decrement">−</button> <span>${this.count}</span> <button data-action="increment">+</button> ` } } customElements.define("my-component", MyComponent)
- data-action — a custom attribute on the button that names what it does
-
event delegation — one listener catches all
clicks, then checks
data-action - this.count — component state stored directly on the instance
- Note: this pattern works, but is not yet the Flow-Arch way — next we add a Reducer
Full Example
Here is a complete, clean Web Component using everything from this tutorial. This is the pattern Flow-Arch builds on.
// ─── STATE ──────────────────────────────────────────── const createInitialState = () => ({ count: 0 }) // ─── REDUCER (pure function) ────────────────────────── const reducer = (state, action) => { switch (action.type) { case "INCREMENT": return { ...state, count: state.count + 1 } case "DECREMENT": return { ...state, count: state.count - 1 } case "RESET": return { ...state, count: 0 } default: return state } } // ─── VIEW (pure function) ───────────────────────────── const view = (state) => ` <style> :host { display: inline-flex; flex-direction: column; align-items: center; gap: 12px; font-family: monospace; } .count { font-size: 48px; color: #c8f542; } .buttons { display: flex; gap: 8px; } button { padding: 8px 18px; font-size: 16px; background: #1e1e1e; color: #c8f542; border: 1px solid #333; border-radius: 4px; cursor: pointer; transition: all 0.15s; } button:hover { background: #c8f542; color: #000; } .reset { font-size: 12px; color: #5a5a54; margin-top: 4px; } </style> <div class="count">${state.count}</div> <div class="buttons"> <button data-action="DECREMENT">−</button> <button data-action="INCREMENT">+</button> </div> <button class="reset" data-action="RESET">reset</button> ` // ─── WEB COMPONENT ──────────────────────────────────── class FlowCounter extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) this.state = createInitialState() this.dispatch = (action) => { this.state = reducer(this.state, action) this.render() } } connectedCallback() { this.render() this.shadowRoot.addEventListener("click", (e) => { const type = e.target.dataset.action if (type) this.dispatch({ type }) }) } render() { this.shadowRoot.innerHTML = view(this.state) } } customElements.define("flow-counter", FlowCounter)
-
State is now a plain object — not a raw number
on
this - Reducer handles all state changes — no logic inside the component class
- View is a separate pure function — completely decoupled from the component
- dispatch() — the single entry point for all state changes