Step 03

Component basics

A custom element's public surface is a design decision, not just a technical one. This step is about that surface: the split between attributes (strings, visible in markup, easy to style from CSS) and properties (any JavaScript value, invisible in markup, set only from code), and reflection — the discipline of keeping the two in sync so an element behaves the way a native one does. observedAttributes and attributeChangedCallback let a component react when its markup changes out from under it, and ElementInternals's custom states give you a third option: state CSS can match with :state() that nothing outside the component can set directly.

Learning Objectives

  • Create functional web components with properties and methods
  • Understand constructor() vs connectedCallback() usage
  • Implement attribute reflection and property binding
  • Build reusable components to explore component design
  • Discover that many components are reinventions or should leverage existing HTML elements

Properties vs Attributes

Understanding the distinction between properties and attributes is fundamental to creating well-designed web components.

Attributes

  • String values in HTML
  • Visible in markup
  • Easily bound to CSS
  • Set via HTML or setAttribute()
  • Always strings (or null) though remember you can stringify things
<my-element value="42" disabled></my-element>

Properties

  • JavaScript object properties
  • Not visible in markup
  • Not bindable with CSS easily
  • Set via JavaScript
  • Can be any type
element.value = 42; // Number element.disabled = true; // Boolean element.data = { foo: 'bar' }; // Object

Reflection

Reflection is the practice of keeping attributes and properties in sync. When a property changes, update the attribute. When an attribute changes, update the property. Deciding what should be reflected and exposed or not is a design criteria not an inherent good or bad thing. Be aware framework folks often claim attrs are inherently bad and props good, they are just different approaches and useful for different purposes and often via reflection are roughly the same.

Observing Attribute Changes

Components can react to attribute changes using the observedAttributes and attributeChangedCallback lifecycle method.

class MyElement extends HTMLElement { // Define which attributes to observe static get observedAttributes() { return ['value', 'disabled', 'theme']; } // Called when observed attributes change attributeChangedCallback(name, oldValue, newValue) { console.log(`${name} changed from ${oldValue} to ${newValue}`); // Update component based on attribute change if (name === 'value') { this._value = newValue; this.render(); } } }
💡 Performance Tip: Only observe attributes you actually need to react to. The attributeChangedCallback is called for every observed attribute change.

Property Getters and Setters

Use getters and setters to create a clean API and maintain synchronization between properties and attributes.

Basic Property Pattern

class MyElement extends HTMLElement { get value() { return this._value; } set value(newValue) { this._value = newValue; // Reflect to attribute this.setAttribute('value', newValue); // Re-render this.render(); } } // Usage element.value = 42; // Calls setter, updates attribute

Boolean Properties

Boolean attributes follow a special pattern - they're present or absent, not true/false strings. Interestingly, JavaScript's weak typing kind of helps (or hurts) you here as non-empty strings are "true" and blank ones are false regardless of value. Conventionally, you may see folks using the value "true" or if following XHTML the name of the attribute disabled="disabled", but it is just the presence that matters so remove it if you are false don't set it to "false" as that actually will convert to true in JavaScript!

get disabled() { return this.hasAttribute('disabled'); } set disabled(value) { if (value) { this.setAttribute('disabled', ''); } else { this.removeAttribute('disabled'); } this.render(); }

Usage Examples

<!-- Boolean attributes: present or absent --> <my-element disabled></my-element> <!-- disabled = true --> <my-element></my-element> <!-- disabled = false -->

Custom states

Reflecting to an attribute makes internal state visible to CSS, but it also makes it public: an attribute is part of your API, and anyone can set it. ElementInternals gives you the other option — a private state set, matched from CSS with :state(), that nothing outside the component can write.

class ToggleSwitch extends HTMLElement { #internals = this.attachInternals(); get checked() { return this.#internals.states.has('checked'); } set checked(value) { if (value) this.#internals.states.add('checked'); else this.#internals.states.delete('checked'); } } toggle-switch:state(checked) { background: seagreen; }

Attribute or custom state?

Reflect to an attribute when the outside world is supposed to set it — disabled, value, label. Use a custom state when it is yours alone: hover and drag state, validation state, anything a consumer setting by hand would put the component into a lie about itself.

Demo 1: Toggle Switch Component

A fully functional toggle switch demonstrating properties, attributes, events, and state management.

Features

  • checked - Boolean property/attribute for toggle state
  • disabled - Boolean property/attribute to disable interaction
  • label - String property/attribute for accessibility
  • change - Custom event fired when toggled

Implementation

class ToggleSwitch extends HTMLElement { static get observedAttributes() { return ['checked', 'disabled', 'label']; } constructor() { super(); this._checked = false; this._disabled = false; } get checked() { return this._checked; } set checked(value) { this._checked = Boolean(value); if (this._checked) { this.setAttribute('checked', ''); } else { this.removeAttribute('checked'); } this.render(); } get disabled() { return this._disabled; } set disabled(value) { this._disabled = Boolean(value); if (this._disabled) { this.setAttribute('disabled', ''); } else { this.removeAttribute('disabled'); } this.render(); } connectedCallback() { this._checked = this.hasAttribute('checked'); this._disabled = this.hasAttribute('disabled'); this.render(); this.attachEventListeners(); } render() { const label = this.getAttribute('label') || ''; this.innerHTML = ` <label class="toggle-container"> ${label ? `<span class="toggle-label">${label}</span>` : ''} <span class="toggle-switch ${this._checked ? 'checked' : ''} ${this._disabled ? 'disabled' : ''}"> <span class="toggle-slider"></span> </span> </label> `; } attachEventListeners() { const toggleSwitch = this.querySelector('.toggle-switch'); toggleSwitch.addEventListener('click', () => { if (!this._disabled) { this.checked = !this._checked; this.dispatchEvent(new CustomEvent('change', { detail: { checked: this._checked } })); } }); } } customElements.define('toggle-switch', ToggleSwitch);

Live Demo

Four toggles cover the states above — default, pre-checked, disabled, and labeled — an event log records every change event, and a small API panel drives the first toggle from JavaScript instead of a click, the same programmatic control the "Interactive API Demo" section below explains.

⚠️ Reinvention Take 1

Was this useful to do? It is a common thing in nearly every collection of UI widgets, but should it be? Is a switch not just a <input type="checkbox"> that you style? You can in fact do that easily. That became so common now that it was introduced natively starting late 2023 (See Safari switch)

Demo 2: Progress Bar Component

A progress bar with dynamic updates, demonstrating numeric property handling, validation, and custom events.

Features

  • value - Numeric property for current progress value
  • max - Maximum value (default: 100)
  • color - String property for bar color customization
  • show-percentage - Boolean to display percentage text
  • percentage - Computed getter for percentage value
  • progress-change - Custom event fired when value changes
  • Automatic clamping of values to valid range (0 to max)
  • Proper ARIA attributes for accessibility

Implementation

class ProgressBar extends HTMLElement { constructor() { super(); this._value = 0; this._max = 100; this._color = '#2563eb'; this._showPercentage = false; } static get observedAttributes() { return ['value', 'max', 'color', 'show-percentage']; } connectedCallback() { this._value = parseInt(this.getAttribute('value') || '0', 10); this._max = parseInt(this.getAttribute('max') || '100', 10); this._color = this.getAttribute('color') || '#2563eb'; this._showPercentage = this.hasAttribute('show-percentage'); this.render(); } attributeChangedCallback(name, oldValue, newValue) { switch (name) { case 'value': { // Braces give the case its own block, so `const` is scoped to it const val = parseInt(newValue || '0', 10); this._value = Math.max(0, Math.min(val, this._max)); break; } case 'max': this._max = parseInt(newValue || '100', 10); break; case 'color': this._color = newValue || '#2563eb'; break; case 'show-percentage': this._showPercentage = newValue !== null; break; } if (this.isConnected) { this.render(); } } get value() { return this._value; } set value(newValue) { const value = Math.max(0, Math.min(parseInt(newValue, 10), this._max)); if (this._value !== value) { this._value = value; this.setAttribute('value', value); this.render(); // Dispatch custom event this.dispatchEvent(new CustomEvent('progress-change', { detail: { value: this._value, percentage: this.percentage }, bubbles: true })); } } get max() { return this._max; } set max(newValue) { const max = Math.max(1, parseInt(newValue, 10)); if (this._max !== max) { this._max = max; this.setAttribute('max', max); this.render(); } } get percentage() { return Math.round((this._value / this._max) * 100); } render() { const percentage = this.percentage; this.innerHTML = ` <div class="progress-container"> <div class="progress-fill" style="width: ${percentage}%; background: ${this._color}"></div> ${this._showPercentage ? `<div class="progress-text">${percentage}%</div>` : ''} </div> `; // Update ARIA attributes for accessibility this.setAttribute('role', 'progressbar'); this.setAttribute('aria-valuenow', this._value); this.setAttribute('aria-valuemin', '0'); this.setAttribute('aria-valuemax', this._max); } } customElements.define('progress-bar', ProgressBar);

HTML Usage

<!-- Basic progress bar with default max of 100 --> <progress-bar value="30" show-percentage></progress-bar> <!-- Custom color --> <progress-bar value="75" color="#10b981" show-percentage></progress-bar> <!-- Custom max value (50 out of 200 = 25%) --> <progress-bar value="50" max="200" color="#f59e0b" show-percentage></progress-bar> <!-- Without percentage text display --> <progress-bar value="60" color="#8b5cf6"></progress-bar> <!-- Listen to progress changes --> <script> const progressBar = document.querySelector('progress-bar'); progressBar.addEventListener('progress-change', (e) => { console.log('Value:', e.detail.value); console.log('Percentage:', e.detail.percentage); }); </script>

Live Demo

Four bars show fixed values across the range and color options, plus an "Animate" button that ramps a fifth bar from 0 to 100 and back, one value setter call at a time.

💡 Value Clamping: The component automatically clamps values to stay within the valid range (0 to max). Setting value = 150 with max = 100 will result in value = 100.

⚠️ Reinvention Take 2

Are we sure we wanted to do this if the progress element already exists!?! We just lost a bunch of free features particularly for accessibility because we didn't leverage HTML!

Demo 3: Tab Container Component

A tab container demonstrating dynamic content switching, event delegation, and parent-child component communication. This is a common demo that shows a nice, but needless component?

Features

  • Multiple tab panels with labeled navigation
  • Active state management
  • Event delegation for tab switching
  • ARIA attributes for accessibility

Implementation

class TabContainer extends HTMLElement { connectedCallback() { this.render(); this.attachEventListeners(); } render() { const panels = Array.from(this.querySelectorAll('tab-panel')); // Build tab navigation const tabs = panels.map((panel, index) => { const label = panel.getAttribute('label') || `Tab ${index + 1}`; const active = panel.hasAttribute('active'); return ` <button class="tab ${active ? 'active' : ''}" data-index="${index}" role="tab" aria-selected="${active}"> ${label} </button> `; }).join(''); // Wrap panels in container const panelsHTML = panels.map((panel, index) => { const active = panel.hasAttribute('active'); return ` <div class="tab-content ${active ? 'active' : ''}" role="tabpanel" data-index="${index}"> ${panel.innerHTML} </div> `; }).join(''); this.innerHTML = ` <div class="tab-navigation" role="tablist">${tabs}</div> <div class="tab-panels">${panelsHTML}</div> `; } attachEventListeners() { const tabNav = this.querySelector('.tab-navigation'); tabNav.addEventListener('click', (e) => { if (e.target.classList.contains('tab')) { this.switchTab(parseInt(e.target.dataset.index)); } }); } switchTab(index) { // Update tab buttons this.querySelectorAll('.tab').forEach((tab, i) => { tab.classList.toggle('active', i === index); tab.setAttribute('aria-selected', i === index); }); // Update tab content this.querySelectorAll('.tab-content').forEach((content, i) => { content.classList.toggle('active', i === index); }); } } class TabPanel extends HTMLElement { // Placeholder for tab panels - content is extracted by TabContainer } customElements.define('tab-container', TabContainer); customElements.define('tab-panel', TabPanel);

HTML Usage

<tab-container> <tab-panel label="Overview" active> <h3>Overview Content</h3> <p>First tab content...</p> </tab-panel> <tab-panel label="Features"> <h3>Features</h3> <ul> <li>Feature 1</li> <li>Feature 2</li> </ul> </tab-panel> <tab-panel label="Code Example"> <h3>Code Example</h3> <pre><code>// Your code here</code></pre> </tab-panel> </tab-container>

Live Demo

⚠️ Reinvention Take 3

Yet again we did something we didn't really need to do. The <details> now supports name attribute to group so you can build an accordion or tab view no problem once you apply CSS. Like previous examples, this is more code, more brittle, and likely loses accessibility or other affordances. With three common examples shown and encouragement by LLMs to make these things maybe you should start with HTML first and then only implement if missing.

Interactive API Demo

Interact with component APIs programmatically using JavaScript. This demonstrates how to control components from external code. The DOM features will already be there, but you might want your own. We will revisit the toggle component even though we don't need to make one to show how you could do that.

Programmatic Control Example

// Get reference to the component const toggle = document.getElementById('api-toggle'); // Toggle the checked state toggle.checked = !toggle.checked; // Toggle the disabled state toggle.disabled = !toggle.disabled; // Get current state console.log('Checked:', toggle.checked); console.log('Disabled:', toggle.disabled); // Listen for changes toggle.addEventListener('change', (e) => { console.log('State changed:', e.detail); });

Live Demo

See it in action in the toggle switch demo above — that frame's own Toggle Checked, Toggle Disabled, and Get State buttons call exactly the setters and getters shown here, so clicking a switch and clicking a button drive the same checked/disabled property pair.

💡 Best Practice: Exposing properties and methods as a public API beyond just DOM methods allows your components to be more obviously controlled programmatically, making them more flexible and easier to integrate with other code.

Component API Design Best Practices

Well-designed components have clear, predictable APIs that are easy to use and understand. You'll likely want to lean into what is assumed understood by the developer and of course have documentation

API Design Principles

  • Maintain Consistency: Obvious property and attribute names mimicking HTML and CSS where appropriate
  • Sensible Defaults: Components work without configuration. If you used in a basic way something should happen.
  • Type Conversion: Handle string attributes gracefully and remember that typing is an issue!
  • Validation: Clamp or validate input values. Again don't trust inputs.
  • Event Communication: Use custom events for state changes

Example: Input Validation

set value(val) { // Convert string to number let numValue = parseFloat(val); // Validate - provide sensible default if (isNaN(numValue)) { numValue = 0; } // Clamp to valid range this._value = Math.min(this.max, Math.max(this.min, numValue)); // Reflect to attribute this.setAttribute('value', this._value.toString()); // Update UI this.render(); }

Next Steps

In Step 4: Shadow DOM considerations, we'll explore:

  • What the shadow boundary buys, and what it costs
  • Open vs. closed shadow roots
  • When to use shadow DOM vs. light DOM
  • Styling strategies across the boundary: custom properties, ::part, and ::slotted