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
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();
}
}
}
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();
}
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; }
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
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.
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.
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?
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.
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
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