- Author: Tab Atkins-Bittner
- Created: 2025-06-30
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.
- 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.
- 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.
- 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).
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
andFOO: 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.)
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
TODO: fill in more concrete proposal based on https://discourse.wicg.io/t/relative-element-references-in-html/5627/
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:
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:
<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.adoptedStylesheets
for web components to use CAS sheets on their shadow roots?<style type="text/css">
...