[css-variables-2] Custom units as simple variable desugaring
tabatkins opened this issue · 24 comments
I've had "custom units" on the back burner of my mind for years now, and never got around to working on them - they had enough question to answer that it seemed exhausting. This morning, tho, I saw a tweet by @jonathantneal exploring the idea of making them just be sugar over normal custom property usage, and... I think I love it?
The example they had:
:root { --rs: calc(1rem * .25); }
.usage { padding-inline: 4--rs; }
/* desugars to */
.usage { padding-inline: calc(4 * (var(--rs))); }
That is, if we see a "custom unit" (aka a dimension whose unit is a dashed-ident), we just treat it as a variable reference (triggering the normal behavior of using a variable - the property is assumed valid, etc), and expand it at variable-resolution time into exactly that calc - given N--foo
, produce calc(N * (var(--foo)))
.
I think this was problematic in the past because there were questions of initial value, resolution-time behavior, etc., but afaict those are all answered now by just using a registered custom property. That is, if you've registered your property/"unit" as a <length>
, then you can set it like --unit:1.2em;
and it'll resolve that into an absolute length immediately, inheriting as a px length. (Or, if you do want the unit to resolve at point of use instead of point of definition, just leave it unregistered, or registered with a *
grammar.)
Plus, a property registration suffices to fully define the "unit" immediately, since you can just set its size in the initial
descriptor. But it also leaves open the possibility of redefining it on the fly like any other custom property, if needed.
So the above example could instead be set up as:
@property --rs {
syntax: "<length>";
initial: .25rem;
inherits: true;
}
.usage { padding-inline: 4--rs; }
This still leaves the door open to do a more full-featured custom unit thing later if we want; full-featured "registered custom units" would just override the variable-based behavior instead. But for now I think this does 95% or more of what we want custom units to do, in a flexible and readable manner.
Thoughts?
I tried to find reasons why that would be annoying to implement, but can't think of any. So ... sounds good?
Like the Idea!
I always think that the behaviour of custom properties should (possibly) not differ from normal properties.
That's why I'm throwing these lines into the room:
.el {
padding-top: 2rem;
padding-bottom: 2padding-top;
}
That however, is complicated, because it adds new and arbitrary dependencies between things that previously couldn't depend on each other.
This also opens up the way to supporting new units through a polyfill:
-
Alias the original unit to its
--
counterpart, i.e.@property --brm { syntax: "<length>"; initial: 1brm; /* browsers with support will use this */ inherits: true; }
-
Have a JS polyfill calc + set the initial value in case of no support
-
Use the custom unit throughout the code
height: 100--brm;
Received a reply on Twitter where the author noted that they find the syntax confusing
6--fr
doesn’t read like6fr
to me, it reads like6 - -fr
and now I’m wondering what fr resolves to, before realizing what this means
I think it's a matter of getting used to it. Once you know how it works, it's OK to read imo.
To be honest I was going to make the same point. calc(4 * var(--x))
I can look at and understand without a spec - not so with4--x
. I expect it's even worse if you don't know the details of the CSS tokenizer.
Sure I can get used to it, but my first reaction is it's a slightly shorter but considerably less intuitive alternative syntax for something we can already do - increasing cognitive load to save a few characters. Clarity wins over brevity for me, so I don't think it's an improvement.
Yeah, readability is an issue, but we had the same concerns about custom properties in general at first, and it seems like that was indeed fine once your eyes got used to it. (After all, in CSS spaces are required around subtraction anyway.) At least we're guaranteed these aren't confusable with built-in units 😃
However, I don't believe this is "slightly shorter" - it's hugely shorter. In raw characters it's a difference of 14 characters per use, including two pairs of parentheses. It's also not immediately distinguishable from more complex math (particularly when embedded in a larger math expression), so you have to parse it manually and realize it's just scaling a variable. That's a big cognitive, visual, and typing load for something that's meant to extremely simple and common.
The CSS Working Group just discussed custom units as variables
, and agreed to the following:
RESOLVED: Start new draft of variables-2 and add custom units as described here
The full IRC log of that discussion
<TabAtkins> Topic: custom units as variables<TabAtkins> github:
<TabAtkins> github: https://github.com//issues/7379
<fantasai> TabAtkins: A week or two ago Jonathan Neal had a suggestion in Twitter, just doing on pre-processor side, about a way to finally address custom units
<fantasai> TabAtkins: where you want to set some length and use multiples of it
<fantasai> TabAtkins: used all over design systems, but doing today with variables is awkward
<fantasai> TabAtkins: have to explicitly use a calc and multiply, quite a lot of writing for what is ~3ch for pre-defined units
<fantasai> TabAtkins: suggestion is to treat custom units just as variables
<fantasai> TabAtkins: so if have number with --unit, this is a variable reference
<fantasai> TabAtkins: triggers same stuff, but resolve it into the appropriat ecalc
<fantasai> TabAtkins: so 3--unit would become calc(3 * var(--unit))
<fantasai> TabAtkins: can set up lengths with @Property rule
<fantasai> TabAtkins: can have some control about whether absolute links are resolved as time of use or ?? by setting as <length> or not
<fantasai> TabAtkins: seems to solve most problems of custom units
<fantasai> TabAtkins: but doesn't prevent us from doing something more complicated using registration
<fantasai> TabAtkins: later
<fantasai> TabAtkins: This allows more readable usage for design systems, not complicated on implementation side
<fantasai> TabAtkins: one of our implementers was looking for implementations problems and couldn't find any
<fantasai> TabAtkins: Thoughts?
<bramus> q+
<dbaron> +1, sounds simple and valuable
<fantasai> astearns: ?? comment that they didn't find it particularly readable
<florian> haven't spent much time thinking about it, but seems reasonable (and terse)
<astearns> s/??/faceless/
<fantasai> astearns: and hides complexity that maybe should be expressed
<fantasai> faceless: [...]
<astearns> ack bramus
<fantasai> faceless: but no objection
<miriam> q+
<fantasai> ???: Would allow ability to polyfill new units as well, e.g. define --brm and use your new custom unit code to polyfill it
<fantasai> ???: seems really nice
<dbaron> s/???/bramus/
<dbaron> s/???/bramus/
<fantasai> astearns: For browsers that do not support the new unit, what happens when you use the custom property
<fantasai> bramus: browser would support the real unit, which you have just made your custom unit as an alias, and for browsers that don't support it you can give them the fallback
<astearns> q?
<fantasai> TabAtkins: if we had ability to do parse-time rejection of declared properties... but need JS for that
<astearns> ack miriam
<fantasai> miriam: I think this would help solve cases where we would need to remove units from a value, e.g. viewport width ppl want to use them in a unitless place like line-height, but this wouldn't help with that case, right?
<jensimmons> q+
<fantasai> TabAtkins: Right, that wouldn't help. What you need is the unit math in the spec to be implemented.
<astearns> ack jensimmons
<fantasai> jensimmons: I really love this, just wish the -- doesn't need to be there
<fantasai> jensimmons: I do think it would be helpful to get some feedback, can think of 2-3 ppl working on responsive typography be good to get their feedback
<fantasai> jensimmons: they're using mix of absolute and relative sizing in setting type sizes etc.
<fantasai> jensimmons: could be very powerful
<fantasai> TabAtkins: That's one of the major use cases, so would be great to get their feedback
<lea> I love how general this is, +1 from me too
<fantasai> astearns: Sounds like this is something we should pursue
<fantasai> TabAtkins: Where to put it? Variables 1 is fairly mature, so suggest starting Variables 2
<fantasai> astearns: Makes sense to me
<lea> +1 for variables-2
<fantasai> +1
<fantasai> astearns: Proposed resolution is to start variables-2, with this as the feature to add
<fantasai> astearns: any objections?
<fantasai> RESOLVED: Start new draft of variables-2 and add custom units as described here
<fantasai> astearns: Let's keep this issue open for a little bit, so Jen you can get some additional people to give feedback
As defined here this might be helpful for design system helpers like [the 8pt grid](The Comprehensive 8pt Grid Guide. Start your UI project right with this… | by Vitsky | The Startup | Medium).
With the ability to inject the custom unit increment into the calc function you can start to do more like adding modular scales to CSS, or creating a complex clamp()
function.
What if it looked something like this:
@unit --scale { /* Changing this from @property to something more specific avoids the need for initial */
syntax: "<length>";
value: --value; /* This will be the input value */
formula: calc(1rem * pow(1.5, var(--value)));
}
h1 {
font-size: 4--scale; /* 5.0625rem */
}
You might also be able to do this to simplify clamp functions:
@unit --fluid {
syntax: "<length>";
value: --value; /* This will be the input value */
formula: clamp(1rem, 1vw * var(--value), 1rem * var(--value));
}
h1 {
font-size: 4--fluid; /* easier to implement clamp function */
}
There is more opportunity than syntax sugar for design systems 8pt grid that I think is worthy of exploration.
Adding a note from @jonathantneal via Twitter where he talks about adding some of the above functionality. I think it’s needed as it greatly expands the utility of custom units.
When I first pitched the approach, it did suppose a special unit to signify the original number.
It supposed an
x
unit, but that was taken.
Perhaps it should have beenvar
.
@property --fluid {
syntax: "<length>";
initial: clamp(1rem, 1vw * 1var, 1rem * 1var);
inherits: true;
}
h1 {
font-size: 4--fluid;
}
To expand just a little on @scottkellum’s comment, I was wondering if we could utilize a ‘nesting’ unit for math, similar to how we might utilize a nesting selector for rules.
- This would be a unit for a new
<dimension>
. - The specific naming of the unit —
var
,n
,whatever
— is bike-shedding. - Outside Custom Units, it would resolve to a unit-less
<number>
.- e.g.
--step: calc(1rem / .25n)
would be equivalent to--step: calc(1rem / .25)
.
- e.g.
- Within Custom Units, it would resolve to the multiple of its own number and the Custom Unit’s number.
- e.g.
12--step
would be equivalent tocalc(1rem / (.25 * 12))
.
- e.g.
This proposal leaves open the possibility for a more full-featured custom units proposal in the future (if you registered a custom --unit it would just win over a --unit property), but I'm explicitly not trying to do anything more complicated than simple variable substitution right now.
This is because simple variable substitution solves the 90% case, afaict, and getting any more complex starts to get really complicated. For example, if you do calc(1--unit + 2--unit)
, is the result equivalent to 3--unit
? In all the examples given here, it absolutely is not, which implies that you're not defining a "unit" at all, but rather a custom function of some kind. That's also something we should do (and has also been on my back burner for a long time, including a simple declarative substitution-based approach like what you're suggesting), but it's separate from the idea of a "unit", which needs to be a vector.
Put a slightly different way - the approach I'm taking (just multiply the value by the substituted variable) works for everything that acts like a "unit" should - can be added together, multiplied, etc. If you're wanting something that doesn't work under this approach, you're not wanting a "unit", but something more complex, and we should address that with a different method.
For example, with your --fluid example, that can mostly be done as:
@property --fluid {
syntax: "<length>";
initial: min(1vw, 1rem);
inherits: true;
}
This does not enforce the "no fluid lengths are ever allowed be less than 1rem
" condition in your version, because that's not something you can reasonably apply at the individual-value level - it means that, say, .01--fluid
and calc(1--fluid / 100)
are very likely not equal (the first is much larger, instead). What you want, instead, is a way to clamp a value at an author-specified time, in a short readable fashion.
Like, pretend for a moment that we have simple custom functions, like:
@custom-function --fluid(--value) {
arg-syntax: --value "<number>";
result: clamp(1rem, var(--value) * 1vw, var(--value) * 1rem);
}
This could work - you can say width: --fluid(5);
and get a reasonable result, and importantly, there is no expectation that calc(--fluid(1) + --fluid(2))
is equivalent to --fluid(3)
, or that calc(--fluid(1) / 100)
is equivalent to --fluid(.01)
. These expressions are reasonable to be different values, so you don't have the same issues as a "unit" does.
Thanks @tabatkins this makes sense!
When I think of modular scales I think of them like exponential rulers that map to my mental model of units but I can see how units need more interoperability than that mental model provides.
I like this custom function idea.
Not something against, but it will look a bit odd :)
:root { --fr: calc(1rem * .25); }
.usage { grid-template-columns: 1--fr 1--fr 1--fr; }
Custom units as syntactic sugar would be great. Custom functions would be awesome. It would allow us to build our own functions such as --progress
to calculate a fluid ratio percentage to use in the new mix()
function.
@custom-function --progress(--current, --min, --max) {
arg-syntax: --current "<length>", --min "<length>", --max "<length>";
result: clamp(0%, 100% * (var(--current) - var(--min)) / (var(--max) - var(--min)), 100%);
}
:root {
--fluid-ratio: --progress(100vw, 375px, 1920px)
}
.usage {
font-size: mix(--fluid-ratio, 1rem, 1.25rem)
}
Custom functions would allow us to write more readable css with less repetition.
@tabatkins Do you know if there are there any issues tracking custom functions?
There is not currently such an issue. Feel free to open one. ^_^
Thought of another use-case that I would use these for:
- I like having
rem
as a reference to the user's chosen font-size preference, which means not adjusting the font-size on the root element. - I also like having
rem
as a site-specific font size that's determined relative to that default.
I can't have both. The only way to have option 1 is to give up on option 2. At that point I have to use custom props & calc in order to reference a site-specific base font size. That size is often a clamp function combining rem
and vi
units, established on the body element to avoid overtaking the rem
. Using custom units would make this feel much more natural:
body {
--bem: clamp(1rem, 0.9rem + 1vi, 1.5rem);
font-size: var(--bem);
}
h1 {
/* font-size: calc(2 * var(--bem)); */
font-size: 2--bem;
}
.small {
font-size: 1rem;
}
Thought I’d mention the specific web components / design systems challenge discussed in #7613 hem
: font-relative unit, relative to host element font-size – this is a clear use case for custom units.
If you want to use font-relative values in a Web Component for a UI element meant to be reusable across many sites,
rem
only works when the size of the root element hasn’t been customised unexpectedly (so unreliable in web components).em
works but its compounding nature is too hard to keep track of (particularly when also used for spacing, not just font size).
So – with custom units, I’d introduce a either a host-relative hem
or container-relative cem
(as proposed in this comment):
/* In web components scoped styles */
h2 {
/* font-size: calc(2 * var(--cem)); */
font-size: 2--cem;
}
/* In parent page styles */
html {
font-size: 62.5%;
}
my-component {
--cem: 1.6rem;
}
I do not have a personal interest for this feature (I came across this issue to report the following potential oversights) but it seems a bit unfortunate to me to introduce a slight inconsistenty in the way a custom variable is referenced: one would be able to specify width: 1--fem
but not color: --primary
.
The oversights/suggestions:
<custom-property-name>
may be preferable to<dashed-ident>
to avoid confusing values likemargin: 2-- 1px
- whitespaces probably need to be disallowed between
<dimension>
and the custom unit - the spec should define that
/**/
must be inserted when serializing a specified value - the spec should define that a property including a custom unit is valid at parse time
whitespaces probably need to be disallowed between
<dimension>
and the custom unit
1--fem
is a single <dimension>
token with value 1
and unit --fem
.
It is not a <number>
token with value 1
and a <ident>
token with value --fem
.
right?
margin: 2--1px
would indeed be confusing, but I think authors can always find ways to name things confusingly.
even worse : 2--1
Yeah, a <dimension-token>
is a single token, not <number-token> <ident-token>
.
So width: 1 --fem
would be invalid just like width: 1 px
or width: 1/**/px
.
And the spec already says
[A variable unit reference] has identical effects and restrictions to using
var()
If a property contains one or more
var()
functions, and those functions are syntactically valid, the entire property’s grammar must be assumed to be valid at parse time.
But I agree that the spec should refer to <custom-property-name>
instead of <dashed-indent>
.
Right, sorry! The unit is parsed as an an ident sequence (string) and it must match <dashed-ident>
(or <custom-property-name>
, as suggested).
<custom-property-name>
may be preferable to<dashed-ident>
to avoid confusing values likemargin: 2-- 1px
Sure, that's correct. The name has to be a custom property name anyway, since it's referencing a custom property.
- whitespaces probably need to be disallowed between
<dimension>
and the custom unit
Others have already said this - the custom unit is part of the <dimension>
already. It's not a separate identifier.
- the spec should define that
/**/
must be inserted when serializing a specified value
Rather, it can't, because this isn't two tokens.
- the spec should define that a property including a custom unit is valid at parse time
Yes, it'll be defined as just a fancy way to have a variable reference, which gives this behavior by default.