← Web Components · From Zero

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.

Before you start

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.

STEP 01 The minimum possible component

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.

my-component.js JS
// 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)
index.html HTML
<!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>
What just happened
  • 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.

STEP 02 Render something

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.

my-component.js JS
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)
Live Result
What just happened
  • 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
STEP 03 Isolate styles

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.

my-component.js JS
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)
Live Result
What just happened
  • 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
STEP 04 Accept data from outside

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="...">.

my-component.js JS
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)
index.html — using the attribute HTML
<!-- 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" -->
Live Result
What just happened
  • 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
STEP 05 React to attribute changes

Lifecycle Methods

Web Components have four lifecycle methods the browser calls automatically. Together they control the full life of your element.

all four lifecycle methods JS
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)
The four lifecycle methods
  • 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
Important

disconnectedCallback is critical if you use setInterval or addEventListener on window. Always clean up, or you will create memory leaks.

STEP 06 Respond to user interaction

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.

event delegation pattern JS
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)
Live Result — try it
What just happened
  • 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
COMPLETE Everything together

Full Example

Here is a complete, clean Web Component using everything from this tutorial. This is the pattern Flow-Arch builds on.

flow-counter.js — complete web component JS
// ─── 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)
Live Result — try it
What changed from Step 06
  • 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
You finished the Web Components tutorial. Next: learn about Shadow DOM in depth.
Continue →