csswg-drafts icon indicating copy to clipboard operation
csswg-drafts copied to clipboard

[css-variables-2] Custom units as simple variable desugaring

Open tabatkins opened this issue 2 years ago • 18 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?

tabatkins avatar Jun 16 '22 19:06 tabatkins

I tried to find reasons why that would be annoying to implement, but can't think of any. So ... sounds good?

andruud avatar Jun 17 '22 10:06 andruud

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;
}

nuxodin avatar Jun 17 '22 11:06 nuxodin

That however, is complicated, because it adds new and arbitrary dependencies between things that previously couldn't depend on each other.

andruud avatar Jun 17 '22 11:06 andruud

This also opens up the way to supporting new units through a polyfill:

  1. Alias the original unit to its -- counterpart, i.e.

    @property --brm {
      syntax: "<length>";
      initial: 1brm; /* browsers with support will use this */
      inherits: true;
    }
    
  2. Have a JS polyfill calc + set the initial value in case of no support

  3. Use the custom unit throughout the code

    height: 100--brm;
    

bramus avatar Jun 17 '22 14:06 bramus

Received a reply on Twitter where the author noted that they find the syntax confusing

6--fr doesn’t read like 6fr to me, it reads like 6 - -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.

bramus avatar Jun 17 '22 14:06 bramus

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.

faceless2 avatar Jun 17 '22 14:06 faceless2

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.

tabatkins avatar Jun 17 '22 15:06 tabatkins

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/w3c/csswg-drafts/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

css-meeting-bot avatar Jun 29 '22 16:06 css-meeting-bot

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.

scottkellum avatar Jun 29 '22 17:06 scottkellum

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 been var.

@property --fluid {
  syntax: "<length>";
  initial: clamp(1rem, 1vw * 1var, 1rem * 1var);
  inherits: true;
}

h1 {
  font-size: 4--fluid;
}

scottkellum avatar Jun 29 '22 18:06 scottkellum

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).
  • 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 to calc(1rem / (.25 * 12)).

jonathantneal avatar Jun 29 '22 18:06 jonathantneal

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.

tabatkins avatar Jun 29 '22 19:06 tabatkins

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.

tabatkins avatar Jun 29 '22 19:06 tabatkins

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.

scottkellum avatar Jun 29 '22 20:06 scottkellum

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; }

moniuch avatar Jun 30 '22 10:06 moniuch

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?

johannesodland avatar Jul 04 '22 06:07 johannesodland

There is not currently such an issue. Feel free to open one. ^_^

tabatkins avatar Jul 11 '22 22:07 tabatkins

Pro: Shortens the syntax for multiplying a variable value with a number Con: Harder to read

I don't think this proposal is worth it yet.

nmn avatar Oct 11 '22 23:10 nmn