Skip to main content
A Frehner Site

A proposal for non-global @property at-rules

Background #

For a long time, web apps have been using lazy loading to reduce the amount of code that is initially downloaded, making the first-load experience faster. And recently, the platform itself started to gain that behavior natively using <template for=""> for "out-of-order patching", which is awesome!

Lazily loading the CSS associated with the HTML and JS is common. However, I've recently been running into one specific piece of lazily loaded CSS that causes performance issues. I'm looking at you, @property.

Don't get me wrong, I like @property. But let's look at a common pitfall situation with it:

Example: a lazily loaded aside #

You have an aside on your page that isn't important for the first load, and therefore can be loaded lazily. And you need an @property rule because you want to use a CSS Custom Property in, say, an animation.

(I'll use the <template for=""> method in this example, and most of the CSS-Tricks @property animation example for the CSS. However, you can imagine using any sort of lazy-loading tool at your disposal, e.g., React.lazy.)

<p>...the main content, rendered first...</p>
<aside><?start name="aside-content">Loading...<?end></aside>

<!-- loaded later -->

<template for="aside-content">
	<style>
		@property --turn {
			syntax: "<angle>";
			initial-value: 0deg;
			inherits: true;
		}

		@keyframes rotate {
			to {
				--turn: 360deg;
			}
		}

		#spinny {
			transform-origin: left center;
			animation: rotate 2.5s cubic-bezier(1, 0, 0, 1) normal infinite;
			transform: rotate(var(--turn));
		}
	</style>
	<div id="spinny">I'll try spinning!</div>
</template>

This seems pretty benign, right? Unfortunately, because the @property registration is global, the moment it gets loaded, every element in the document gets restyled! In a large document, that has huge performance implications.

A workaround #

How can we mitigate this? Well, it's easy enough to just register every property the application might use in the initial document or stylesheet.

The downside is that every page carries the registrations of every lazily loaded feature, and those features can't ship their types with their own CSS. For teams with hundreds or thousands of devs working on an app, that single entrypoint stylesheet turns into the classic mess of problems that shared files have.

The proposal #

A non-global @property rule registers a CSS Custom Property for the elements its parent selector matches (and their descendants), and nothing else changes about how CSS Custom Properties work.

Example:

#spinny {
	@property --turn {
		syntax: "<angle>";
		initial-value: 0deg;
		inherits: true;
	}
}

(Note that I was very careful to NOT use the term "scoped" for this. There's been a lot of discussion in the CSSWG about "scoped @property", i.e., in shadow roots, and there are a lot of problems with it. This proposal is explicitly not that.)

Importantly, if it is lazily loaded, the whole document does not need to restyle because of the late registration; only #spinny and its descendants do.

Here's how I propose it behaves:

  1. The CSS Custom Property inherits like it did before. It behaves the same through shadow DOM, slotted children, ::part(), animations, transitions, getComputedStyle(), etc. The only difference is that it may find a nearer @property definition between it and the document root.
  2. If a Custom Property has one syntax above the at-rule (e.g., "<percentage>") and the non-global at-rule uses another (e.g., "<color>"), then everything under the non-global rule gets a <color>-enforced value, and everything above it gets a <percentage>-enforced value.
  3. To force a value reset, follow the non-global at-rule with an initial declaration.
#spinny {
	@property --turn {
		syntax: "<angle>";
		initial-value: 0deg;
		inherits: true;
	}
	/* force a reset to `0deg` */
	--turn: initial;
}

Open questions I have #

  1. Can browsers optimize the current behavior without this proposal, and restyle only the elements that use the property instead of the whole document? That would render half of this proposal moot (but not the half about teams owning and shipping their own @property rules).
  2. While I would hope that the regular rules around the cascade makes reading the value the same, I wonder if setting the value would require a DOM walk up the tree to check for a non-global @property, and if so, would that hurt performance? Could this be done efficiently, without a full walk?
  3. Jake Archibald also wrote recently about Controlling when CSS custom property values are computed, and the timing around this still kind of hurts my brain a bit. Would this proposal affect any of that?

A reminder of the desired outcomes #


Research and questions #

Earlier CSSWG discussions were mostly about shadow DOM and keeping registration private to a shadow tree, but that led to a lot of hard questions about how it inherits (or doesn't) into slots, parts, etc. I'm explicitly avoiding that. :)

But I did some research as part of this proposal. Here are my rough notes:

Spec notes #

Doesn't the spec say registrations can't be scoped? #

It says:

Why can't registrations be scoped?

The reason registrations must be global is because elements can exist in multiple tree scopes at the same time, with styles from each tree scope intermingling and cascading together.

If registrations could be scoped to a tree scope, and a single property was registered both inside and outside, it's not clear which registration should be applied to parse the value.

— CSS Properties and Values API, §Shadow DOM

As mentioned above, this is why I've avoided using the term "scoped" in this proposal; it came from a discussion about whether the platform should support shadow-root-scoped CSS Custom Properties: css-houdini-drafts#939.

This proposal is not that.

Are there parts of that spec note that still apply? #

The note says:

the shadow DOM might expect a property to have a particular value space, and be surprised when it receives something completely different due to the outer tree winning the cascade and applying its own registration.

Here, the reverse can happen. A rule from elsewhere in the application writes #spinny { --turn: 10px } with a <length> in mind, and the non-global <angle> registration makes it invalid at computed-value time. The spec already accepts this kind of conflict for global registration:

If the outer page is using a custom property of the same name for different purposes, that is already a conflict that needs to be resolved, and the registration behavior does not make it worse.


@property in shadow trees #

@property inside @scope or @container #

Nested @property #

Late registration of @property #

CSSOM / Typed OM #