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 statedisabled - Boolean property/attribute to disable interactionlabel - String property/attribute for accessibilitychange - 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 valuemax - Maximum value (default: 100)color - String property for bar color customizationshow-percentage - Boolean to display percentage textpercentage - Computed getter for percentage valueprogress-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