stave.dev

Counter Component Tutorial

Build your first ShadowComponent from scratch — observable properties, event handling, and Shadow DOM styling.

Beginner

This tutorial walks you through building a counter component step by step. By the end, you will have a reusable <stave-counter> web component that tracks a count value, responds to button clicks, and styles itself inside the Shadow DOM.

ShadowComponent Framework

This tutorial uses the ShadowComponent framework, which lets you define web components in single HTML files with declarative templates, scoped styles, and reactive properties. Read the overview if you haven't already.

1 Start with a Minimal Template

Every ShadowComponent is a single HTML file. The file name determines the tag name: stave-counter.html becomes <stave-counter>. Let us start with the bare minimum:

<template>
    <div class="counter">
        <span class="count">0</span>
    </div>
</template>

<style>
    .counter {
        display: inline-flex;
        align-items: center;
        gap: 0.5rem;
        font-family: sans-serif;
    }

    .count {
        font-size: 2rem;
        font-weight: bold;
        min-width: 3ch;
        text-align: center;
    }
</style>

<script>
    class StaveCounter extends ShadowComponent {
        // Component logic will go here
    }
</script>

Three sections, each with a clear purpose:

  • <template> — The HTML structure rendered inside the Shadow DOM
  • <style> — Scoped CSS that only applies to this component (no leaking)
  • <script> — The component class extending ShadowComponent

This renders a static "0" on the page. Not very useful yet, but it is a valid component.

2 Add an Observable Property

To make the count reactive, we declare it as an observable property. When the property changes, the component automatically updates the DOM:

<template>
    <div class="counter">
        <span class="count" data-bind="count">0</span>
    </div>
</template>
class StaveCounter extends ShadowComponent {

    static get observedAttributes() {
        return ['count'];
    }

    get defaults() {
        return {
            count: 0
        };
    }

    onPropertyChanged(name, oldValue, newValue) {
        if (name === 'count') {
            this.shadowRoot.querySelector('[data-bind="count"]').textContent = newValue;
        }
    }
}

Key concepts:

  • observedAttributes — Tells the browser which HTML attributes to watch. When count changes on the element, the component reacts.
  • defaults — Sets the initial value. The count starts at 0.
  • onPropertyChanged — Called whenever an observed property changes. We update the DOM element that displays the count.
  • data-bind="count" — A convention to mark which DOM element displays this property's value.

Now you can set the count from outside: <stave-counter count="5"> renders "5" instead of "0".

3 Add Buttons and Events

A counter needs increment and decrement buttons. We add them to the template and wire up click handlers in the script:

<template>
    <div class="counter">
        <button class="btn decrement" aria-label="Decrement">−</button>
        <span class="count" data-bind="count">0</span>
        <button class="btn increment" aria-label="Increment">+</button>
    </div>
</template>
class StaveCounter extends ShadowComponent {

    static get observedAttributes() {
        return ['count'];
    }

    get defaults() {
        return {
            count: 0
        };
    }

    onConnected() {
        this.shadowRoot.querySelector('.increment').addEventListener('click', () => {
            this.count++;
        });
        this.shadowRoot.querySelector('.decrement').addEventListener('click', () => {
            this.count--;
        });
    }

    onPropertyChanged(name, oldValue, newValue) {
        if (name === 'count') {
            this.shadowRoot.querySelector('[data-bind="count"]').textContent = newValue;
        }
    }
}

The onConnected() lifecycle method fires when the component is added to the DOM. This is where you set up event listeners. Because count is an observed property, simply assigning this.count++ triggers onPropertyChanged automatically — you don't need to manually update the display.

Lifecycle Methods

onConnected() replaces the native connectedCallback(). ShadowComponent also provides onDisconnected(), onReady() (after first render), and onPropertyChanged(). Use these instead of the raw custom element callbacks.

4 Add Styling

Styles inside a ShadowComponent are scoped to the Shadow DOM. They cannot leak out to the rest of the page, and external styles cannot reach in (except through CSS custom properties). Here is the full styled version:

.counter {
    display: inline-flex;
    align-items: center;
    gap: 0.75rem;
    font-family: var(--font-mono, monospace);
    padding: 0.5rem;
}

.count {
    font-size: 2rem;
    font-weight: bold;
    min-width: 3ch;
    text-align: center;
    color: var(--accent-primary, #d4622b);
}

.btn {
    background: rgba(255, 255, 255, 0.08);
    border: 1px solid rgba(255, 255, 255, 0.15);
    border-radius: 4px;
    color: inherit;
    font-size: 1.25rem;
    width: 2.5rem;
    height: 2.5rem;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    transition: background 150ms ease, border-color 150ms ease;
}

.btn:hover {
    background: rgba(255, 255, 255, 0.15);
    border-color: rgba(255, 255, 255, 0.25);
}

.btn:active {
    transform: scale(0.95);
}

Note the use of CSS custom properties like var(--accent-primary, #d4622b). Custom properties do cross the Shadow DOM boundary, which means the host page can theme the component by setting --accent-primary in its own stylesheet. The fallback value ensures the component still looks good without a theme.

5 The Complete Component

Here is the final stave-counter.html with all sections combined:

<template>
    <div class="counter">
        <button class="btn decrement" aria-label="Decrement">−</button>
        <span class="count" data-bind="count">0</span>
        <button class="btn increment" aria-label="Increment">+</button>
    </div>
</template>

<style>
    .counter {
        display: inline-flex;
        align-items: center;
        gap: 0.75rem;
        font-family: var(--font-mono, monospace);
        padding: 0.5rem;
    }

    .count {
        font-size: 2rem;
        font-weight: bold;
        min-width: 3ch;
        text-align: center;
        color: var(--accent-primary, #d4622b);
    }

    .btn {
        background: rgba(255, 255, 255, 0.08);
        border: 1px solid rgba(255, 255, 255, 0.15);
        border-radius: 4px;
        color: inherit;
        font-size: 1.25rem;
        width: 2.5rem;
        height: 2.5rem;
        cursor: pointer;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        transition: background 150ms ease, border-color 150ms ease;
    }

    .btn:hover {
        background: rgba(255, 255, 255, 0.15);
        border-color: rgba(255, 255, 255, 0.25);
    }

    .btn:active {
        transform: scale(0.95);
    }
</style>

<script>
    class StaveCounter extends ShadowComponent {

        static get observedAttributes() {
            return ['count'];
        }

        get defaults() {
            return {
                count: 0
            };
        }

        onConnected() {
            this.shadowRoot.querySelector('.increment').addEventListener('click', () => {
                this.count++;
            });
            this.shadowRoot.querySelector('.decrement').addEventListener('click', () => {
                this.count--;
            });
        }

        onPropertyChanged(name, oldValue, newValue) {
            if (name === 'count') {
                this.shadowRoot.querySelector('[data-bind="count"]').textContent = newValue;
            }
        }
    }
</script>

That is the entire component in one file — template, styles, and logic. No build step, no bundler, no configuration.

6 Using the Component

To use <stave-counter> in your page, place the HTML file in a component directory that ShadowComponent scans, then use the tag in your markup:

<!-- Basic usage -->
<stave-counter></stave-counter>

<!-- Start at a specific count -->
<stave-counter count="10"></stave-counter>

<!-- Multiple independent counters -->
<stave-counter></stave-counter>
<stave-counter count="100"></stave-counter>
<stave-counter count="-5"></stave-counter>

Each instance maintains its own state. Changing the count on one counter does not affect the others. You can also set the count programmatically from JavaScript:

const counter = document.querySelector('stave-counter');

// Read the current count
console.log(counter.count); // 0

// Set a new count (triggers onPropertyChanged)
counter.count = 42;

// Also works via attribute
counter.setAttribute('count', '42');

In a Zero Framework module, you would place the component file in modules/YourModule/assets/component/stave-counter.html and it is automatically loaded when the module is requested.

Live Demo

Live stave-counter demo coming soon
Coming Soon

A live, interactive <stave-counter> component will be embedded here once the component is registered on stave.dev. You will be able to click the buttons and watch the count update in real time.

7 Key Takeaways

  1. Single-file components — Template, styles, and script in one HTML file. The file name becomes the tag name.
  2. Observable properties — Declare them in observedAttributes and defaults. Changes trigger onPropertyChanged automatically.
  3. Scoped styles — CSS inside the component cannot leak out. Use CSS custom properties to allow theming from the host page.
  4. Lifecycle hooks — Use onConnected() to set up event listeners and onPropertyChanged() to react to state changes.
  5. No build step — Drop the HTML file in a component directory and use the tag. ShadowComponent handles registration and loading.
Next Steps

Ready for something more complex? The Todo App example combines a Zero Framework module with a ShadowComponent to build a full-stack application. Or explore the ShadowComponent documentation for advanced features like slots, dispatching custom events, and nested components.