Skip to content

Instantly share code, notes, and snippets.

@tabatkins
Created June 30, 2025 22:28
Show Gist options
  • Save tabatkins/4074abaf486af5b3f7ac289737e216e4 to your computer and use it in GitHub Desktop.
Save tabatkins/4074abaf486af5b3f7ac289737e216e4 to your computer and use it in GitHub Desktop.
A rough sketch of a "Cascading Attribute Sheets" proposal

Cascading Attribute Sheets++

  • Author: Tab Atkins-Bittner
  • Created: 2025-06-30

Summary

In general, we put "semantics" into HTML, and "presentation" into CSS. This line is somewhat blurry (display:none, for example), but it's usually reasonable to tell them apart. But sometimes, even for things that "clearly" fall into the semantics half, we get proposals to put them in CSS, because they're probably going to be used on a lot of elements, in a formulaic pattern, and CSS Selectors are a great feature for that.

This has the theoretical problem that now more semantic content is written into what is "supposed" to be presentational. It has a practical problem that Selectors can actually get really complex and depend on funky things, and that can be a bit of an issue when keeping the a11y tree in sync with the stated semantics. It also splinters the set of features across two different syntaxes, based purely on whether someone believes, early in the design process, that it would be useful to work with Selectors.

It would be nice if we could continue always putting the a11y/etc data into HTML, but allow authors to use a more convenient syntax to apply them when they wish. Then this isn't a design question for individual features, it's just something anyone can do for any attribute.

Tl;dr

  1. Define "Cascading Attribute Sheets" (CAS) (first proposed in https://www.xanthir.com/blog/b4K_0, this is a subset of that proposal). This lets authors choose to write highly repetitious attributes in a much more compact fashion.
  2. Define the JS equivalent of CAS, to take some pressure off of CAS to do everything. Properties, event listeners, element references, etc can all be done in JS instead, with similar usability.
  3. Define an expansion of ID references, used in attributes, to allow fuller selectors. (Proposed most recently in https://discourse.wicg.io/t/relative-element-references-in-html/5627/.) This allows more things that might need element refs (and thus require the JS api) to be done in CAS instead, and just generally helps solve the "link two things, but for a bunch of repeated component structures).

Cascading Attribute Sheets

A new language, reusing CSS syntax, and invoked via <script type=cas>.

element.selector > goes[here] {
  attr-name: attr-value;
}
  • The selectors are the full set of CSS selectors.
  • The "property name" is the name of an attribute you're going to set, and the "property value" is the value you're going to set it to. Note that in both cases, casing is preserved; foo: bar and FOO: BAR are two different things, setting <div foo=bar> or <div FOO=BAR>.
  • If the value is a CSS string, we use the string's value; if it's an ident, number, or dimension, we stringify those.
  • If there are multiple values, we put in a single space between the values.
  • We have a few special values, #on, #off/#unset, and #initial, that respectively set it to the empty string (for boolean attributes), unset it entirely (#off for boolean attributes, #unset for anything else, tho this is just a convention; they're identical), or leave it unchanged from how it was.
  • You can also mark a declaration as "optional", where it only sets the attribute if it's not currently set (but it leaves manually-set attributes in the HTML alone), with an !optional at the end (like CSS's !important).

Here's a simple but realistic example:

video {
  preload: metadata;
}
#content video {
  preload: auto;
}

Execution is done by collecting all the CAS sheets, and when DOMContentLoaded, applying all the rules, using CSS's normal specificity rules. The winning declarations then modify all the attributes. Notably, this is all done in one pass, so an attribute set by one CAS rule can't affect the selector of another CAS rule.

Note

Maybe we want a mechanism to allow for layering, so you can do some setup of more generic attributes, then apply some sheets based on those. Possibly reuse cascade layers? Layers by default shouldn't affect these, so we can continue to use them for specificity reasons as usual, but we can add a rule that indicates which layers are "breakpoints", so earlier layers are matched and executed in their entirety before the breaking layer and later ones are matched/executed. Building on cascade layers feels very natural, as by definition their rules can't be interleaved with earlier layers anyway.

Additionally, CAS sheets set up automatic mutation listeners, listening for inserted elements. Whenever an element is inserted into the document, all CAS rules are matched and executed over it, modifying its attributes.

(This is slightly inconsistent with the initial run - an insertion can implicitly depend on attributes set by other CAS rules on already-inserted elements, but that's unavoidable.)

JS CAS API

CAS can only set actual attributes to actual string values. But there's plenty of use-cases for setting DOM properties without associated attributes, and sometimes setting them to element references, or functions, etc. We can't do this in CAS, because setting a property can execute a setter; we need a JS execution context.

To allow these cases to be handled with the same ease as CAS, and the same basic timing/behavior, we introduce a DOM API that does essentially the same thing.

Rough sketch:

CAS.registerRule("selector here", {"attr": "val", "*prop": "val", "onfoo": function(e){...}});
print(CAS.getRules()); // only prints the JS-created ones, I guess?

Like CAS sheets, any JS-created CAS rules are executed at DOMContentLoaded, and on element insertion.

You should also be able to manually re-trigger CAS execution on an element or the entire document, including both CAS sheets and JS-created ones:

CAS.execute(doc); // runs on the entire document
CAS.execute(el); // runs on the element and its children
CAS.executeOne(el); // runs on just the one element, not its children

Selector Element References

TODO: fill in more concrete proposal based on https://discourse.wicg.io/t/relative-element-references-in-html/5627/

@martinthomson
Copy link

Regarding !optional, I think that perhaps this is better if it follows the CSS patterns, where the CAS sheet provides a value in the case that no attribute is specified, rather than overriding the value in the document. That would allow you to keep !important, but - more importantly - fits better with the authoring pattern we have with CSS, rather than introducing a different mode.

@LeaVerou
Copy link

LeaVerou commented Jul 6, 2025

Nice to see movement in this direction. This design-principles issue I raised when I was in the TAG is relevant too. Also, this more complete writeup of the IDREF issue and some ideas about solving it. I think the IDREF problem is becoming worse now with the proliferation of web components, since WC authors look at HTML's design as the source of truth and copy its design decisions, so now we have a ton of WCs using IDREFs as well.

While the regular CAS proposal seems solid, I have a lot of reservations around the JS API. Attributes are inherently declarative, so setting them via a CSS-like API is natural. All you need to figure out is precedence order — for which there is plenty of precedent (pun not intended :).

But JS properties are a whole other can of worms — what property value do you apply upon selector invalidation? And having to manually observe any factor that may affect selector matching and re-trigger defeats a lot of the DX gain of having this API in the first place.
So I wonder if instead of this, we need a SelectorObserver for when a selector matches a new element and stops matching. This would allow the CAS pattern very easily:

let so = new SelectorObserver(records => {
	for (let r of records) {
		if (r.type === "added") r.target.value = "foo";
		else if (r.type === "removed") r.target.value = /* some other value */
	}
}, {selector: "selector here"});
so.observe(document.documentElement);

While this doesn't resolve the invalidation issue, it allows authors to make decisions based on their individual use cases, which are a lot simpler than having to define something for the general case, and affords a lot more flexibility than just setting and removing properties.

Ideally, this would come with a parameter about whether or not to observe open shadow roots, which is a big pain point with MutationObserver. Bonus points that it can be polyfilled via animations.

Some other questions and thoughts:

  • Are CAS properties going to be fixed or any ident corresponds to an attribute with the same name, even if unknown? The latter allows a lot more flexibility (e.g. setting custom element attributes)
  • What's the plan for setting namespaced attributes? We can't really designate a convention based on current <ident> grammar, so either we need to expand the <ident> grammar with a new symbol (eek), or invent an @-rule for this (e.g. an expansion of @namespace that can have contents) and deal with the poor DX, since it's rare, so it doesn't need to be easy, just possible.
  • Will there be a CAS-analog to adoptedStylesheets for web components to use CAS sheets on their shadow roots?
  • Will element-backed pseudo-elements accept attributes via CAS, or are targets reserved to regular elements? If yes, that could break encapsulation, but if no, that restricts use cases quite a lot (and serves as another argument for why these should really be combinators).
  • I wonder if we could also piggyback on CAS to resolve the issue of different web platform technologies (SVG, MathML, MapML etc) needing different CSS properties. But then CAS would need to be compatible with <style type="text/css">...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment