[WICG/webcomponents] Proposal: Composable shadow roots, for extending builtins and more (Issue #1108)

LeaVerou created an issue (WICG/webcomponents#1108)

## Background & Use Cases

Extending native elements is a very common need, but the [customizable built-ins](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/is) proposal was blocked by WebKit, and for good reasons. Indeed, there are many issues with that direction:
- **Clumsy user-facing syntax:** While `<button is="foo-button">` may provide the small benefit of a nicer fallback, for many use cases it's not acceptable syntax. Authors generally want `<foo-button>`, and the element it extends is often an implementation detail. Encoding it in every single component instance makes syntax much clumsier for component authors, who often won't understand why some components are `<foo-something>` and other times `<something is="foo-something">`. For things where `<button is="foo-button">` is actually acceptable, [custom attributes](https://github.com/WICG/webcomponents/issues/1029) tend to be a better fit.
- **Limited capabilities:** Native elements typically have closed shadow roots. Without being able to tweak the element's UI, there are very few useful things that can be done.

However, the lack of ways to extend native elements is a real problem. Components typically end up either recreating the native element and its API entirely, or wrapping it, both with very serious maintainability and ergonomics issues.
- **Recreating native elements** introduces a usability cliff, as now adding a small feature to a native element requires recreating everything. It is also not maintainable, as no userland code can really keep up with the rate the web platform is evolving.
- **Wrapping native elements** (e.g. a `<foo-button>` whose shadow root contains a `<button>`) both requires recreating the native's API and is a styling disaster. How can components "redirect" styling on `<foo-button>` so that it is applied to their internal `<button>`? One way is `display: contents` on the host, but it causes several problems (e.g. `getClientRects()` and methods that depend on it, such as `getBoundingClientRect()` [stops working](https://github.com/w3c/csswg-drafts/issues/12040), overriding `display` on the host from the outside breaks everything, etc.). In practice, components often end up requiring that authors style parts instead, or expose custom properties for common stylings, both with suboptimal DX ([example](https://webawesome.com/docs/components/button#styling-buttons)).

Note that this is not specific to natives, component consumers may also want to extend other web components they don't control. For example, to define their own WCs that customize components from a third-party library.

This proposal attempts to sketch out a way to solve these problems based on regular ES class inheritance (with no user-facing HTML syntax that replicates the relationship) and a syntax for composing shadow roots without needing access to them, by piggybacking on slots. 

> [!NOTE]
> Please note that this is a proposal, not a fully-fleshed out spec, and a pretty ambitious one at that. You can definitely poke holes at it; there are many kinks and details to be ironed out, but I think the general direction could be promising.

### Goals

- Extend native elements and other web components, even if their shadow roots are closed.
- Inherit JS API, attributes, accessors, AT, and optionally, styling.
- Do not expose the inheritance relationship to component consumers (except perhaps as an optional rendering hint)
- Have control over the child component API, including exposing fewer attributes than its parent component or taking different values for some of them.

### Non-goals

- Multiple inheritance. E.g. if you want to add a `href` to a button, how can you inherit from both the APIs of `<a>` and `<button>`? This is out of scope for this proposal, just like it is for JS. But one advantage of this proposal is that once JS gets native class mixins, elements can automatically benefit.

### Relationship to other proposals

#### [Custom attributes](https://github.com/WICG/webcomponents/issues/1029)

[Custom attributes](https://github.com/WICG/webcomponents/issues/1029) could help when the desired functionality can be expressed as a trait, but:
1. Custom attributes are not an appropriate solution for all use cases, often the customizations desired are fundamental to the identity of the element.
2. With no ability to tweak the element's shadow DOM, their functionality is also limited.

This proposal does not compete with custom attributes; in fact the way it outlines for composing different shadow roots with different levels of encapsulation can be used by custom attributes as well.

#### [`ElementInternals.type`](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ElementInternalsType/explainer.md)

However, it _does_ provide a more generalizable alternative to `ElementInternals.type`. `ElementInternals.type` involves an alternative "magic" inheritance mechanism that doesn't go through `extends` (and thus `super` still resolves to `HTMLElement`), the resulting element inherits some properties from the parent but not others, and whose values are an odd mix of element types (`button`, `label`) and values (`submit`, `reset`). Additionally, its design violates several TAG principles, such as [throwing on setters](https://www.w3.org/TR/design-principles/#attributes-vs-methods). It solves the most pressing use cases, but in a way that is [overfit](https://github.com/w3ctag/design-principles/issues/450) to them.

## Composable Shadow Roots Proposal

The core idea of composable shadow roots is that slots are a powerful mechanism for merging two DOM structures in a controlled way, and can be repurposed for combining two shadow roots with potentially different encapsulation types (since natives typically have closed shadow roots) without breaking encapsulation.



### Subclassing via regular JS `extends`

A core idea of this proposal is to allow author WCs to extend natives without having to expose the inheritance relationship to _their_ users on every instance of the component. User-facing syntax remains the same as regular WCs, which also means existing WCs can be "upgraded" to extend from natives without usage having to be updated. Yes, we lose the fallback behavior, but to most authors (a) this does not seem to be a benefit that is worth the user-facing API tradeoff (b) we can still have it as an optional hint, see `like` attribute below.

So, this just would just work:

```js
class FooButton extends HTMLButtonElement {

}

customElements.define("foo-button", FooButton);
```

With nothing more than the `extends` declaration, the resulting element behaves identically to the native one, just with a different element name and none of the UA styles. Its shadow root is the same closed shadow root of its superclass, and thus, the subclass has no access to it (`this.shadowRoot` is `null`). 

> [!NOTE]
>  Note that if `this.shadowRoot` is `null`, we can't even use `adoptedStyleSheets` to add styles, so this has very limited utility without composable shadow roots (see below).

#### How to subclass specific types of elements?

HTML often overloads HTML elements that components may want to handle separately, e.g. `<input type>`, `<button type>` etc.
It's a little awkward, but if the child component does not want to expose the parent component's `type` attribute/property, it can hardcode it to a specific value:

```js
class MyInput extends HTMLInputElement {
 constructor() {
  super();
  super.type = "text";
 }
 
 get type() { return super.type }
 set type(v) {}
}
```

#### How to have different values for an attribute with the same name?

Similarly to the example above, suppose we want to have a `<foo-button>` component with a `type` attribute that defaults to `button`, but could also be set to `submit`, and we don't want to allow `reset`.
We can just regular JS to transform the value before passing it to the superclass:

```js
class FooButton extends HTMLButtonElement {
 constructor() {
  super();
  if (super.type !== "submit") super.type = "button";
 }
 
 get type() { return super.type }
 set type(v) {
  if (v === "submit") super.type = "submit";
  else super.type = "button";
 }
}
```

### What about `ElementInternals`?

The child component would inherit the same internals as its parent. If `attachInternals()` is used any values set are composed with the parent values:
- `states` are composed via union
- Any scalar values set (e.g. ARIA) override parent ones.

### Composable shadow roots via slots

For some (few) use cases, not tweaking the element's UI may be fine, since the only extensions required can be implemented through JS subclassing. However, most use cases do require some kind of DOM extension as well, even if it's just adding styles.

The core idea of this proposal is that this kind of DOM amending required is usually **additive**, i.e. adding elements *around* the native element's shadow root (but within its shadow host) or around the shadow host.

For those, this proposal introduces a new `<slot>` attribute: `type` which can be used to designate certain slot elements as _special purpose_ (more types may be added in the future). To mark a slot as "this is where the superclass' shadow root goes", the subclass WC would use `<slot type="supershadow">`. For the whole shadow host, it could be `<slot type="super">`. The exact values are TBB; alternatively, it could even be a special slot name that is syntactically distinct (e.g. `<slot name="$super">`).

If the subclass calls `attachShadow({mode: "open"})`, the shadow root automatically gets initialized with `""` just like it is today, and authors need to add `<slot type="supershadow"></slot>` to still render the parent shadow root in it. Authors can also add any other content around it, including other slots, e.g.:

```html
<slot name="prefix"></slot>
<slot type="supershadow"></slot>
<slot name="suffix"></slot>
```

> [!NOTE]
> **Why not initialize with `'<slot type="supershadow"></slot>'`?**
> Note that while this is primarily designed to facilitate extending builtins, it is not restricted to them, and can be used to extend any element class. Therefore, initializing with `'<slot type="supershadow"></slot>'` would not be web-compatible.

The slot would otherwise behave like a regular slot for open shadow roots, and as if it had no slotted content for closed shadow roots, including:
- Methods like `assignedNodes()` or `assignedElements()` 
- the `:has-slotted` pseudo-class
- the `slotchange` event: for closed shadow roots it can not fire, for open shadow roots it could fire just fine.

Note that the subclass could simply choose not to include any special slot if it doesn't desire to render the actual superclass shadow root anywhere. This is useful for cases where one wants to completely change presentation, but still inherit all API methods. In that case, the superclass element still exists, but is disconnected, and API methods still apply to that disconnected element (and fire events as normal, which can be listened to by the subclass). Components could even include the slot sometimes and not others, depending on certain conditions.

Edge cases:
- If multiple special slots are specified, the first one wins, just like what happens today if multiple slots are specified with the same name.
- In case of slot naming conflicts, child component slots have priority over parent slots regardless of source order. So if they use slots with the same name, the child's slots always win. This mainly affects extending other WCs, since natives don't use slots.

### Other potential extensions (non-MVP)

#### `<slot type="super">`

Alternatively, authors could use `<slot type="super">` to encapsulate the entire element, while still adding DOM around it. This is similar to the existing pattern of wrapping natives, except authors do not need to recreate the entire element API, they get all properties, attributes, and methods for free, and they automatically get redirected to the encapsulated element (unless overridden to do something else). This is useful for cases where one wants to create higher-level components that add labels, tooltips, etc. For example, one could do this to create an input with a label auto-generated from its `label` attribute or `label` slot, hints, etc. (such as [this](https://webawesome.com/docs/components/input#labels)).

```html
<label><slot name="label">{{ attrs.label }}</slot>
  <slot type="super"></slot>
</label>
<slot name="hint"></slot>
```

In that case, all superclass DOM methods would be automatically "forwarded" to the internal element.

#### The `like` attribute and the CSS `:like()` pseudo-class

If fallback display like the corresponding native element is desired, we could flip the relationship: `<foo-button like="button">` instead of `<button is="foo-button">`. But that would be an optional UA hint for optimization, and not required for inheritance to work. `like` would be a global attribute, that is valid on all custom elements. E.g. `<foo-button like="button">` would also render like a functional button and be exposed like a button in the AT, even if no `<foo-button>` ever gets registered.

To help with styling, we can introduce a new `:like(<ident>)` pseudo-class which is basically the CSS equivalent of `instanceof` (whether the `like` attribute is used or not). E.g in the hierarchy `foo-icon-button` < `foo-button` < `button`, `:like(button)` would match instances of all three. Then, UA stylesheets and authors could style `:like(button)` instead of `button` and said styling would apply to button subclasses too.

> [!NOTE]
> One issue with this is that this is typically desirable when using `<slot type="supershadow">` but not when using `<slot type="super">`.

To take advantage of `:like()` even when full-blown inheritance is not desirable, perhaps there could be an `ElementInternals.like` property to override.



-- 
Reply to this email directly or view it on GitHub:
https://github.com/WICG/webcomponents/issues/1108
You are receiving this because you are subscribed to this thread.

Message ID: <WICG/webcomponents/issues/1108@github.com>

Received on Sunday, 20 July 2025 21:11:46 UTC