Skip to content

Reflecting Attributes

A custom element exposes its state in two ways at the same time: as a JavaScript property (e.g. element.label) and as an HTML attribute (e.g. label="Hello"). Reflection means keeping both in sync — writing to one automatically updates the other.

The native <input> element does this for value, disabled, checked, and others. You can give your own elements the same ergonomics.

The reflection pattern is a JavaScript getter and setter pair that delegates to getAttribute and setAttribute:

get label() {
return this.getAttribute('label') || '';
}
set label(v) {
this.setAttribute('label', v);
}

When external code reads element.label, the getter returns the current attribute value. When code assigns element.label = 'X', the setter calls setAttribute, so the HTML attribute is updated immediately. getAttribute('label') and element.label will always agree.

For attributeChangedCallback to fire — whether the attribute was changed from HTML, DevTools, or JavaScript — the attribute name must be listed in the static observedAttributes getter:

static get observedAttributes() {
return ['label', 'color'];
}

The browser reads this list once when the class is registered. Attributes not in the list are silently ignored.

When a watched attribute is added, changed, or removed the browser calls:

attributeChangedCallback(name, oldValue, newValue) {
// name — which attribute changed, e.g. 'label'
// oldValue — previous value string, or null on first set
// newValue — new value string, or null when removed
this._render();
}

Because the setter calls setAttribute, setting element.label = 'X' triggers attributeChangedCallback just as if you had written element.setAttribute('label', 'X') directly. The callback is your single source of truth for re-rendering.

class MyBadge extends HTMLElement {
static get observedAttributes() { return ['label', 'color']; }
get label() { return this.getAttribute('label') || ''; }
set label(v) { this.setAttribute('label', v); }
get color() { return this.getAttribute('color') || 'blue'; }
set color(v) { this.setAttribute('color', v); }
attributeChangedCallback(name, oldValue, newValue) {
this._render();
}
connectedCallback() { this._render(); }
_render() {
this.textContent = this.label;
this.style.color = this.color;
}
}
customElements.define('my-badge', MyBadge);

Reflecting attributes gives your element several practical advantages:

  • DevTools visibility — attribute values appear directly in the Elements panel without expanding any shadow DOM.
  • CSS attribute selectors — you can style reflect-badge[color="#7c3aed"] or reflect-badge[label] without any extra classes.
  • Server-side HTML — the initial state can be baked into the markup; no JavaScript is needed to set the first value.
  • Framework interoperability — frameworks like Vue, Svelte, and Angular bind to attributes by default. Reflected properties mean <my-badge label={x}> just works.

After 1.2 seconds the element’s label property is set from JavaScript. The #log line confirms that element.label and getAttribute('label') return the same string — proof that the setter and the attribute are perfectly in sync.

OptionBenefitCost
Reflect the property to an attributeState is visible in DevTools, usable as a CSS attribute selector, and works with server-rendered HTML with no JavaScript on first loadRisks an infinite loop if the setter and attributeChangedCallback call each other, and the attribute can only ever hold a string
Don’t reflect (keep the value in a private field)Avoids the infinite-loop risk entirely and supports any data type, not just stringsState doesn’t appear in markup or DevTools, and you can’t select elements by state with a CSS attribute selector
  • Forgetting to declare the attribute name in observedAttributes — if it’s not in the list, attributeChangedCallback is never called, no matter whether the attribute changes from HTML, DevTools, or JavaScript.
  • Having the setter call setAttribute without checking if the value actually changed — this causes an infinite loop between the setter and attributeChangedCallback calling the setter again. Always check if (this.getAttribute(name) !== v) before writing.
  • Reflecting rich data, like an array or object, back to an attribute — an attribute is a string only; trying to reflect an object produces the useless [object Object]. Only reflect plain strings, booleans, or numbers.

💡 Real-world examples

The browser’s native <input> element reflects properties like disabled and checked directly to their attribute, so input:disabled works as a CSS selector immediately with no extra JavaScript.

GitHub’s Catalyst framework provides an @attr decorator that generates this exact getter/setter reflection pattern automatically, cutting the boilerplate you’d otherwise repeat in every custom element. (github.github.io/catalyst)

What does the `set label(v)` setter do in the reflection pattern?
What is required for `attributeChangedCallback` to fire?
Which CSS selector can you use when an attribute is reflected?