Skip to content

Attributes

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 -->
<input type="text" value="hello" disabled />
const input = 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:

const input = 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:

AttributeProperty
LocationHTML markup / data-*JavaScript object
TypeAlways string | nullAny β€” number, boolean, object…
APIgetAttribute / setAttributeDirect assignment
Observed changesattributeChangedCallbackGetter/setter
Serialisable to HTMLYesNot automatically
Available before JS runsYesNo

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.

class CounterElement extends HTMLElement {
// Must declare which attributes to observe
static observedAttributes = ["count", "step"];
// Called whenever a listed attribute changes
attributeChangedCallback(name: string, _old: string | null, next: string | null) {
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.

import {
function attributes<T extends abstract new (...args: any[]) => HTMLElement>(target: AttributeTarget<T>, context: ClassDecoratorContext<T>): AttributeDecorated<T>

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
class MyElement extends HTMLElement {
static [ATTRIBUTES] = {
count(this: MyElement, value: string | null) {
this.count = Number(value);
},
};
}

attributes
,
const ATTRIBUTES: typeof attr
export ATTRIBUTES

Static-field key used by the @attributes decorator (and

dispatchAttrChange

/

observedAttributes

) to locate the attribute handler map on a custom-element class.

@example

class MyElement extends HTMLElement {
static [ATTRIBUTES]: Attributes<MyElement> = {
name(value) { this.name = value ?? ""; },
};
}

ATTRIBUTES
as
const attr: typeof attr

Static-field key used by the @attributes decorator (and

dispatchAttrChange

/

observedAttributes

) to locate the attribute handler map on a custom-element class.

@example

class MyElement extends HTMLElement {
static [ATTRIBUTES]: Attributes<MyElement> = {
name(value) { this.name = value ?? ""; },
};
}

attr
} from "elements-kit/attributes";
import {
function reactive<This extends object, Value>(source?: (self: This) => Signal<Value>): (_target: unknown, context: ClassFieldDecoratorContext<This, Value>) => (this: This, initialValue: Value) => Value

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

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(value) { this.#count(value); }
}

reactive
} from "elements-kit/signals";
@
function attributes<T extends abstract new (...args: any[]) => HTMLElement>(target: AttributeTarget<T>, context: ClassDecoratorContext<T>): AttributeDecorated<T>

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
class MyElement extends HTMLElement {
static [ATTRIBUTES] = {
count(this: MyElement, value: string | null) {
this.count = Number(value);
},
};
}

attributes
class
class CounterElement
CounterElement
extends
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.

MDN Reference

HTMLElement
{
static [
const attr: typeof attr

Static-field key used by the @attributes decorator (and

dispatchAttrChange

/

observedAttributes

) to locate the attribute handler map on a custom-element class.

@example

class MyElement extends HTMLElement {
static [ATTRIBUTES]: Attributes<MyElement> = {
name(value) { this.name = value ?? ""; },
};
}

attr
] = {
// Called whenever the HTML "count" attribute changes
function count(this: CounterElement, value: string | null): void
count
(
this: CounterElement
this
:
class CounterElement
CounterElement
,
value: string | null
value
: string | null) {
this.
CounterElement.count: number
count
=
var Number: NumberConstructor
(value?: any) => number

An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.

Number
(
value: string | null
value
?? 0); // string β†’ number, write to reactive property
},
function step(this: CounterElement, value: string | null): void
step
(
this: CounterElement
this
:
class CounterElement
CounterElement
,
value: string | null
value
: string | null) {
this.
CounterElement.step: number
step
=
var Number: NumberConstructor
(value?: any) => number

An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.

Number
(
value: string | null
value
?? 1);
},
};
@
reactive<object, unknown>(source?: ((self: object) => Signal<unknown>) | undefined): (_target: unknown, context: ClassFieldDecoratorContext<object, unknown>) => (this: object, initialValue: unknown) => unknown

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

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(value) { this.#count(value); }
}

reactive
()
CounterElement.count: number
count
= 0;
@
reactive<object, unknown>(source?: ((self: object) => Signal<unknown>) | undefined): (_target: unknown, context: ClassFieldDecoratorContext<object, unknown>) => (this: object, initialValue: unknown) => unknown

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

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(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.

MDN Reference

customElements
.
CustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void

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.

MDN Reference

define
("x-counter",
class CounterElement
CounterElement
);

The handler is your type conversion layer β€” value is always string | null (the raw HTML attribute), and you decide what to do with it.

What @attributes generates

// Before @attributes β€” manual boilerplate:
class CounterElement extends HTMLElement {
static observedAttributes = ["count", "step"];
attributeChangedCallback(name, _old, next) {
if (name === "count") /* handler */;
if (name === "step") /* handler */;
}
}
// After @attributes β€” generated automatically:
// static observedAttributes = ["count", "step"] ← from [ATTRIBUTES] keys
// attributeChangedCallback(...) ← dispatches to handlers

Inheriting attributes

@attributes walks the prototype chain β€” subclasses inherit parent attribute handlers automatically:

@attributes
class BaseInput extends HTMLElement {
static [attr] = {
disabled(this: BaseInput, value: string | null) {
this.disabled = value !== null;
},
name(this: BaseInput, value: string | null) {
this.name = value ?? "";
},
};
@reactive() disabled = false;
@reactive() name = "";
}
@attributes
class TextInput extends BaseInput {
static [attr] = {
// Adds "value" on top of inherited "disabled" + "name"
value(this: TextInput, value: string | null) {
this.value = value ?? "";
},
};
@reactive() value = "";
}
// TextInput.observedAttributes = ["disabled", "name", "value"]

See also