Custom element authors frequently need their elements to leverage platform behaviors that are currently exclusive to native HTML elements, such as form submission, popover invocation, label behaviors, form semantics, and radio button grouping. This proposal introduces platform-provided behaviors as a mechanism for autonomous custom elements to adopt specific native HTML element behaviors. Rather than requiring developers to reimplement native behaviors in JavaScript or extend native elements (customized built-ins), this approach exposes native capabilities as composable behaviors.
Custom element authors can't access native behaviors that are built into native HTML elements. This forces them to either:
- Use customized built-ins (
is/extendssyntax), which have Shadow DOM limitations and can't use the ElementInternals API. - Try to reimplement native logic in JavaScript, which is error-prone, often less performant, and cannot replicate certain platform-internal behaviors.
- Accept that their custom elements simply can't do what native elements can do.
This creates a gap between what's possible with native elements and custom elements, limiting web components and forcing developers into suboptimal patterns.
- Establish an extensible framework for custom elements to adopt native behaviors for built in elements.
- Enable autonomous custom elements to trigger form submission like
<button type="submit">as the initial capability of this framework.
- Recreating all native element behaviors in this initial proposal.
- Making updates to customized built-ins.
This proposal is informed by:
-
Issue discussions spanning multiple years:
- WICG/webcomponents#814 - Form submission
- whatwg/html#11061 - ElementInternals.type proposal
- whatwg/html#9110 - Popover invocation from custom elements (via the popover API or the invoker commands API)
- whatwg/html#5423 and whatwg/html#11584 - Label behaviors
- whatwg/html#10220 - Custom elements as forms
-
TPAC discussions in 2023 and 2025 exploring alternatives to customized built-ins.
-
Real-world use cases from frameworks that work around these limitations:
- Shoelace: Uses
ElementInternalsbut still requires manual wiring to intercept the click event on its internal shadow button (as shown below) and can't support implicit submission ("Enter to submit").
// button.component.ts handleClick(event) { if (this.type === 'submit') { this._internals.form.requestSubmit(this); } }
-
Material Web: Renders a
<button>inside the Shadow DOM for accessibility/clicks. They created a dedicated class to handle form submission and intercept the click event to callform.requestSubmit(this). -
Older method (used by earlier design systems): To enable implicit submission, the component injects a hidden
<button type="submit">into its own light DOM. This approach breaks encapsulation, risks unintended layout effects by participating in the parent’s flow or the surrounding container, and can pollute the accessibility tree.
<ds-button> #shadow-root <button>Click Me</button> <button type="submit" style="display: none;"></button> </ds-button>
- Shoelace: Uses
- There's a clear gap with implicit form submission.
- Form submission has clear semantics, making it useful for validating the overall pattern (lifecycle, conflict resolution, accessibility integration) before expanding to more complex behaviors.
- There's no API to make a custom element participate in implicit form submission as
form.requestSubmit()only handles explicit activation. - The value also lies in establishing a composable pattern for exposing platform behaviors that can extend to inputs, labels, popovers, and more.
This proposal introduces a behaviors option to attachInternals(). Behaviors are instantiated with new and attached via the options object. Once attached, behaviors can't be added, removed, or replaced but the behavior instances themselves remain mutable.
// Instantiate the behavior and attach it.
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({ behaviors: [this._submitBehavior] });
// Access behavior state directly via the stored reference.
this._submitBehavior.formAction = '/custom';
// Or find a behavior in the array.
const submitBehavior = this._internals.behaviors.find(
b => b instanceof HTMLSubmitButtonBehavior
);
submitBehavior?.disabled = true;Platform behaviors give custom elements capabilities that would otherwise require reimplementation or workarounds. Each behavior automatically provides:
- Event handling: Platform events (click, keydown, etc.) are wired up automatically using the standard DOM event infrastructure (respecting
stopPropagation,preventDefault, etc.) - ARIA defaults: Implicit roles and properties for accessibility.
- Focusability: The element participates in the tab order as appropriate for the behavior.
- CSS pseudo-classes: Behavior-specific pseudo-classes are managed by the platform.
By bundling these capabilities as high-level units, the platform can ensure accessible defaults, correct event wiring, and proper pseudo-class management.
This proposal introduces HTMLSubmitButtonBehavior, which mirrors the submission capability of <button type="submit">:
| Capability | Details |
|---|---|
| Activation | Click and keyboard (Space/Enter) trigger form submission. |
| Implicit submission | The element participates in "Enter to submit" within forms. |
| ARIA | Implicit role="button". |
| Focusability | Participates in tab order; removed when disabled is true. |
| CSS pseudo-classes | :default, :disabled/:enabled, :focus, :focus-visible. |
Note: While HTMLButtonElement also supports generic button behavior (type="button") and reset behavior (type="reset"), this proposal focuses exclusively on introducing the submit behavior.
HTMLSubmitButtonBehavior doesn't require the custom element to be form-associated (static formAssociated = true), but form association is needed for submission to work. Without it, behavior.form is always null and activation is a no-op even if the element is inside a form. This is a divergence from native <button>, which submits its form without any explicit opt-in.
| Scenario | Behavior |
|---|---|
| Form-associated element inside a form | Full functionality: activation triggers submission, participates in implicit submission, matches :default. |
| Form-associated element outside a form | behavior.form returns null, activation is a no-op (like a native button outside a form). |
| Non-form-associated element | behavior.form is null, activation is a no-op even inside a form. Implicit submission and :default don't apply. The element still gets role="button" and implicit focusability. |
Each behavior exposes properties and methods from its corresponding native element. Behaviors can be accessed directly via the stored reference. For HTMLSubmitButtonBehavior, the following properties are available (mirroring HTMLButtonElement):
Properties:
disabled- The element is effectively disabled if eitherbehavior.disabledistrueor the element is disabled via attribute or is a descendant of<fieldset disabled>(spec).form- read-only, delegates toElementInternals.form.formActionformEnctypeformMethodformNoValidateformTargetlabels- read-only, delegates toElementInternals.labelsnamevalue
class CustomSubmitButton extends HTMLElement {
constructor() {
super();
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({ behaviors: [this._submitBehavior] });
}
get disabled() {
return this._submitBehavior.disabled;
}
set disabled(val) {
this._submitBehavior.disabled = val;
}
get formAction() {
return this._submitBehavior.formAction;
}
set formAction(val) {
this._submitBehavior.formAction = val;
}
}To expose properties like disabled or formAction to external code, authors define getters and setters that delegate to the behavior. This gives authors full control over their element's public API.
Authors are also responsible for attribute reflection. If the author wants HTML attributes on their custom element (e.g., <my-button formaction="/save">) to affect the behavior, they need to observe and forward those attributes using attributeChangedCallback:
class CustomButton extends HTMLElement {
static observedAttributes = ['disabled', 'formaction'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'disabled') {
this._submitBehavior.disabled = newValue !== null;
} else if (name === 'formaction') {
this._submitBehavior.formAction = newValue ?? '';
}
}
}Note: Automatic exposure would require adding behavior properties (e.g., formAction, name, value) to HTMLElement. This would bloat every HTMLElement instance's prototype with properties that only make sense for elements with specific behaviors. An opt-in alternative was considered but adds API complexity without a clear benefit.
When attachInternals() is called with behaviors, each behavior is attached to the element:
| Event | Effect |
|---|---|
attachInternals() called with behaviors |
Each behavior is attached. Event handlers become active. Default ARIA role is applied unless overridden by ElementInternals.role. |
| Element disconnected from DOM | Behavior state is preserved. Event handlers remain conceptually attached but inactive. |
| Element reconnected to DOM | Event handlers become active again. Behavior state (e.g., formAction, disabled) is preserved. |
Note: Behaviors are immutable after attachInternals(). Dynamic behavior updates (adding, removing, or replacing behaviors after attachment) are not supported, as developer feedback indicated that the problems with <input>'s mutable type attribute (state migration, event handler cleanup, property compatibility) should not be replicated.
Including the same behavior instance twice in the behaviors array, or attaching multiple instances of the same behavior type to a single element, throws a TypeError.
Throws TypeError due to duplicate behavior instance in the array:
const sharedBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({
behaviors: [sharedBehavior, sharedBehavior] // Throws `TypeError`.
});Throws if multiple instances of the same behavior type are attached to one element, even if they are separate objects:
const behavior1 = new HTMLSubmitButtonBehavior();
const behavior2 = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({
behaviors: [behavior1, behavior2] // Throws `TypeError`.
});This restriction exists because having two instances of the same behavior type on one element creates ambiguity.
Additionally, a behavior instance can only be attached to one element. Attempting to attach an already-attached instance to another element throws a TypeError:
const sharedBehavior = new HTMLSubmitButtonBehavior();
element1._internals = element1.attachInternals({ behaviors: [sharedBehavior] });
element2._internals = element2.attachInternals({ behaviors: [sharedBehavior] }); // Throws `TypeError`.This ensures that element-specific properties like behavior.form and behavior.labels have unambiguous meaning, and avoids potential confusion where changing a property on one element unexpectedly affects another.
Behaviors are instantiated with new and passed to attachInternals():
behaviorsoption inattachInternals({ behaviors: [...] })accepts behavior instances.behaviorsproperty onElementInternalsis a read-onlyFrozenArray.- Developers hold direct references to their behavior instances.
Note: An ordered array is preferred over a set because order may be significant for conflict resolution. behaviors uses a FrozenArray because behaviors are immutable after attachment.
With the current approach, developers hold direct references to their behavior instances: no array lookup, instanceof checks, or behaviors interface is needed to access behavior state. It also aligns with the W3C design principle that classes should have constructors that allow authors to create and configure instances, and it extends naturally to future developer-defined behaviors that follow the same new + attach pattern.
For future developer-defined behaviors:
class TooltipBehavior {
#content = '';
behaviorAttachedCallback(internals) { /* ... */ }
get content() { return this.#content; }
set content(val) { this.#content = val; }
}
// In custom element constructor:
this._tooltipBehavior = new TooltipBehavior();
this._internals = this.attachInternals({ behaviors: [this._tooltipBehavior] });
// Access state directly.
this._tooltipBehavior.content = 'Helpful tooltip text';When multiple behaviors are attached to an element, they may provide overlapping capabilities. This section discusses strategies for resolving such conflicts.
For the built-in behaviors currently under consideration and mentioned in this document (HTMLSubmitButtonBehavior, HTMLButtonBehavior, HTMLResetButtonBehavior, etc.), no two are expected to be compatible. However, this framework allows for composability if use cases emerge, at which point a conflict resolution strategy would need to be followed.
The conflict resolution strategy should:
- Allow the platform to add new low-level behaviors to existing bundled behaviors without creating compatibility issues.
- Enable authors to reason about which behavior "wins" for any given capability.
- Give authors control over the outcome when behaviors conflict in meaningful ways.
Behaviors can conflict in several ways, such as:
| Conflict Type | Example |
|---|---|
| ARIA role | Behaviors provide a default role |
| Event handling | Behaviors handle click in different ways |
| CSS pseudo-class | Behaviors contribute to :disabled |
| Mutually exclusive | Checkbox behavior combined with radio behavior |
The order of behaviors in the array determines precedence. The last behavior in the array "wins" for any capability that can only have one value:
// Last behavior's role wins.
this._labelBehavior = new HTMLLabelBehavior();
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({
behaviors: [this._labelBehavior, this._submitBehavior]
});
// The element's implicit role is "button" (from HTMLSubmitButtonBehavior, last in list).
console.log(this.computedRole); // "button"
// If the author sets `internals.role`, that takes precedence over all behavior defaults.
this._internals.role = 'link';
console.log(this.computedRole); // "link"There are two options for how strictly to apply last-in-wins:
Last-in-wins applies uniformly to everything: properties, methods, and event handlers. Only the last behavior's handler for a given event runs.
class LabeledSubmitButton extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._labelBehavior = new HTMLLabelBehavior();
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({
behaviors: [this._labelBehavior, this._submitBehavior]
});
}
}When clicked:
- HTMLLabelBehavior's click handler is skipped.
- HTMLSubmitButtonBehavior's click handler runs → form submits. Result: Form submits and no focus delegation occurs.
If the author wants both behaviors' handlers to run, they must manually invoke the earlier behavior's logic:
this.addEventListener('click', () => {
// Manually trigger label behavior's focus delegation.
const labelTarget = this._labelBehavior?.control;
if (labelTarget) {
labelTarget.focus();
}
});Pros:
- Consistent mental model: authors always know last-in-wins applies.
- No unexpected double-actions (e.g., submit form and delegate focus).
Cons:
- Authors who want both handlers must manually wire up the earlier behavior's logic.
- May not match author expectations if they assume events "stack" like regular DOM event listeners.
Properties are inherently exclusive (an element can only have one role, one disabled state, one formAction value), but events are additive in the DOM (multiple listeners can respond to the same event). Behaviors following this pattern align with how authors already think about event handling.
Event handlers run in reverse array order (last-to-first), so the last behavior in the array has priority for both properties and events. For events, "priority" means running first. For example:
class LabeledSubmitButton extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._labelBehavior = new HTMLLabelBehavior();
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({
behaviors: [this._labelBehavior, this._submitBehavior]
});
}
}When clicked:
- HTMLSubmitButtonBehavior's click handler runs first → form submits.
- HTMLLabelBehavior's click handler runs second → delegates focus to associated control. Result: Form submits and delegates focus.
Pros:
- Matches DOM event semantics (multiple listeners can coexist).
- Enables composition where behaviors handle different aspects of the same event.
Cons:
- Risk of unexpected double-actions if authors don't realize both handlers run.
Shared considerations for both options:
Pros:
- Simple and predictable once understood.
- Enables forward compatibility: if a future version of
HTMLSubmitButtonBehaviorinternally composes a new low-level behavior, the existing resolution rules still apply.
Cons:
- May hide subtle issues. For example:
- If two behaviors both provide
disabled, setting it on one doesn't sync to the other. Authors might not realize which behavior'sdisabledis "winning." - An unexpected ARIA role could harm accessibility if the author doesn't notice the last behavior overrode the intended role.
- If two behaviors both provide
- Authors may accidentally combine behaviors that don't make sense together.
Compatibility between behaviors are defined in the specification. This follows the pattern used by attachShadow, where the list of valid shadow host names is spec-defined and enforced at runtime. Web authors can reference documentation or DevTools errors to determine which combinations are valid.
Any combination not explicitly allowed would be rejected by attachInternals(), preventing invalid states (like being both a button and a form):
// This would work if anchor were made compatible with button (nav-button pattern).
this._buttonBehavior = new HTMLButtonBehavior();
this._anchorBehavior = new HTMLAnchorBehavior();
this.attachInternals({
behaviors: [this._buttonBehavior, this._anchorBehavior]
});
// Throws: checkbox is not compatible with submit button.
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._checkboxBehavior = new HTMLCheckboxBehavior();
this.attachInternals({
behaviors: [this._submitBehavior, this._checkboxBehavior]
});
// Error message: "HTMLSubmitButtonBehavior is not compatible with HTMLCheckboxBehavior".There can be two interpretations of what "compatible behaviors" means:
- Compatible behaviors have completely disjoint capabilities (e.g., one provides
disabled, the other provideshref). No conflict resolution is needed because they never touch the same property or event. - Compatible behaviors may share some capabilities (e.g., both provide a role or handle click). In this case, a conflict resolution strategy (Alternative 1 or 3) is still required for overlapping capabilities.
Pros:
- Prevents nonsensical combinations at attachment time.
- Clear error messages guide developers to do the right thing.
- The platform can expand compatibility lists in future versions without breaking existing code.
Cons:
- More restrictive as authors can't experiment with novel combinations.
- Requires the platform to update compatibility lists.
- May block legitimate use cases that weren't anticipated.
- Must still be combined with Alternative 1 or 3 when compatible behaviors have overlapping capabilities.
If conflicts occur, the platform requires the author to explicitly resolve them. This applies to properties, methods, and event handlers:
class LabeledSubmitButton extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._labelBehavior = new HTMLLabelBehavior();
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({
behaviors: [this._labelBehavior, this._submitBehavior],
resolve: {
role: 'button', // Use button role.
click: 'all', // Both handlers run. Also could be 'first', 'last'.
disabled: 'HTMLSubmitButtonBehavior' // Use submit button's disabled.
}
});
}
}Pros:
- Authors have full control over conflict resolution, no hidden behavior.
- Supports complex use cases where default resolution isn't appropriate.
- Authors can mix strategies (e.g., last-in-wins for role, additive for events).
Cons:
- More verbose API.
- Adds complexity for simple cases where order-based resolution would suffice.
- Authors must understand all potential conflicts to resolve them correctly.
Web authors can detect whether behaviors are supported by checking for the existence of behavior classes on the global scope:
if (typeof HTMLSubmitButtonBehavior !== 'undefined') {
// Behaviors are supported.
this._submitBehavior = new HTMLSubmitButtonBehavior();
this._internals = this.attachInternals({ behaviors: [this._submitBehavior] });
} else {
// Fall back to manual event handling.
this._internals = this.attachInternals();
this.addEventListener('click', () => {
this._internals.form?.requestSubmit(this);
});
}This proposal supports common web component patterns:
-
A child class extends the parent's functionality and retains access to the
ElementInternalsobject and its active behaviors. -
HTMLSubmitButtonBehaviorand subsequent platform-provided behaviors should be understood as bundles of state, event handlers, and accessibility defaults and not opaque tokens. Web authors can reason about what a behavior provides (e.g., click/Enter triggers form submission, implicitrole="button", focusability,:disabledpseudo-class) and anticipate how it composes with other behaviors. This framework would also enable polyfilling: because behaviors have well-defined capabilities, authors can approximate new behaviors in userland before native support ships (see Developer-defined behaviors in Future Work). -
This proposal targets autonomous custom elements that need platform behaviors (e.g., when needing Shadow DOM and custom APIs or building a design system component that is an autonomous custom element). Making native elements more flexible (Customizable Select, open-stylable controls) is valuable and complementary, but doesn't completely eliminate the need for autonomous custom elements.
-
Platform-provided behaviors are JavaScript-dependent, as is any autonomous custom element. If script fails to load, the element receives no behavior—this is true with or without this proposal.
-
Custom elements using behaviors can still follow progressive enhancement patterns: use
<slot>to render fallback content, provide<noscript>alternatives, and design markup to be readable without JavaScript. -
While this proposal uses an imperative API, the design supports future declarative custom elements. Once a declarative syntax for
ElementInternalsis established, attaching behaviors could be modeled as an attribute, decoupling behavior from the JavaScript class definition. The following snippet shows a hypothetical example:<custom-button name="custom-submit-button"> <element-internals behaviors="html-submit-button-behavior"></element-internals> <template>Submit</template> </custom-button>
While this proposal only introduces HTMLSubmitButtonBehavior, the example below references HTMLResetButtonBehavior and HTMLButtonBehavior to illustrate how switching would work once additional behaviors become available in the future.
A design system can use delayed attachInternals() to determine the behavior based on the initial type attribute. This approach uses a single class while keeping behaviors immutable after attachment.
class DesignSystemButton extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['type', 'disabled', 'formaction'];
// Behavior reference (set once in connectedCallback).
#behavior = null;
#internals = null;
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
// Attach behaviors once based on initial type attribute.
if (!this.#internals) {
const type = this.getAttribute('type') || 'button';
this.#behavior = this.#createBehaviorForType(type);
this.#internals = this.attachInternals({ behaviors: [this.#behavior] });
}
this.#render();
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this.#behavior) {
return;
}
switch (name) {
case 'type': {
// Type changes after connection are intentionally ignored.
console.warn('ds-button: type attribute changes after connection have no effect.');
break;
}
case 'disabled': {
this.#behavior.disabled = newValue !== null;
break;
}
case 'formaction': {
if ('formAction' in this.#behavior) {
this.#behavior.formAction = newValue ?? '';
}
break;
}
}
}
#createBehaviorForType(type) {
switch (type) {
case 'submit': {
return new HTMLSubmitButtonBehavior();
}
case 'reset': {
return new HTMLResetButtonBehavior();
}
default: {
return new HTMLButtonBehavior();
}
}
}
// Expose behavior properties.
get disabled() {
return this.#behavior?.disabled ?? false;
}
set disabled(val) {
this.toggleAttribute('disabled', val);
}
get formAction() {
return this.#behavior?.formAction ?? '';
}
set formAction(val) {
if (this.#behavior && 'formAction' in this.#behavior) {
this.#behavior.formAction = val;
}
}
// Additional getters/setters for formMethod, formEnctype,
// formNoValidate, formTarget, name, and value would follow
// the same pattern.
#render() {
const isSubmit = this.#behavior instanceof HTMLSubmitButtonBehavior;
const isReset = this.#behavior instanceof HTMLResetButtonBehavior;
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-block; padding: 8px 16px; cursor: pointer; }
:host(:disabled) { opacity: 0.5; cursor: not-allowed; }
</style>
${isSubmit ? '💾' : isReset ? '🔄' : ''} <slot></slot>
`;
}
}
customElements.define('ds-button', DesignSystemButton);<form action="/save" method="post">
<input name="username" required>
<!-- Submit button with custom form action. -->
<ds-button type="submit" formaction="/draft">Save Draft</ds-button>
<!-- Default submit button (matches :default). -->
<ds-button type="submit">Save</ds-button>
<!-- Reset button. -->
<ds-button type="reset">Reset</ds-button>
<!-- Regular button (default when no type specified). -->
<ds-button>Cancel</ds-button>
</form>The element gains:
- Click and keyboard activation (Space/Enter).
- Focusability (participates in tab order; removed when disabled).
- Implicit ARIA
role="button"that can be overridden by the web author. - Behavior-specific capabilities (form submission, reset, etc.) based on initial
type. - CSS pseudo-class matching:
:default,:disabled/:enabled. - Participation in implicit form submission (for submit buttons).
- Behavior properties like
disabledandformActionare accessible via the stored behavior reference.
Consider behaviors: [submitBehavior, resetBehavior, buttonBehavior] with the intent to toggle behaviors:
- Under last-in-wins, only
buttonBehavior's property values (the last in the array) determine the element's effective state. Fordisabled, this means settingsubmitBehavior.disabledorresetBehavior.disabledhas no effect as onlybuttonBehavior.disabledcontrols whether the element matches:disabled, is removed from the tab order, and stops receiving activation events (no click or keyboard handler from any behavior runs while disabled). - All three behaviors register click handlers with different effects. Under strict last-in-wins (Option A), only
buttonBehavior's handler runs. Under additive events (Option B), all handlers run (the element would submit and reset the form on every click).
Because disabled cannot selectively silence individual behaviors and conflict resolution applies uniformly to all attached behaviors, loading all three simultaneously and switching between them is not viable.
Call-to-action elements like "Sign Up" or "Download Now" often need to look like buttons but navigate to new pages. Web authors may:
- Use
<a>styled as a button, but lose button keyboard semantics: Space doesn't activate, only Enter does.
<!-- <a> styled as a button. -->
<a href="/signup" class="button-styles">Sign Up</a>
<script>
const link = document.querySelector('a.button-styles');
// Add Space key activation (buttons activate on Space, links don't).
link.addEventListener('keydown', (e) => {
if (e.key === ' ') {
e.preventDefault();
link.click();
}
});
</script>- Use
<button>with JavaScript navigation but lose native anchor features: no right-click "Open in new tab",downloadattribute,target="_blank"for external links nor native prefetching.
<!-- <button> with JavaScript navigation. -->
<button class="button-styles">Sign Up</button>
<script>
const btn = document.querySelector('button.button-styles');
const href = '/signup';
btn.addEventListener('click', () => {
window.location.href = href;
});
// May implement prefetch on hover.
btn.addEventListener('mouseenter', () => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = href;
document.head.appendChild(link);
});
// To implement download behavior author would have to create a temporary <a> element.
</script>- Some frameworks use polymorphic component patterns, where a component can change the underlying HTML element it renders via a prop, to render a button as a link or vice versa (e.g., MUI's
componentprop, styled-components'asprop, Chakra UI'sasprop). However, the underlying element can only be one or the other.
// MUI: Button rendered as an anchor
<Button component="a" href="/signup">Sign Up</Button>
// styled-components: Button rendered as an anchor
<StyledButton as="a" href="/signup">Sign Up</StyledButton>
// Chakra UI: Button rendered as an anchor
<Button as="a" href="/signup">Sign Up</Button>In all cases, the rendered element is an <a>, so it loses button keyboard semantics (Space doesn't activate). Alternatively, rendering a link as a button loses anchor features (no right-click "Open in new tab", no download attribute).
Combining HTMLButtonBehavior (from <button type=button>) with HTMLAnchorBehavior (from <a>) could solve this by giving the element:
- Button keyboard activation (Space and Enter both work).
- Right-click context menu offers navigation-related options.
- Native anchor navigation with all its features (
href,target,download, browser prefetching). - A single component that design systems can style once.
class NavButton extends HTMLElement {
constructor() {
super();
this._buttonBehavior = new HTMLButtonBehavior();
this._anchorBehavior = new HTMLAnchorBehavior();
this._internals = this.attachInternals({
behaviors: [this._buttonBehavior, this._anchorBehavior]
});
// Explicitly set the role.
this._internals.role = 'link';
}
connectedCallback() {
// Set navigation target.
this._anchorBehavior.href = this.getAttribute('href');
}
}
customElements.define('nav-button', NavButton);<!-- A button-styled element that navigates like a link. -->
<nav-button href="/dashboard">Sign Up</nav-button>These behaviors are technically compatible because:
- Button provides keyboard activation (Space/Enter) and anchor provides navigation on the same
clickevent. - They have complementary properties: button has
disabled, anchor hashref,target,download. - Both are focusable elements.
Role conflict and accessibility implications: HTMLButtonBehavior provides role="button" while HTMLAnchorBehavior provides role="link". Under the last-in-wins rule, the element would have role="link" (or role="button" if the order is reversed). However, authors should:
- Explicitly set
internals.roleto the appropriate value based on the element's primary purpose. - Consider user expectations of screen reader users. They might expect buttons to perform actions and links to navigate.
- Test with assistive technologies to ensure the element behaves as users expect based on its announced role.
Even with an explicit role, this pattern may confuse users who expect consistent behavior from elements announced as buttons or links. Authors should evaluate whether their use case requires both behaviors or if a single semantic (button or link) would better serve users.
Some behaviors are inherently mutually exclusive.
this._checkboxBehavior = new HTMLCheckboxBehavior();
this._radioBehavior = new HTMLRadioGroupBehavior();
this.attachInternals({
behaviors: [this._checkboxBehavior, this._radioBehavior]
});| Capability | HTMLCheckboxBehavior | HTMLRadioGroupBehavior |
|---|---|---|
checked property |
Toggles independently on/off | Checking one unchecks others from the group |
| Click handling | Toggles checked state |
Sets checked = true (radios don't toggle off) |
| ARIA role | role="checkbox" |
role="radio" |
aria-checked |
"true" / "false" / "mixed" |
"true" / "false" |
The result is incoherent: the element has radio semantics for the checked property (group coordination) but the checkbox's click handler might still try to toggle off, or vice versa depending on event handler ordering (if applying "last-in-wins"). An element cannot meaningfully be both a checkbox and a radio button.
The behavior pattern can be extended to additional behaviors:
- Generic Buttons:
HTMLButtonBehaviorfor non-submitting buttons (popover invocation, commands). - Reset Buttons:
HTMLResetButtonBehaviorfor form resetting. - Inputs:
HTMLInputBehaviorfor text entry, validation, and selection APIs. - Labels:
HTMLLabelBehaviorforforattribute association and focus delegation. - Forms:
HTMLFormBehaviorfor custom elements acting as form containers. - Radio Groups:
HTMLRadioGroupBehaviorforname-based mutual exclusion. - Tables:
HTMLTableBehaviorfor table layout semantics and accessibility.
Future behaviors would also manage their own relevant pseudo-classes:
| Behavior | CSS Pseudo-classes |
|---|---|
HTMLCheckboxBehavior |
:checked, :indeterminate |
HTMLInputBehavior |
:valid, :invalid, :required, :optional, :placeholder-shown |
HTMLRadioGroupBehavior |
:checked |
HTMLResetButtonBehavior |
:default (if only reset button in form) |
A future extension of this proposal could allow developers to define their own reusable behaviors by subclassing an ElementBehavior base class. This would enable patterns like custom tooltip behaviors, polyfilling upcoming platform behaviors, and composing developer-defined behaviors with platform-provided ones.
This direction is explored in a separate document: Developer-defined behaviors. It is not part of the current proposal and should be treated as forward-looking exploration.
Although this proposal currently focuses on custom elements, the behavior pattern could potentially be generalized to all HTML elements (e.g., a <div> element gains button behavior via behaviors). However, extending behaviors to native HTML elements would raise questions about correctness and accessibility.
The American English spelling of behavior throughout this proposal follows the WHATWG spec style guidelines. However, the word "behavior" has some drawbacks:
- "behaviour" vs "behavior" may cause some friction for contributors.
- Shorter names would improve ergonomics.
- "Behavior" is used in other contexts (such as CSS scroll-behavior), which could cause confusion.
Alternatives:
| Name | Example class | Example API | Notes |
|---|---|---|---|
| mixin | HTMLSubmitButtonMixin |
attachInternals({ mixins: [...] }) |
Related term, familiar concept but implies class-level composition |
| conduct | HTMLSubmitButtonConduct |
attachInternals({ conducts: [...] }) |
Short |
| action | HTMLSubmitButtonAction |
attachInternals({ actions: [...] }) |
Intuitive but overloaded (form action attribute) |
| trait | HTMLSubmitButtonTrait |
attachInternals({ traits: [...] }) |
Related term and short |
Behaviors are exposed as functions that take a superclass and return a subclass.
class CustomSubmitButton extends HTMLSubmitButtonMixin(HTMLElement) { ... }Pros:
- Familiar JavaScript pattern.
- Prototype-based composition.
Cons:
- Behavior is fixed at class definition time (e.g. A design system couldn't offer a single
<ds-button>that changes behavior based on thetypeattribute as it would need separate classes like<ds-submit-button>,<ds-reset-button>,<ds-button>, increasing bundle sizes and API surface). - Authors might need to generate many class variations for different behavior combinations.
- It strictly binds behavior to the JavaScript class hierarchy, making a future declarative syntax hard to implement without creating new classes.
Rejected in favor of the imperative API because it doesn't allow behavior composition (attaching multiple complementary behaviors to a single element), requires multiple classes instead of a single element that adapts to initial configuration, and doesn't support configuring behavior state before attachment.
Alternative 2: ElementInternals.type (Proposed)
Set a single "type" string that grants a predefined bundle of behaviors.
class CustomButton extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachInternals().type = 'button';
}
}Pros:
- Simple API.
- Easy to understand for common cases.
Cons:
- No composability as one custom element can only have one type.
- Bundling behavior can get confusing as it isn't obvious what behaviors and attributes are added.
- String APIs are error-prone and hard to debug.
Too inflexible for the variety of use cases web developers need. While simpler, it doesn't solve the composability problem and it might be confusing for developers to use in practice.
Alternative 3: Custom Attributes (Proposed)
Define custom attributes with lifecycle callbacks that add behavior to elements.
class SubmitButtonAttribute extends Attribute {
connectedCallback() {
this.ownerElement.addEventListener('click', () => {
// Submit form logic.
});
}
}
HTMLElement.attributeRegistry.define('submit-button', SubmitButtonAttribute);<custom-element submit-button>Submit</custom-element>Pros:
- Would work with both native and custom elements.
- Would have a declarative version.
- Would provide composability (multiple attributes).
Cons:
- Would require authors to implement all behavior in JavaScript (no access to platform internals).
- There are performance concerns with
Attrnode creation. - Namespace conflicts need resolution.
- Doesn't solve the platform integration problem.
- Still a proposal without implementation commitment.
Custom attributes are complementary but don't provide access to native behaviors. They're useful for userland behavior composition but can't trigger form submission, invoke popovers through platform code, etc.
Extend native element classes directly.
class FancyButton extends HTMLButtonElement {
constructor() {
super();
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });<button is="fancy-button">Click me</button>Pros:
- Full access to all native behaviors.
- Natural inheritance model.
Cons:
- Interoperability issues across browsers.
- Limited Shadow DOM support - only certain elements can be shadow hosts
- Can't use
ElementInternalsAPI. - The
is=syntax isn't considered developer-friendly to some. - Doesn't support composing behaviors from different base elements.
While customized built-ins are useful where supported, the issues listed above makes them unsuitable as the primary solution.
Expose specific behavioral attributes (like popover, draggable, focusgroup) via ElementInternals so custom elements can adopt them without exposing the attribute to the user. See issue #11752.
Pros:
- Solves specific use cases like popovers and drag-and-drop.
- Hides implementation details from the consumer.
Cons:
- Doesn't currently address form submission behavior.
- Scoped to specific attributes rather than general behaviors.
- Since the composition doesn't have an order/sequence to it, web authors would not be able to specify a desired "winner" when using multiple behaviors that happen to impact a shared value or behavior.
Modify existing native HTML elements to be fully stylable and customizable, similar to Customizable Select.
Pros:
- Developers can use standard HTML elements (
<button>,<select>, etc.) without needing custom elements. - Accessibility and behavior are handled entirely by the browser.
Cons:
- Requires specification and implementation for every single HTML element.
- Does not help developers who need to create a custom element for semantic or architectural reasons (e.g., a specific design system component with custom API).
- Doesn't solve the problem of "autonomous custom elements" needing native capabilities; it just improves native elements.
While valuable, this can be a parallel effort. Even if all native elements were customizable, there would still be valid use cases for autonomous custom elements that need to participate in native behaviors (like form submission) while maintaining their own identity and API.
Expose individual primitives (focusability, disabled, keyboard activation) directly on ElementInternals.
Pros:
- Maximum flexibility—authors compose exactly what they need.
- Each primitive is independently useful.
Cons:
- Primitives like
disabledandfocusableinteract with each other, with accessibility, and with event handling. Settinginternals.disabled = truewithout the associated behavior might result in the element looking disabled but still receiving clicks, remaining in the tab order, and submitting with a form. - Even seemingly simple primitives like focusability could have significant complexity around accessibility integration. This is why
popovertargetis limited to buttons(it was originally intended for any element, but the accessibility requirements around focusability and activation made buttons the practical choice). See design-principles tradeoff between high-level and low-level APIs. - Form submission participation can be seen as a primitive itself (it can't be broken down further due to accessibility concerns).
Use TC39 decorators to attach behaviors to custom element classes.
@HTMLSubmitButtonBehavior
class CustomButton extends HTMLElement {
// Decorator applies submit button behavior to the class.
}
customElements.define('custom-button', CustomButton);Pros:
- Clean, declarative syntax at the class level.
- Familiar pattern for developers coming from other languages (Python, Java annotations) or TypeScript.
- Allows composition.
Cons:
- Decorators operate at class definition time, not instance creation time. This creates the same limitation as static class mixins: behavior is fixed when the class is defined, not when instances are created (e.g., a design system couldn't offer a single
<ds-button>class that adapts behavior based on thetypeattribute). - Instance-specific behavior configuration (e.g., setting
formActionbefore attachment) isn't supported. - Decorators are inherently JavaScript syntax and don't support a future declarative, JavaScript-less approach to custom elements. This proposal's design decouples behaviors from the class definition, enabling future declarative syntax (see Other considerations).
HTMLSubmitButtonBehavior could itself be designed as a decorator, but decorators can't easily access ElementInternals or instance state during application. Decorators would need to coordinate with attachInternals() timing. Additionally, getting a reference to the behavior instance for property access (e.g., behavior.formAction) would require additional wiring.
- Platform behaviors must provide appropriate default ARIA roles and states (e.g.,
role="button"forHTMLSubmitButtonBehavior). - Custom elements using a platform-provided behavior must gain the same keyboard handling and focus management as their native counterparts (e.g., Space/Enter activation).
- Authors must be able to override default semantics using
ElementInternals.roleandElementInternals.aria*properties if the default behavior does not match their specific use case.
- This proposal exposes existing platform capabilities to custom elements, rather than introducing new capabilities.
- Form submission triggered by behaviors must respect the same security policies as native form submission.
- All security checks that apply to native elements (e.g., form validation, submission restrictions) apply to custom elements using these behaviors.
- The presence of specific behaviors in the API surface can be used for fingerprinting or browser version detection. This is consistent with the introduction of any new Web Platform feature.
- This proposal does not introduce new mechanisms for collecting or transmitting user data beyond what is already possible with native HTML elements.
- Chromium: Positive
- Gecko: No signal
- WebKit: No signal
Many thanks for valuable feedback and advice from:
- Alex Russell
- Andy Luhrs
- Daniel Clark
- Hoch Hochkeppel
- Justin Fagnani
- Keith Cirkel
- Kevin Babbitt
- Kurt Catti-Schmidt
- Mason Freed
- Rob Eisenberg
- Steve Orvell
Thanks to the following proposals, articles, frameworks, and languages for their work on similar problems that influenced this proposal.
- A "story" about
<input>by Monica Dinculescu — analysis of<input>element design problems that informed our decision to use static behaviors. - Real Mixins with JavaScript Classes by Justin Fagnani.
- ElementInternals.type proposal.
- Custom Attributes proposal.
- TC39 Maximally Minimal Mixins proposal.
- TC39 Decorators proposal.
- Lit framework's reactive controllers pattern.
- Expose certain behavioural attributes via ElementInternals proposal.
- WICG/webcomponents#814 - Form submission from custom elements
- whatwg/html#9110 - Popover invocation
- whatwg/html#5423, whatwg/html#11584 - Label behaviors
- whatwg/html#10220 - Custom elements as forms
- w3c/tpac2023-breakouts#44 - TPAC 2023 discussion
- WebKit/standards-positions#97 - WebKit position on customized built-ins