Reflecting Attributes
What is attribute reflection?
Section titled “What is attribute reflection?”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 getter / setter pair
Section titled “The getter / setter pair”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.
Declaring observed attributes
Section titled “Declaring observed attributes”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.
attributeChangedCallback
Section titled “attributeChangedCallback”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.
Full pattern
Section titled “Full pattern”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);Why reflection matters
Section titled “Why reflection matters”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"]orreflect-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.
Live demo
Section titled “Live demo”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.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Reflect the property to an attribute | State is visible in DevTools, usable as a CSS attribute selector, and works with server-rendered HTML with no JavaScript on first load | Risks 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 strings | State doesn’t appear in markup or DevTools, and you can’t select elements by state with a CSS attribute selector |
Common mistakes
Section titled “Common mistakes”- Forgetting to declare the attribute name in
observedAttributes— if it’s not in the list,attributeChangedCallbackis never called, no matter whether the attribute changes from HTML, DevTools, or JavaScript. - Having the setter call
setAttributewithout checking if the value actually changed — this causes an infinite loop between the setter andattributeChangedCallbackcalling the setter again. Always checkif (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 likedisabledandcheckeddirectly to their attribute, soinput:disabledworks as a CSS selector immediately with no extra JavaScript.GitHub’s Catalyst framework provides an
@attrdecorator that generates this exact getter/setter reflection pattern automatically, cutting the boilerplate you’d otherwise repeat in every custom element. (github.github.io/catalyst)