Attributes
Attributes vs properties β and how @attributes wires them to reactive state.
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.
import { reactive, computed } from "elements-kit/signals";
import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes";
import { defineElement } from "elements-kit/custom-elements";
import { render } from "elements-kit/render";
@attributes
export class RangeDisplay extends HTMLElement {
static [attr] = {
// Each handler converts the raw string attribute to a typed reactive property
min(this: RangeDisplay, v: string | null) {
this.min = Number(v ?? 0);
},
max(this: RangeDisplay, v: string | null) {
this.max = Number(v ?? 100);
},
value(this: RangeDisplay, v: string | null) {
this.value = Number(v ?? 50);
},
label(this: RangeDisplay, v: string | null) {
this.label = v ?? "";
},
};
@reactive() min = 0;
@reactive() max = 100;
@reactive() value = 50;
@reactive() label = "";
// Derived from reactive properties
percent = computed(() =>
Math.round(((this.value - this.min) / (this.max - this.min)) * 100),
);
#unmount?: () => void;
#template = () => (
<div>
<label style="display: block; margin-bottom: 0.5rem">
{() => this.label || "Value"}: <strong>{() => this.value}</strong> (
{() => this.percent}%)
</label>
<input
type="range"
min={computed(() => String(this.min))}
max={computed(() => String(this.max))}
value={computed(() => String(this.value))}
on:input={(e: Event) =>
(this.value = Number((e.target as HTMLInputElement).value))
}
style="width: 100%"
/>
<div style="display: flex; justify-content: space-between; font-size: 0.8em; color: #888">
<span>{() => this.min}</span>
<span>{() => this.max}</span>
</div>
</div>
);
connectedCallback() {
this.style.display = "block";
this.style.fontFamily = "sans-serif";
this.style.padding = "1rem";
this.#unmount = render(this, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}
defineElement("x-range", RangeDisplay);
declare global {
namespace ElementsKit {
interface CustomElementRegistry {
"x-range": typeof RangeDisplay;
}
}
}
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 unchangedThe 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.
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 { , as } from "elements-kit/attributes";
import { } from "elements-kit/signals";
@
class extends {
static [] = {
// Called whenever the HTML "count" attribute changes
(: , : string | null) {
this. = ( ?? 0); // string β number, write to reactive property
},
(: , : string | null) {
this. = ( ?? 1);
},
};
@() = 0;
@() = 1;
}
.("x-counter", );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 handlersInheriting 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"]