Authors: Lea Verou, TBD
A lot of reusable UI functionality is better expressed as composable traits or behaviors on existing elements, rather than whole new HTML elements (has a rather than is a).
The platform already has this capability, through attributes.
Imagine if global attributes like title, popover, lang, hidden had to be implemented as elements.
Yet, this is the only tool web component authors have today.
Additionally, certain web components have an attribute counterpart to link them to another element (or link to them from another element).
A native example here is <input list> and <datalist>. While <datalist> could be implemented as a web component, there is no authorland counterpart for the list attribute.
Besides the philosophical data modeling argument, overcomponentization introduces tangible problems. Unlike framework components that can compile to much shallower DOM trees, inserting a whole new element in the DOM has a cost. It affects selector matching, DOM traversal, styling, and many other things.
Inserting a custom element is not even allowed in all contexts. Consider this:
<sortable-table>
<table>
<thead><!-- elided --></thead>
<tr>
<td-value value="0.5">
<td>Half</td>
</td-value>
</tr>
</table>
</sortable-table>Even when wrapping an element to add additional functionality is a viable solution, the ergonomics are considerably worse, with a significantly lower signal-to-noise ratio. Compare:
<dt-format type="relatve"><time datetime="2025-11-15"></time></dt-format>with:
<time datetime="2025-11-15" dt-format="relative"></time>Being able to define custom attributes that can be used on any element also addresses several pain points around extending built-ins,
which was one of the most prominent pain points around Web Components per State of HTML 2025.
Authors can simply do <button my-button> rather than having to define their own <my-button> component that emulates or wraps buttons, introducing a ton of complexity.
A concrete list of use cases can be found here.
- Minimal custom attributes extending
Attrby @keithamus - Proposal: Custom attributes for all elements, enhancements for more complex use cases by @leaverou
- Custom Element Features, Built-in Enhancements, Itemscope Managers
- Original custom attributes proposal (naming only) by @leaverou
- Element Behaviors by @lume
- @lume's custom attributes proposal by @lume
- htmx
- VueJS custom directives
- Angular directives
- Svelte attachments
- SolidJS custom directives
- Alpine.js custom directives
Generalizing the PoC as consumers > producers, we end up with this expanded PoC:
- End-users
- HTML authors
- Custom attribute authors
- Implementors
- Spec authors
- Philosophical purity
Important
These boxes are used for conclusions, based on the prose before them.
The prevailing pattern seems to be defining a subclass of Attr.
This has several benefits:
- Existing API to use (e.g.
this.ownerNodeto refer to the host element) - Existing mental model around "upgrading" nodes
- Because attribute nodes are accessible via
element.attributes, this also provides a clash-free way to hang methods and other values. Attris even anEventTargetso in theory attributes could even dispatch events
There are also some downsides:
Attris an old API, and comes with baggage. E.g. now we need to define how to handle namespaces too.
Important
Despite drawbacks, extending Attr seems like a very internally consistent solution and solves many problems.
The proposal that hosted most of the discussion proposed handling attribute-property reflection as well, as this is a big pain point when using WC APIs directly.
However, this opens this up to a lot of API design complexity and increases the API surface, while a custom attributes API can ship without it and still cover use cases.
Important
Let’s defer handling attribute-property reflection and provide sufficient low-level primitives to allow authors to make their own decisions.
Even if authors handle attribute-property reflection themselves, at the very least there needs to be a designated slot to hold the reflected value (which by default would be a string mirroring the attribute value).
Keith proposed simply specifying accessors on attr.value:
class extends Attr {
get value() {
return Number(super.value)
}
set value(value) {
super.value = value;
}
}While elegant, this approach has several downsides:
- Internal consistency: No built-in attributes work that way. In fact,
Attr.prototype.valueis defined to be a string. - In many cases there are very big differences between the JS-facing value and its string representation.
For example, consider the
styleattribute andelement.style, which is a whole object! - Even when conversion is idempotent, we want to avoid any roundtrips that are not absolutely necessary, since these conversions are not always cheap.
- While
Attris not very widely used directly, being a very old API means there can be any number of scripts depending onattr.valuebeing a string.
Important
Let’s use a separate property (e.g. data, parsed, etc) to hold the converted value. Attr would define it as an accessor over this.value, but authors can override it so that it does different things.
This approach allows authors to decide for themselves what the source of truth would be.
If they'd prefer, they can even define data it as a class field, with value being the accessor that proxies it.
Many native features add methods etc to the element.
E.g. the popover attribute also adds showPopover().
However, just like reflection, trying to specify this adds additional complexity, and is not strictly necessary:
with the model of Attr subclasses, authors can always hang methods on their Attr subclass, and they will be accessible via element.attributes.attrName.methodName().
Authors can use additional JS features to improve ergonomics, such as first-class protocols, decorators, or even monkey-patching, at their own risk.
Important
It doesn't look like we need a primitive for this.
If we want to make things easier, we could have a lifecycle hook for registration that lets authors react to the attribute being registered on an element.
class MyAttr extends Attr {
// elided
static definedCallback(name, ElementConstructor) {
console.log(name, ElementConstructor.name);
}
}
HTMLInputElement.customAttributes.define("my-attr", MyAttr);
// prints my-attr HTMLInputElementSome proposals involve a global customAttributes registry, while in others customAttributes is a property of specific element classes, with HTMLElement serving as the global one.
However, many (most?) use cases only involve specific element types and don't make sense in the global scope.
Additionally, the same attribute name may have entirely different meanings depending on the context (e.g. for is often used generically for element linking, and can mean completely different things).
And of course, the larger the scope, the larger the potential for clashes.
Global attributes would need to also work on SVG, MathML etc, which could delay the entire feature if global attributes are the MVP we go with.
Important
Given the number of use cases around specific element types and the complexity of handling SVG at this early stage, it seems prudent to scope to specific element classes.
Additionally, as @annevk points out:
CustomElementRegistrycan be scoped to documents, shadow roots, and elements. Anddocument.customElementRegistryis probably what we want to mimic for anything new. Not sure we should add another global accessor for this.
@sorvell also talked about scoped registries:
Experience with customElements and the scoped registries proposal suggests that scoping is a must and to avoid the pain custom elements has gone through, this feature shouldn't ship without it. While it's clear that
However, given the amount of time it took to ship scoped registries for custom elements, it does not seem prudent for this to be a blocker. Nothing prevents us from shipping scoped custom attributes later.
Important
Let’s defer scoped custom attributes for later.
There are many use cases where the same attribute needs to apply to multiple element types, without it being global.
Examples abound in the platform: href, src, several form control attributes, loading attributes like loading or crossorigin, etc.
Therefore, it should be possible to register the same attribute to multiple classes, since it is not always feasible to use inheritance to register an attribute on multiple elements.
E.g. consider a persist-value attribute that is placed on form controls to persist their values in localStorage whenever they are edited.
We may want to register it on built-ins like HTMLInputElement, HTMLTextAreaElement, HTMLSelectElement by default:
// persist-value.js
export class PersistAttr extends Attr {
// elided
}
// Add to native form elements by default
HTMLInputElement.customAttributes.define("persist-value", PersistAttr);
HTMLTextAreaElement.customAttributes.define("persist-value", PersistAttr);
HTMLSelectElement.customAttributes.define("persist-value", PersistAttr);Then, consumers may want to additionally register it on custom form controls they use:
import { PersistAttr, RangeSlider } from "./attrs/persist-value.js";
RangeSlider.customAttributes.define("persist-value", PersistAttr);Originally, hyphens were suggested, as a way to mirror custom element naming rules. However, there are many exceptions in the web platform making this a bit awkward, e.g.:
- A ton of SVG presentation attributes (e.g.
fill-opacity) aria-*data-*allow-charsethttp-equiv
If we scope down v1 to HTMLElement only, we are left with a fixed, manageable set of names to exclude: Anything starting with aria-, as well as the two existing attributes.
data-* does not need to be excluded, since it's already authorland.
Another issue making this hard is that many custom element attributes use hyphens. On two different social media polls, about half of authors voted that they prioritize readability over platform consistency (which recommends concatcase):
- https://x.com/LeaVerou/status/1863812496106389819
- https://front-end.social/@leaverou/113587139842885936
Another option would be a specific prefix, though given that the attribute name itself often needs to be namespaced with a library prefix, this would only produce acceptable ergonomics if very short, e.g. a single character (#, $, :, @ etc).
The CSS custom ident prefix (--) has also been proposed.
On one hand it is the target of numerous author complaints, on the other it is an existing established convention.
What does connectedCallback etc mean in the context of an attribute?
Do they still correspond to the element being connected, or the attribute being specified on the element? Or when both are true?
@DeepDoge makes a good case for the latter:
IMO custom attributes are composable behavior units, kind of like a superset of extended custom elements. So,
connectedCallback()should run only when both are true:
- The attribute is attached to an element
- That element is connected to the DOM
If we simplify it even more, it should trigger when the attribute is connected to the DOM, not when attribute is connected to an element.
Just like how a custom element's
connectedCallback()gets triggered when the element is actually connected to the DOM, not when it has a parent.The whole point of
connectedCallback()/disconnectedCallback()is to initialize or clean things up depending on whether the thing (element or attribute) is live in the DOM.So should it be called "when the attribute is connected, or when the element is connected?": It should be called when the attribute is connected to the DOM, which also requires element its connect to be connected to the DOM as well. I think we can all agree that "Connected" means "Connected to the DOM".
We could probably define similar semantics for other lifecycle callbacks (disconnectedCallback, adoptedCallback etc), though they may be less straightforward.
It would be good to do a review of use cases to see how common it is to need lifecycle hooks around the element itself, that are separate from those of the attribute node. Assuming these are niche, they can always be addressed via MutationObserver improvements down the line (e.g. observing connectedness is already an open feature request)
Important
Let's define lifecycle callbacks taking into account the element and the attribute as a whole. Review use cases to see if element-specific hooks are needed.
While it may seem at first that simply specifying a setter on attr.value would help us react to attribute changes, that is not what actually happens today (demo).
The getter of attr.value simply returns the value of an internal slot, so setting attribute values does not go through it at all.
While a mutation observer is always an option, reusing attributeChangedCallback() seems like a very fitting solution, and on par with reusing existing lifecycle hooks.
Important
attributeChangedCallback() will fire when the attribute changes.
While not MVP, there are many use cases where a feature utilizes multiple attributes. A common pattern is when one attribute enables the feature, and the rest customize it.
For example, in the web platform there is <template shadowrootmode="open">, but also several shadowroot* attributes that provide parameters (shadowrootdelegatesfocus etc).
Another pattern is where multiple attributes work together to specify a DSL. For example Vue directives (v-if, v-for, v-on etc).
Therefore, a nice-to-have would be to have a way for the attribute to react to attribute changes of other attributes on the element.
And if we're already using attributeChangedCallback() (see above),
we may as well reuse observedAttributes and feed two birds with one scone (note that the attribute itself would always be observed automatically, authors would only need observedAttributes for observing other attributes).
Important
Let's reuse attributeChangedCallback() and observedAttributes() to allow an attribute to observe others.
Putting all of the above together gives us a strawman to facilitate discussion.
Custom attributes are defined as a subclass of Attr and registered for use with one or more element constructors:
class MyTooltip extends Attr {
// elided
}
HTMLElement.customAttributes.define("my-tooltip", MyTooltip);New static member of type CustomAttributeRegistry, with the same methods as CustomElementRegistry. This is a distinct instance per subclass, and an element recognizes attributes registered on any of its superclasses.
Note
We may want to define a CustomRegistry superclass that they both inherit from.
Lifecycle callbacks similar to custom elements are available:
connectedCallback: Executed when the attribute is present on the element andthis.ownerElement.isConnectedis true.disconnectedCallback: Executed when the attribute is no longer connected (see above)connectedMoveCallback(): TBDadoptedCallback(): Fired when the attribute node is moved to another element (e.g. viasetAttributeNode) ORownerElementis moved to another document.attributeChangedCallback(): Executed when the attribute itself or any of the attributes inthis.constructor.observedAttributesare changed, added, removed, or replaced. The attribute itself is always observed whether it’s specified inobservedAttributesor not.
There is also a static lifecycle hook:
definedCallback(name, ElementConstructor): Executed whenever the attribute is defined on an element constructor
Note that browsers currently create a new Attr node every time an attribute is added, even though they reuse an existing node if an existing attribute value is changed.
If reusing the same node is desirable (e.g. due to high setup costs), this could be done with a WeakMap:
/** @type WeakMap<HTMLElement, Attr> */
let nodes = new WeakMap();
class MyAttr extends Attr {
constructor() {
super();
let existing = nodes.get(this.ownerElement);
if (existing) {
return existing;
}
}
}Currently, Attr nodes are already constructed by the time the constructor of a custom element’s subclass runs,
presumably by Element’s constructor (demo).
Should upgrading happen then too? This means any custom attribute code needs to run before the element has a chance to construct itself fully.
We could also define it to run after the constructor it was registered on.
E.g. registering an attribute on HTMLElement would run it after the HTMLElement constructor, whereas registering an attribute on HTMLFormElement would run it after the HTMLFormElement constructor.
No, they have distinct purposes, just like attributes and elements have distinct purposes in the platform. Some things are better suited to attributes, and others to elements.
For example, you wouldn't want to implement a text field by doing <div my-textfield textfield-value="foo" textfield-autofocus></div>.
Ew!
An element is or isn't a text field, it's not something you can just slap on any element.
That said, specializing an element type is a totally valid use case. E.g. <input type="password" pwd-toggle> or even <button my-button>.
For more background/motivation, check out the Introduction.
Not really.
First, currently the only allowable namespace for custom attributes per spec is still data-*.
Coupled together with a library’s own prefix, this makes every attribute comically verbose.
MutationObserver does not work across shadow roots (though there is an open issue for observing open roots).
Even if it were, there is no way to run preparatory code before the element is connected.
MutationObserver is for reacting to future changes.
To react to existing uses of the attribute, we'd also need querySelectorAll() improvements.
But even if all the moving pieces were there, having a primitive for this makes it easier to document, type, explain, and distribute.
A similar argument could have been made for custom elements: All the moving pieces were similarly there, but there was still value in being able to package the functionality up.