HTML elements have two distinct ways to receive data: attributes and properties. Understanding the difference is essential before using @attributes β which bridges the two for custom elements.
Attributes vs properties
Attributes live in the HTML markup. They are always strings (or absent). The browser parses them from the HTML source and exposes them via setAttribute / getAttribute.
Properties are JavaScript object members. They can be any type β numbers, booleans, arrays, objects.
<!-- Attribute β string in the HTML source -->
<inputtype="text"value="hello"disabled />
constinput= document.querySelector("input")!;
// Attribute API β always strings
input.getAttribute("value"); // "hello"
input.setAttribute("value", "bye"); // sets the HTML attribute
// Property API β typed JavaScript values
input.value; // "hello" (synced from attribute initially)
input.value ="bye"; // sets the JS property directly
input.disabled; // true (boolean, not "disabled")
For many built-in elements, attributes and properties start in sync, then diverge:
constinput= document.createElement("input");
input.setAttribute("value", "initial");
input.value; // "initial" β synced on creation
input.value ="typed"; // user types or JS sets property
input.getAttribute("value"); // still "initial" β attribute unchanged
The value attribute is the initial/default value. The value property is the current live value. Changing one does not automatically change the other (for most built-ins).
Hereβs a quick comparison:
Attribute
Property
Location
HTML markup / data-*
JavaScript object
Type
Always string | null
Any β number, boolean, objectβ¦
API
getAttribute / setAttribute
Direct assignment
Observed changes
attributeChangedCallback
Getter/setter
Serialisable to HTML
Yes
Not automatically
Available before JS runs
Yes
No
Custom element attributes
For built-in elements the browser manages the attributeβproperty relationship. For custom elements you manage it yourself via observedAttributes and attributeChangedCallback.
if (name ==="count") this.#count =Number(next ??0);
if (name ==="step") this.#step =Number(next ??1);
this.#render();
}
#count=0;
#step=1;
#render() {
// ...
}
}
This is repetitive. Every attribute needs a manual type conversion, a branch in attributeChangedCallback, and a #render() call. The @attributes decorator automates all of it.
@attributes decorator
@attributes reads a static [ATTRIBUTES] map on the class and wires up observedAttributes and attributeChangedCallback automatically. Each key in the map is an observed attribute name; the value is a handler called with this bound to the element instance.
A decorator that makes a class field reactive by automatically wrapping its value in a signal.
The field behaves like a normal property (get/set) but reactivity is tracked under the hood.
Any reads will subscribe to the signal and any writes will trigger updates.
@example
classCounter {
\@reactive() count:number=0;
}
constcounter=newCounter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes
@remarks β
Equivalent to manually creating a private signal and getter/setter:
A class decorator that automatically wires up observedAttributes and attributeChangedCallback
from a static [ATTRIBUTES] map.
The this type inside attribute handlers is automatically inferred from the decorated class.
@example
\@attributes
classMyElementextendsHTMLElement {
static [ATTRIBUTES] = {
count(this:MyElement, value:string|null) {
this.count =Number(value);
},
};
}
attributes
class
classCounterElement
CounterElementextends
var HTMLElement: {
new ():HTMLElement;
prototype:HTMLElement;
}
The HTMLElement interface represents any HTML element. Some elements directly implement this interface, while others implement it via an interface that inherits it.
A decorator that makes a class field reactive by automatically wrapping its value in a signal.
The field behaves like a normal property (get/set) but reactivity is tracked under the hood.
Any reads will subscribe to the signal and any writes will trigger updates.
@example
classCounter {
\@reactive() count:number=0;
}
constcounter=newCounter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes
@remarks β
Equivalent to manually creating a private signal and getter/setter:
A decorator that makes a class field reactive by automatically wrapping its value in a signal.
The field behaves like a normal property (get/set) but reactivity is tracked under the hood.
Any reads will subscribe to the signal and any writes will trigger updates.
@example
classCounter {
\@reactive() count:number=0;
}
constcounter=newCounter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes
@remarks β
Equivalent to manually creating a private signal and getter/setter:
classCounter {
#count=signal(0);
getcount() { returnthis.#count(); }
setcount(value) { this.#count(value); }
}
reactive()
CounterElement.step: number
step=1;
}
var customElements:CustomElementRegistry
The customElements read-only property of the Window interface returns a reference to the CustomElementRegistry object, which can be used to register new custom elements and get information about previously registered custom elements.
The define() method of the CustomElementRegistry interface adds a definition for a custom element to the custom element registry, mapping its name to the constructor which will be used to create it.