Some time ago, I created the modern spec for CSS Variables, which lets you store uninterpreted values into "custom properties", then substitute those values into real properties later on.
This worked great, and was particularly convenient for Shadow DOM, which wanted a simple, controllable way to let the outside page twiddle the styling of a component, but without giving them full access to the internals of the component. The component author could just use a couple of var()
functions, with default values so that it worked automatically, and then the component user could set custom properties on the shadow host and let them inherit into the component.
After a while, tho, we realized that this still had some issues. It was perfect for small amounts of customization - providing a "theme color" for a component or similar - but it fell down when you wanted to allow arbitrary styling of a component. For example, an alert widget might want to expose its title area to whatever inline styling the user wants. If the component could use light-DOM elements, pulled into the shadow DOM via slots, this works fine, as the user can target and fully style that element, but if the component itself generated the element, they were out of luck. The only way to get close is to define a whole bunch of custom properties, mimicking the actual CSS properties you want to allow, and then apply them all to the element in the shadow's stylesheet. This is awkward at best, and at worst can mean literally hundreds of boilerplate custom properties per styleable element, which is ugly, awkward, and slow - the trifecta!
So I thought - hey, custom properties can hold anything, right? Why not, instead of holding a single value, they held an entire ruleset, and then I add a new capability to let you substitute the whole shebang into an element's style? And thus the @apply
rule was born:
/* outer page */ x-component { --heading-style: { color: red; text-decoration: underline; font-weight: bold; }; } /* shadow DOM stylesheet */ .heading { @apply(--heading-style); }
Mixing Levels
This seemed like a really elegant solution for a while - I got to reuse some existing functionality to solve a new problem! But gradually, we realized that it came with its own problems.
First, folding this into custom properties meant we were mixing levels, in a way that turned out awkward. For example, using a var()
in a ruleset didn't do what you might think:
.list { --heading-style: { color: var(--theme-color); }; } .list > x-component:first-child { --theme-color: red; } .list > x-component:last-child { --theme-color: blue; }
The above code looks like it sets up the heading styles for all the components in the list, deferring the color
property's value to the --theme-color
variable, set on the individual components. Instead, tho, it subs in the value of --theme-color
on the .list element itself.
This is due to the fact that custom properties don't care what's inside of them. A value, a ruleset, it all looks the same. As far as the CSS engine is concerned, that first rule was:
.list { --heading-style: █████var(--theme-color)█████; }
And so it happily says "hey look, a variable reference! I know how to handle those!" and eagerly substitutes in the value from that element, rather than waiting until it's actually @apply'd.
I am planning on adding a feature to variables that makes them resolve "late", at use-time rather than definition-time, which would "solve" this. But it wouldn't really work: it requires the user to remember that they have to do this special thing for variables in custom property rulesets, and it doesn't solve the problem of wanting to use a late variable in a property meant to be @apply'd. Basically, this just kicks the problem a little further down the road; it doesn't actually solve anything.
This "mixing levels" thing would persist and cause problems in many other different ways.
Setting Custom Properties Inside
An obvious thing you might want to do in an @apply ruleset is set more custom properties, to provide styles for further-nested components. This brings up some new difficulties.
For one, can you set variables intended for the @apply'd element itself? You can do that with normal variables:
/* This is fine */ .foo { --one: blue; color: var(--one); } /* But does this work? */ .foo { --one: { color: blue; } @apply(--one)? } /* How about this? */ .foo { --one: { --two: blue; } } .foo > .child { @apply(--one); color: var(--two); }
There are use-cases for these. In particular, the last one makes sense; if it didn't work, then when you wanted to style an element in a shadow, you'd have to write all the normal properties in a custom property intended for @apply, then separately write all the custom properties you want to define:
x-component { --heading-style: { text-decoration: underline; }; --theme-color: blue; } /* But this wouldn't work: */ x-component { --heading-style: { text-decoration: underline; --theme-color: blue; }; }
That's pretty annoying. But letting this work bring up some interesting issues. For example, you now have to hard against circularity:
x-component { --one: { --one: blue; } @apply(--one); color: var(--one); }
What does the above even mean? Does the meaning change if I swap the ordering of the lines? We ended up defining that it does work, by carefully ordering the steps: first you do any var() substitution, and let custom properties define more variables, then you @apply substitution, then you repeat var() substitution over again with the new values that the @apply might have brought in. So the above example ends up giving the color
property a value of blue
. Circuitous and confusing since --one is interpreted in two totally different ways, and kinda annoying/expensive for the CSS engine, but it technically works.
But then we hit a further stumbling block - animations! We want to allow animations to be able to animate custom properties, but also to use custom properties (so you can, for example, animate a background from var(--theme-color-1)
to var(--theme-color-2)
). The way this ended up working is that the element first does variable substitution and definition as normal, then any animations defined to run on the element get to use the variables so defined, and define new ones, then the properties on the element get variable-substituted again. Sound familiar? Combining animations with @apply meant figuring out precisely how to interleave them, and how many times to re-substitute variables, and it turns out there isn't even a "correct" answer - whatever you choose, you'll exclude some reasonable use-cases.
Interacting With Selectors, and More
But ok, all that's possible to define, even if it's clumsy and confusing in some cases. Now real JS frameworks, in particular Polymer, started using a polyfilled version of @apply
, in expectation of it eventually landing in browsers natively. And they ran into problems.
See, the original reason for @apply
was to avoid an explosion of custom properties when you wanted to allow arbitrary styling - instead, you just had one single custom property. Much more elegant!
And that works fine, as long as you just want to throw some styles at an element and be done with it. But often, we want more than that. We want to define hover styles, focus styles, active styles. If it's an input, we want to define placeholder and error styles.
With @apply
, the user doesn't have access to selectors anymore, so pseudo-classes don't exist. The component author has to reinvent them themself, adding a --heading-style-hover
, --heading-style-focus
, etc. And it's not uncommon to want to combine these, meaning you also need a --heading-style-hover-focus
property, and more. The possibilities explode combinatorially, eliminating our nice "just one property" thing we had going, and ensuring that component users have to memorize the precise set of pseudo-classes each component chooses to expose, and precisely how they name things (is it --heading-style-hover-focus
, or --heading-style-focus-hover
? Or was it --heading-style_hover_focus
for this one? Maybe --heading-style--hf
, or --heading-style_hocus
?).
This problem pervades the @apply
rule, because in general it moves all the various pieces of a style rule one level down:
- selectors get pushed into property names, losing reordering, syntax, optionality
- property names get pushed into property values, losing the ability to easily cascade and override things - you can't define a block of properties for the normal element and then easily override just some of those properties for the element when hovered
- property values get pushed into untyped property values, losing early grammar checking, and causing the problems explained in earlier sections
Ultimately, there's probably ways around all of these issues. For example, we toyed for a while with a "macro" facility that would auto-define the hover/focus/etc variants for you. But these "solutions" would just be reinventing, in a messy and ad-hoc way, the existing features of CSS that we so cavalierly threw away. I became increasingly disillusioned with the feature.
Enter ::part()
At the recent January 2017 CSSWG face-to-face meeting, while discussing these issues with my coworker Shane, we realized that we could avoid all of this by reviving the older ::part() proposal for Shadow DOM. This was a proposal to let the component author "tag" certain elements in their shadow with a "part name", and then the component user could target those parts by name with the ::part() pseudo-element on the component, like x-component::part(heading)
.
This had all the good stuff: it used CSS things at the correct level, so selectors lived in the selector space (allowing ::part(foo):hover
, etc), property names lived in the property name space (allowing the page to define the same property multiple times and let the cascade figure things out), and property values lived in the property value space (var()
worked correctly, no complications with animations or circularity, grammar checking works properly).
It also allowed some new useful powers - ::part() only targets the parts exposed by the component itself, not any sub-components it happens to use (unless it actively chooses to surface those sub-parts), which means better information-hiding. (Custom properties, because they inherit, default to the opposite - you have to specifically block them from inheriting into sub-components and styling them.) This also means name collisions are less of a problem - setting a custom property that the component and a sub-component uses can be a problem, but if they both use the same part name, that's just fine.
(We also have a ::theme()
pseudo-element in the proposal that does automatically apply down into sub-components, for the rare times when that's exactly what you want to do.)
The one downside of ::part()
is that it only works for shadow DOM. If you wanted to use @apply
within your normal light-DOM page, you're out of luck. However, I'm okay with this. For one, @apply
isn't actually all that useful for light DOM uses - just using normal selectors does the job better. For two, this might encourage more usage of Shadow DOM, which I consider a good result - more encapsulation is more better. (Tho we really need to explore a simple declarative version of Shadow DOM as well, to make simple structural usage of it possible without having to invoke JS.) For three, within a light DOM page we can potentially do even more powerful stuff, like inventing a real mixin facility, or a selector-variables thing, or what-have-you.
There's plenty more space to experiment here, and while it does suck to lose a tool that you might have gotten excited about, @apply
really is just quite a bad idea technically. Let's solve these problems correctly. ^_^
Thanks for the write-up, Tab. It's really interesting to see the life cycle of a feature like this, complete from conception through to figuring out the issues to realising the original problem can be solved another way.
I think it's all too easy to get so attached to your own ideas that you ignore other options. It's an example to us all to see you acknowledge
apply
's shortcomings and discover a better way.Reply?
Could something like
::theme
be ported to the light-dom where a selector gives itself some identifiers for targeting?``` ::theme(some-ident) { color: var(--some-color); }
.some-selector { --some-color: red;
part: some-ident, some-other-ident; } ```
Reply?
Hi Tab. I like the idea of being able to create a shadow DOM declaratively. There are plenty of CSS authors who don't have a good handle on JS. With Edge on the way to supporting shadow DOM it looks like we'll have cross-browser support for this far sooner than custom elements. I never see anyone write about using shadow DOM for encapsulating styles on normal HTML elements (which is a shame as it sounds like shadow DOM could be useful all by itself). The example on MDN for styling is shadow.innerHTML += '<style>span { color: red; }</style>'; Is there currently a better way of styling if you're not using custom elements?
Reply?
What is the latest spec to read about this stuff? The shadow DOM spec has a red box pointing to the CSS Scoping Module Level 1 but that's pretty old (2014)
Reply?
There's no better way yet, no - that's the state of the art. I hope to encourage someone to work on declarative shadow DOM this year.
The most accurate specs on Shadow DOM are the DOM spec and the CSS Scoping ED.
Reply?
Seems to me all issues are merely implementation concerns and not an inherit issue of @apply. Doesn't the removal of @apply cause component-concept styling to be dependent on Javascript? For aren't web components non-existent apart from Javascript? (i.e. we can't construct them without registering elements through JS.) As far as I am concerned, a bigger problem is that I need Javascript to construct the DOM of a custom component. If we had, for example, something like
<template is="my-component> <!-- DOM here --> </template>
<my-component></my-component>
the browser client could do the registering required for DOM construction apart from Javascript, and when you inspect the page you would see DOM constructed. If we are interested in solving problems correctly, let us do away with requiring JS to register a custom component that its DOM be constructed.
I do not believe the usefulness of @apply can be seen by keeping our heads within the shadow DOM. I believe the light DOM is just as important. @apply was the closest thing to the idea of mixins in CSS that we've ever had. CSS pre-processors, like SASS, and others support mixins--their usefulness is recognized, and that is how I've been using them. Indeed, I wrote my CSS with @apply with the assumption that my mixins would not be overwritten but, rather, overruled. In other words,
--my-button: { /* complex styling */ };
button { @apply --my-button;
/* potentially other properties */ }
button:hover { /* overruled */ }
Reply?
First, "merely implementation issues" are nothing to sneeze at when developing tech, but second, you're wrong, these are fundamental problems with the technology as described.
Note, for example, that you interpreted @apply as something completely different to what it actually is. ^_^ The tech that I actually wrote out a spec for and tried to make work (based on the custom properties mechanism) is what's problematic. There's likely something else in this space that will work much better, that actually does some notion of first-class mixins, but that's a substantially larger task. One of the appeals of @apply was that it didn't require too much new underlying mechanisms.
(Note as well that @apply was, at best, a syntax equivalent to zero-argument Sass mixins only; mixins with arguments would have required a different mechanism entirely. This really wasn't a very good approach, y'all.)
Reply?
SASS/SCSS has mixins for a reason. Tailwind has components and @apply
I really was wondering what is the correct way to mix Tailwind classes and be readable without repeating yourself.
So the concept of mixin is very interesting.
Example:
.red { color: red; }
.my_section h1 { include .red }
This would be fun to use.
Reply?