w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.5k stars 664 forks source link

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

Open tabatkins opened 2 years ago

tabatkins commented 2 years ago

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?

andruud commented 2 years ago

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

nuxodin commented 2 years ago

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;
}
andruud commented 2 years ago

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

bramus commented 2 years ago

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 commented 2 years ago

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.

faceless2 commented 2 years ago

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.

tabatkins commented 2 years ago

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.

css-meeting-bot commented 2 years ago

The CSS Working Group just discussed custom units as variables, and agreed to the following:

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
scottkellum commented 2 years ago

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 commented 2 years ago

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;
}
jonathantneal commented 2 years ago

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.

tabatkins commented 2 years ago

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 commented 2 years ago

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.

scottkellum commented 2 years ago

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.

moniuch commented 2 years ago

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; }
johannesodland commented 2 years ago

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?

tabatkins commented 2 years ago

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

mirisuzanne commented 1 year ago

Thought of another use-case that I would use these for:

  1. 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.
  2. 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;
}
thibaudcolas commented 1 year ago

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,

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;
}
cdoublev commented 1 year ago

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:

romainmenke commented 1 year ago

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

Loirooriol commented 1 year ago

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

cdoublev commented 1 year ago

Right, sorry! The unit is parsed as an an ident sequence (string) and it must match <dashed-ident> (or <custom-property-name>, as suggested).

tabatkins commented 1 year ago
  • <custom-property-name> may be preferable to <dashed-ident> to avoid confusing values like margin: 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.