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.

range-display.tsxmain.tsx
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 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 { ,  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 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