Skip to content

Properties vs Attributes

Attributes live in HTML and are always strings

Section titled “Attributes live in HTML and are always strings”

When you write <my-card count="5" label="hello"> in HTML, the browser stores those as attribute nodes — and every attribute value is a string, no matter what you type. Calling element.getAttribute('count') returns the string "5", not the number 5.

const el = document.querySelector('my-card');
console.log(el.getAttribute('count')); // "5" (string)
console.log(el.getAttribute('label')); // "hello" (string)
console.log(el.getAttribute('missing')); // null (not present)

This is important: getAttribute returns either a string or null — never a boolean, number, array, or object.

Properties are regular JavaScript object properties on the element instance. They can be any JS type: booleans, numbers, arrays, objects, or functions. You access them with dot notation, not getAttribute.

el.count = 5; // number
el.rows = [{ id: 1 }]; // array
el.config = { theme: 'dark', size: 'lg' }; // object
el.disabled = true; // boolean

These property values are not serialised back into the DOM unless your component explicitly does so. A property set on an element instance lives only in JavaScript memory.

Boolean attributes: check presence, not value

Section titled “Boolean attributes: check presence, not value”

HTML boolean attributes work by presence or absence — if the attribute exists on the element, it is true; if it is absent, it is false. The value of the attribute string does not matter.

That means <my-btn disabled=""> and <my-btn disabled="false"> are both disabled because the attribute is present. Only <my-btn> (no attribute at all) means not disabled.

Never compare getAttribute('disabled') === 'true' or === true. Always use hasAttribute or check the return value against null.

// Boolean attribute — check presence, not value
class MyButton extends HTMLElement {
get disabled() { return this.hasAttribute('disabled'); }
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
}

Notice the setter sets the attribute to the empty string '' when truthy — that is the idiomatic HTML pattern. The getter ignores whatever string is stored; it just asks: is the attribute there at all?

Rich data: use a property, not an attribute

Section titled “Rich data: use a property, not an attribute”

When you need to pass an array, an object, or any non-string value into a component, a JS property setter is the right tool. Trying to serialize objects into attribute strings (e.g. data="[object Object]") is fragile and lossy.

// Rich data — use a property, not an attribute
class DataTable extends HTMLElement {
set rows(data) { // data is an array of objects
this._rows = data;
this._render();
}
get rows() { return this._rows || []; }
_render() { /* ... */ }
}

Then from JavaScript:

const table = document.querySelector('data-table');
table.rows = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];

The setter receives the actual array — no parsing required. When the data changes, just assign the property again and _render runs.

The <user-card> element below combines both techniques:

  • A boolean attribute premium — when present it renders a crown
  • A property setter profile — accepts an object { name, role } and re-renders

The HTML sets the premium attribute directly in markup. The JavaScript then assigns el.profile = { name: 'Alice', role: 'Admin' } — a plain object — triggering the property setter and re-rendering the card.

ScenarioUse
Simple string config (label, type, variant)Attribute
Boolean flag (disabled, hidden, selected)Boolean attribute + hasAttribute
Number, object, array, or functionProperty setter
Value set from HTML markup onlyAttribute
Value set from JavaScriptEither — prefer property for non-strings
OptionBenefitCost
AttributeVisible directly in HTML markup and DevTools, usable as a CSS attribute selector, works with server-rendered HTML with no JavaScript neededAlways a string — you can’t pass rich data like an array or object directly
PropertyAccepts any JS type — object, array, or function — and is faster to access via dot notationDoesn’t show up in HTML markup or the DevTools Elements panel, making it harder to debug from markup alone
  • Comparing getAttribute('disabled') === 'true' — a boolean attribute works by presence, not by its string value. Always use hasAttribute instead.
  • Using JSON.stringify() to cram an object into an attribute string, e.g. data='{"a":1}' — an attribute can only hold a string, which is fragile and easy to parse incorrectly. Pass it through a property setter directly instead.
  • Expecting the attribute to update automatically after setting a property — without writing your own reflection pattern (a getter/setter that calls setAttribute), setting a bare property never syncs back to the attribute, leaving the markup and the JS state out of sync.

💡 Real-world examples

The browser’s native <input> element is the clearest example of this distinction — the value attribute is the default value set from HTML, while the .value property is the current value the user is typing right now. The two diverge the moment the user starts typing.

GitHub’s Catalyst framework recommends custom elements always expose a property for rich data, and reserve attributes only for simple string config values. (github/catalyst)

What type does `getAttribute` always return?
How should you check if a boolean attribute like `disabled` is present?
When is using a JS property better than an HTML attribute?
Setting `<my-el count="5">` — what is the type of `this.getAttribute('count')`?