Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Declarative custom functions #7490

Open
johannesodland opened this issue Jul 12, 2022 · 27 comments
Open

Declarative custom functions #7490

johannesodland opened this issue Jul 12, 2022 · 27 comments

Comments

@johannesodland
Copy link

johannesodland commented Jul 12, 2022

@tabatkins mentioned the idea of declarative custom functions in a comment on the custom units issue:

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 seems too good to not follow up on. Custom functions would make it possible to write more readable css with less repetition.

Background

We can already increase readability and reduce repetition of expressions by using function like custom properties, as @mirisuzanne describes in her excellent article on CSS Custom Properties In The Cascade. This is available in browsers today.

There are however limits to what we can do with function-like custom properties, and there are issues with their readability.

Example
The following expression returns 0% at a minimum viewport size of 375px, and 100% at a maximum viewport size of 1920px.

clamp(0%, 100% * (100vw - 375px) / (1920px - 375px), 100%)

We could give this expression semantic meaning and reuse it almost everywhere by declaring it as the value of a custom property:

* {
    --min-width: 0px;
    --max-width: 2560px;
    --fluid-ratio: clamp(0%, 100% * (100vw - var(--min-width)) / (var(--max-width) - var(--min-width)), 100%);
}

This custom property could then be used with "arguments" where needed:

p {
  --min-width: 375px;
  --max-width: 1920px;
  font-size: mix(var(--fluid-ratio), 1rem, 1.25rem);
}

This is great, and should work in browsers today (except for divide by unit, that is specified in css-units-4 but not supported yet).

There are some limitations to this approach:

  1. It's not possible to read from the rule that --min-width and --max-width are arguments for --fluid-ratio, nor that --fluid-ratio is a function like custom property.
  2. We would run into trouble if we wanted to reuse the same "function" with separate parameters.
p {
  /* This would not work. (Arguably a constructed example) */
  --min-width: 375px;
  --max-width: 1920px;
  font-size: mix(var(--fluid-ratio), 1rem, 1.25rem);

  --min-width: 375px;
  --max-width: 700px;
  padding: mix(var(--fluid-ratio), 1rem, 2rem);
}

Proposal

Updated based on feedback. The original text is available below for context.

Add syntax for declarative custom functions. The syntax could follow the example from @tabatkins comment with an at-rule to declare the function and the syntax of its arguments:

@custom-function --fluid-ratio(--min-width "<length>", --max-width "<length>") {
   result: clamp(0%, 100% * (100vw - var(--min-width)) / (var(--max-width) - var(--min-width)), 100%);
} 

This would work through regular var substitution with a slightly different handling of the arguments. The custom function could then be used anywhere a var function can be used:

p {
  font-size: mix(--fluid-ratio(375px, 1920px), 1rem, 1.25rem);
  padding: mix(--fluid-ratio(375px, 700px), 1rem, 2rem);
}

As this is based on variable substitution it is not limited to math functions and can return any value. It could be used to return one of background patterns from @LeaVerou's pattern gallery:

@custom-function --checkerboard(--size "<length>") {
   result: linear-gradient(
        45deg,
        silver 25%,
        transparent 25%,
        transparent 75%,
        silver 75%
      )
      0px 0px / var(--size) var(--size),
    linear-gradient(
        45deg,
        silver 25%,
        transparent 25%,
        transparent 75%,
        silver 75%
      )
      calc(var(--size) / 2) calc(var(--size) / 2) / var(--size) var(--size);
}

.used {
  background: --checkerboard(32px);
}


There's an existing proposal for JS backed custom functions in Houdini. This proposal is related and would be a simple declarative version of that proposal, where in Houdini the substituted value would be provided by JS.









Original proposal text









### Proposal

Add syntax for declarative mathematical functions. The syntax could follow the example from @tabatkins comment with an at-rule to declare the function and the syntax of its arguments:

@custom-function --fluid-ratio(--min-width, --max-width) {
   arg-syntax: --min-width "<length>", --max-width "<length>";
   result: clamp(0%, 100% * (100vw - arg(--min-width)) / (arg(--max-width) - arg(--min-width)), 100%);
} 

The custom function could then be used where mathematical expressions can be used:

p {
  font-size: mix(--fluid-ratio(375px, 1920px), 1rem, 1.25rem);
  padding: mix(--fluid-ratio(375px, 700px), 1rem, 2rem);
}

There are probably many reasons why this would be annoying or impossible to implement, but hopefully there's smarter people than me out there that can come up with a way to solve custom functions.

@dbaron
Copy link
Member

dbaron commented Jul 12, 2022

I'd assume that the argument syntax validation would match what's done for @property. If that's the case, I don't immediately see anything that would make this particularly difficult.

One issue that might (but I'm not sure) be difficult (although maybe more for designing/specifying the feature than for implementing it) is determining when the functions are resolved. (Does it work like calc() and depend on what the arguments are?)

The syntax you propose seems generally reasonable, although there are various ways it could be changed. For example, I probably wouldn't do both of requiring -- for the arguments and requiring references to them be in an arg() function, but we probably do want at least one or the other, or something like them. (I think I'd lean towards not using the --, and keeping arg().)

There's also a question of whether this could interact with features like inherit, initial, or more advanced extensions like that proposed in #2864. (That is, whether the result could use those things.)

@bkardell
Copy link
Contributor

Somewhat related, maybe? depending on how you squint at it? w3c/css-houdini-drafts#857

@johannesodland
Copy link
Author

Somewhat related, maybe? depending on how you squint at it? w3c/css-houdini-drafts#857

Definitely. Thanks for pointing me to that issue 🙏🏼

I think the main difference was that this proposal was limited to mathematical or "pure" functions. They could be defined directly in css, and I hoped they could be easier to implement and polyfill than fully-fledged functions.

The houdini proposal is much more powerful and would allow you to back the custom functions with js.

I don't think there's a need for both custom mathematial functions, and proper/complex custom functions. Closing this issue, hoping that there will be a way to declare simple functions directly in css once the css-functions-api is drafted up.

@bkardell
Copy link
Contributor

fwiw, my intent wasn't to suggest that this issue should be closed, but rather to link up history and use cases I was aware of so that it's easy to keep it all in mind.

@dbaron
Copy link
Member

dbaron commented Jul 13, 2022

Yeah, I agree that this is worth keeping open in case we want to do this first.

@dbaron dbaron reopened this Jul 13, 2022
@tabatkins
Copy link
Member

One issue that might (but I'm not sure) be difficult (although maybe more for designing/specifying the feature than for implementing it) is determining when the functions are resolved. (Does it work like calc() and depend on what the arguments are?)

It would just be variable substitution, nothing more, nothing less. (Well, something more - we validate the arguments per the syntax as a help to the author, identical to registered properties. So still "just" variable substitution.)

The fact that the OP example is a math function has nothing to do with the custom function part, it just means that when it var-substitutes you get left with a math function, same as --foo: clamp(...); width: var(--foo);. This is just var substitution with some "time-of-use" subs (the arguments) rather than "time of definition" subs (what you'd get today if you tried to do something similar with custom properties).

[stuff about arg()]

My suggested syntax just used var() and normal custom property names for simplicity, and to underscore the fact that this is just var substitution under the covers. It also means you get the same var() behaviors you'd get elsewhere, like the ability to specify fallback values (useful if the argument doesn't match its declared syntax, as it'll sub the guaranteed-invalid value otherwise, per standard variable rules).

That said, it's possible we may want to do smarter stuff for the arguments, in which case it would make sense to have a different function. I just can't come up with any "smarter stuff" yet.

(That is, whether the result could use [initial, inherit, etc].)

That's the nice thing about making it just a slightly special case of variable substitution - we've already got answers to those questions. ^_^

Somewhat related, maybe? depending on how you squint at it? w3c/css-houdini-drafts#857

Definitely related. This would be a simple declarative version of the more full-featured, JS-driven Houdini feature linked there. I imagine they'd live in the same namespace and registered Houdini functions override @function declarations of the same name. They should already have similar behavior in many respects; that is, the Houdini feature will also be essentially just variable substitution, but with JS providing the substitution value.

@johannesodland johannesodland changed the title Custom mathematical functions Declarative custom functions Jul 22, 2022
@johannesodland
Copy link
Author

I tried to update the proposal text based on the feedback and my (hopefully) improved understanding:

  • This would work through var substitution. I updated the text to reflect that.
  • It is not limited to math functions. I updated the title and added an example.
  • It is related to the Houdini issue. Added some text and a link.
  • Using custom syntax for args is not needed. Replaced it with var() functions.

The original text is left in an expandable details element for context.

@johannesodland
Copy link
Author

johannesodland commented Jul 23, 2022

Do we need initial argument values for when the function is referenced with too few arguments?

I think it would be sufficient if the initial values of arguments always are guaranteed-invalid value?

Providing an argument with wrong syntax would also result in a guaranteed-invalid value at compute time, so I guess it would be fine if both "missing argument" and "wrong argument" resulted in the same guaranteed-invalid value?

example: initial values for arguments
@custom-function --double-length(--length) {
   arg-syntax: --length "<length>";
   /* could be ambiguous with multiple arguments (i.e. one is "<length>#") */
   arg-initial-values: 0px; 
   result: calc(2 * var(--length, 20px));
} 

.use {
   height: --double-length(); /* 0px */
   width: --double-length(red); /* 40px */
}
example: missing arguments results in guaranteed-invalid values
@custom-function --double-length(--length) {
   arg-syntax: --length "<length>";
   result: calc(2 * var(--length, 20px));
} 

.use {
   height: --double-length(); /* 40px */
   width: --double-length(red); /* 40px */
}

@johannesodland
Copy link
Author

Is there a need for a result syntax and a fallback value?

If declarative custom functions were to be used in animations, the result should have a declared syntax.

In addition, one should probably be able to provide a fallback value if the result expression is invalid.

@custom-function --flat-color(--color) {
   arg-syntax: --color "<color>";
   result: var(--color), var(--color);
   result-syntax: "<color>#";
   fallback: red, red;
} 

.valid-use {
   background: linear-gradient(--flat-color(green)); /* green */
}

.invalid-use {
   background: linear-gradient(--flat-color()); /* red */
}

Would a single space be allowed as an argument, similar to a space in a custom property declaration?

--valid-declaration: ; /* single whitespace */

@custom-function --identity(--input) {
   arg-syntax: --input "*";
   result: var(--input);
}

.use {
   --valid-declaration: ; /* single whitespace */
   --also-valid-declaration: --identity( ); /* single whitespace */
   --invalid-declaration: --identity(); /* no argument, guaranteed-invalid */
}

If so, I guess this could be used to create an --if-else function using the space toggle trick (not that we want to do this):

@custom-function --if-else(--condition, --result-if-true, --result-if-false) {
   arg-syntax: --condition "*", --result-if-true "*", --result-if-false "*";
   result: var(--condition) var(--result-if-true);
   result-syntax: "*";
   fallback: var(--result-if-false);
} 

.use-with-true-condition {
   background: --if-else( , green, red); /* green */
}

.use-with-false-condition {
   background: --if-else(initial, green, red); /* red */
}

@johannesodland
Copy link
Author

Another question, sorry for the spam:

Would the initial value of result be the guaranteed-invalid value?

So that you could declare a function that always returned invalid?

@custom-function --invalid() {
   result: initial;
   /* 
   if there is a return-syntax, that would probably have to be the universal syntax definition 
   to skip fallback and allow result to return the initial `guaranteed-invalid value`*/
   result-syntax: "*"; 
} 

/* So that this */
.with-custom-function {
   some-prop: --invalid(); /* Guaranteed invalid value */
}

/* Would be equal to this */
.with-custom-prop {
  --invalid: initial;
  some-prop: var(--invalid); /* Guaranteed invalid value */
}

@johannesodland
Copy link
Author

Another question regarding argument syntax. (sorry).

Is there anything stopping us from declaring the syntax inline with the arguments?

/* Could we do this (inline) */
@custom-function --sum(--argument1 "<number>", --argument2 "<number>") {
   result: calc(var(--argument1) + var(--argument2));
} 

/* Instead of this? (separate syntax declaration) */
@custom-function --function(--argument1, --argument2) {
   arg-syntax: --argument1 "<number>", --argument2 "<number>";
   result: calc(var(--argument1) + var(--argument2));
} 

@tabatkins
Copy link
Member

Do we need initial argument values for when the function is referenced with too few arguments?

As you note, just ensuring that unpassed arguments produce invalid variables suffices, since you can use variable fallback to supply the default value then. It might still be useful to have a default value expressed more declaratively, purely as an authoring convenience.

Is there a need for a result syntax and a fallback value?

No, a result syntax isn't needed. Custom properties need a syntax to enable animation of the properties, but you don't need to do anything special to enable animation of a value with a var() in it, and this is equivalent to a var().

A fallback used when the passed arguments don't match their declared syntaxes makes sense! And if no fallback is specified, it just operates normally with those arguments resolved to the invalid value, so you can handle them with fallback if desired.

Would a single space be allowed as an argument, similar to a space in a custom property declaration?

Note that the distinction between --foo:; and --foo: ; was an accident of a clumsily written property grammar. The current spec correctly doesn't distinguish between them. So both of your examples should have identical behavior. (And in either case, the value wouldn't match the syntax unless it was "*" or ended with a ?.)

Would the initial value of result be the guaranteed-invalid value?

It would be a required declaration, so there wouldn't be an initial value. (And declarations don't accept the CSS-wide keywords unless they're specified to.)

You can still make a function that's guaranteed to be invalid in various ways; for example, writing an invalid var() function.

Is there anything stopping us from declaring the syntax inline with the arguments?

Nope, and that's indeed probably better.

@jimmyfrasche
Copy link

Do the arguments need the -- prefix?

@tabatkins
Copy link
Member

You reference the argument value via var(), so yeah they need to obey the same naming restrictions as normal var() usage, and effectively be <custom-property-name>s.

@jimmyfrasche
Copy link

As I understand it, that naming restriction is in place because custom properties are declared in rule blocks alongside regular properties so the prefix is necessary to avoid collisions. The formal parameters are not declared in rules so there's no such collisions to avoid. If the issue is the definition of var syntax, couldn't that be updated at the same time? The ---less vars could only show up in function bodies so I don't see any point where it could cause issues in practice where it wasn't already an error.

@tabatkins
Copy link
Member

Conditionally-valid syntax isn't great API design, in general. If it's excessively troublesome to use the generally-applicable syntax, it can be worthwhile making a specialized version, but in this case it's literally the two characters --.

It would also restrict our ability to evolve the var() syntax in the future; we couldn't rely on the custom-property name always being trivially distinguishable from other keywords, but would have to account for it being confusable. Dealing with that isn't hard (we'd just say the property name has to come first), but it's still a concern for something with very minimal benefit.

@brandonmcconnell
Copy link

brandonmcconnell commented Sep 24, 2022

Amazing discussion so far! It's exciting to see and keep up with all the changes coming.

I've been giving quite a bit of thought to custom CSS function defs this past week before @tabatkins informed me yesterday that this thread already existed (thanks btw Tab!). It's awesome to see that a lot of the same thoughts and considerations I had have already been voiced and taken into account here.

A few ideas I'd add which I'm hoping will stir continued fruitful discussion—

TL;DR (aka Table of Contents)

  1. Arg names, arg syntaxes, and function return syntax before def block { }
  2. @return rule instead of result value
  3. Intermediate value declarations
  4. Intermediate logic
  5. Recursion
  6. Alternative invocation syntax (passing args by name vs. insertion order)

1. Arg names, arg syntaxes, and function return syntax before def block { }

As @johannesodland pointed out, it could be advantageous to include the arg names and syntaxes in the opening line declaring and naming the function. I also think it would help to define the return syntax there unless that can be done implicitly, in which case it can be omitted altogether.

@custom-function --function-name(--arg "<number>") "<string>" { /* def block */ }
                                           │            │
                       /* arg syntax */ ───┘            └─── /* return syntax, if not implicit */

2. @return rule instead of result value

The justification for using a @return rule instead of a result property lies in functions being intrinsically logical. Using @return allows for there to be multiple @return rules within a single function definition, thereby allowing a function to "short-circuit" and return early if certain conditions are met employing an @if statement (already being spec'd, more commonly known thus far as @when).

@custom-function --return-same(--arg "*") "*" {
  @return var(--arg);
}

** See point no. 4 below for more thoughts and notes on the use case for logical expressions inside functions defs

3. Intermediate value declarations

Most/all of the examples I see here contain all of the function's logic within the result property. This could be limiting, and by allowing custom properties to be exposed and manipulated within the function def, I think we could add a lot of logic-oriented value to CSS functions.

@custom-function --func-name(--arg "<string>") "<string>" {
  --prefix: "Title: ";
  @return var(--prefix) var(--arg);
}

4. Intermediate logic

In addition to adding/modifying property values within the function definition, I also see it as vital that we can do so conditionally. This both allows to set values intermediately and return conditionally, employing a short-circuit pattern.

Logically intermediate values

@custom-function --func-name(--arg "<string>") "<string>" {
  @if media(width < 500px) {
    --prefix: "(";
    --suffix: ")";
  }
  @return var(--prefix, "") var(--arg) var(--suffix, "");
}

Logically intermediate @return

@custom-function --show-conditionally(--arg "<string>", --min-screen: "<length>": 500px) "<string>" {
  @if media(width < var(--min-screen)) {
    @return "This data is intended for screen sizes above " var(--min-screen);
  }
  @return var(--arg);
}

There are simpler ways to express this function—no doubt—but it suffices as a simple example, I think.

5. Recursion

Many of the points here can be expressed in an example demonstrating recursion, which I think to be a key example of the importance of logic use within a CSS function def.

@custom-function --repeat(--string "<string>", --times "<number>", --delimiter "<string>": "") "<string>" {
  /* @if should be able to handle mathematical comparisons */
  @if (var(--times) > 0) {
    --string: var(--string) var(--delimiter) --repeat(var(--string), var(--times) - 1, var(--delimiter));
  }
  @return var(--string);
}

And breaking down a couple of vital points in that recursive example:

--string: var(--string) var(--delimiter) --repeat(var(--string), var(--times) - 1, var(--delimiter));
                                            │                                 │
                /* same function name */ ───┘ /* math in arg (w/o calc) */ ───┘

@return var(--string);
    │
    └─── /* the returned value of each recursive iteration would pass its
            value back up to the --string property from which it was called */

6. Alternative invocation syntax (passing args by name vs. insertion order)

One more consideration I had is that in some cases, you may create a function with default values for all parameters and only need to change one property whenever the function is invoked, with no priority to one param over another. In this case, it would also help to support a syntax where the desired arg values could be passed into the function by prop name instead of in their insertion order.

Here is an example of how that might look, using the same recursion example from above:

Definition:

@custom-function --repeat(--string "<string>": "hello world", --times "<number>": 2, --delimiter "<string>": "") "<string>" {
  @if (var(--times) > 0) {
    --string: var(--string) var(--delimiter) --repeat(var(--string), var(--times) - 1, var(--delimiter));
  }
  @return var(--string);
}

Usage(s):

selector {
  /* to use all default param values */
  --value: --repeat();

  /* to pass args in insertion order */
  --value: --repeat("hello john");

  /* to pass args by prop name, to only set/override particular args,
    without having to repeat and reference default values */
  --value: --repeat({ --times: 5; });

  /* or in a more prettified fashion */
  --value: --repeat({
    --times: 5;
    --delimiter: " ";
  });
}

This (the final pattern above) is a pattern I don't think we've seen in CSS yet, but I think it makes sense to add, especially in the case of functions. You'd essentially be passing a style block—or a definitions block, rather—of custom properties, whose names must match params used by the function. I don't think there'd need to be any checks/errors if a name is used which does exist as a param in the function, and that would be ignored.

It works almost identically to destructuring in JavaScript, though I think this syntax to be even simpler as it's fairly familiar to a standard CSS block.

This also begs the question—in my mind at least—if there'd be any value in adding a new type to CSS altogether for definition blocks like this and allowing custom properties to be set to them.

Expand this to see a shallow dive into the unrelated topic/idea of a definition-block value type ✨🤷🏻‍♂️

Definition block values, some random & trailing (but also relevant-ish) thoughts

Implementing definition block values would not be necessary for this use case, though doing so could take this suggestion a step further and support future development of features like mixins (I think everybody's still noodling that one) and maybe even an @extend rule.

That would mean the above example would be equivalent to this:

selector {
  --default-arg-overrides: {
    --times: 5;
    --delimiter: " ";
  };
  --value: --repeat(var(--default-arg-overrides));
}

I do see a lot of value in this too, but I'm sure that's a rabbit hole for another discussion, as that would also then beg a syntax for allowing "spreading", though CSS already naturally supports overriding previously set values of the same property, for example:

selector {
  color: red;
  color: blue; /* <-- blue will be used */
}

With that in mind, perhaps spreading could look like declaring the variable as its own single prop/value pair in place of styles, or flattening a number of defs blocks to form the final args object to pass to the function. 2 possible options I see—

Spreading definition blocks vs. Flattening definition blocks

Concept A: spread def block by using var() in place of prop/value pair

selector {
  --default-arg-overrides: {
    --times: 5;
    --delimiter: " ";
  };
  --value: --repeat({
    --delimiter: "-"; /* <-- by precedence, this will be overridden by spreading `--default-arg-overrides` */
    var(--default-arg-overrides);
    --times: 10; /* <-- this `--times` value will override the one spread via `--default-arg-overrides` */
  });
}

…or maybe, it would be simpler to allow passing multiple def blocks into a function, which CSS would then flatten into a single block to use as the final args passed to the function— the idea behind Concept B:

Concept B: no spreading needed, CSS flattens multiple def blocks into single args object

selector {
  --default-arg-overrides: {
    --times: 5;
    --delimiter: " ";
  };
  --value: --repeat(
    { --delimiter: "-"; }, /* <-- as before, gets overridden by spreading `--default-arg-overrides` */
    var(--default-arg-overrides),
    { --times: 10; }, /* <-- as before, this `--times` value will override the one spread via `--default-arg-overrides` */
  );
}

For what it's worth, my preference is B, strongly. I think it most closely resembles CSS's existing syntax and doesn't do anything too mind-numbing or syntax-breaking. It simply takes the would-be-newly introduced def-block type and passes multiple of them to a function.

** To reiterate, these def blocks are basically what you'd be passing to a function if using my suggested alternative syntax to pass args by name. The idea behind def blocks outside of that is just to segment them int their own type for reusability.

Placeholder selector syntax using var() w/ definition blocks instead of %

In terms of use with @extend, rather than requiring a new placeholder selector syntax similar to that of Sass's %, we could more simply extend a variable that contains a definition block. For example:

:root {
  --button-styles: {
    padding: 1rem 1.5rem;
    background: pink;
    border: 2px solid black;
    color: black;
  };
}
selector {
  @extend var(--button-styles); /* with or without `var(` ... `)` wrapping the custom property name */
}

This wouldn't take away from being able to extend existing class names also.

selector {
  @extend .primary-button */
}

It also would not suffice to replace mixins entirely, as I believe mixins to be far more sophisticated in nature, likely requiring much of the same logical depth I'm pushing for here.

@johannesodland
Copy link
Author

  1. Arg names, arg syntaxes, and function return syntax before def block { }
  2. @return rule instead of result value
  3. Intermediate value declarations
  4. Intermediate logic
  5. Recursion
  6. Alternative invocation syntax (passing args by name vs. insertion order)

I think the idea here is to solve declarative custom functions through regular var substitution, so some of these might be difficult to achieve. There is a more fully fledged custom function proposal in Houdini where the function will be defined in JS. I think that proposal would solve most of your needs.

There seems to be consensus for having arg names and syntax before the definition block, so I'll update the proposal to reflect this.

Intermediate values and recursion might be doable, but I think the experts will have to look into that :)

@LeaVerou
Copy link
Member

Is this proposal and w3c/css-houdini-drafts#857 mutually exclusive (under the same function syntax), or can they co-exist? It would be great if they could co-exist, for evolution as well, e.g. you start off with a custom function that is just syntactic sugar, then down the line realize you need JS, you can just switch to JS without having to rewrite all your code.
Or even use the CSS syntax as a fallback, until the JS loads!

@johannesodland
Copy link
Author

Is this proposal and w3c/css-houdini-drafts#857 mutually exclusive (under the same function syntax), or can they co-exist?

Hopefully :)

I closed the issue when I was made aware of w3c/css-houdini-drafts#857 but it was reopened by @dbaron opened after this comment:

Yeah, I agree that this is worth keeping open in case we want to do this first.

@tabatkins wrote later the same day:

This would be a simple declarative version of the more full-featured, JS-driven Houdini feature linked there. I imagine they'd live in the same namespace and registered Houdini functions override @function declarations of the same name.

@brandonmcconnell
Copy link

@johannesodland @LeaVerou @tabatkins @dbaron
I see tremendous value in extending CSS @custom-function rules to be more full-featured and not rely on JS.

I 100% agree that Houdini functions will be a huge advantage to CSS users. However, most instances I can imagine using CSS’s @custom-function would be styling-specific and not require extra JS scripting.

In my experience, it’s often very helpful to clearly divide styles from scripts when possible. Functions that are purely style-related can stay in the CSS and be fully capable, supporting conditional logic using native CSS if-statements, short-circuiting, recursion, and more, all without a JS counterpart as outlined in my previous comment.

I understand some of those features weren’t included in the original spec outlined in this issue, however, my vision—before knowing of this spec—and this spec are quite closely aligned, and I believe most/all features I mentioned is already possible using existing CSS syntax.

I also believe such features and enhancements would make CSS functions vastly more useful and desirable overall.

Per @LeaVerou’s comment, there could very well be used as fallback functions in some instances until the JS loads, but I also think these functions could stand strong on their own in many instances without needing JS.

@brandonmcconnell
Copy link

brandonmcconnell commented Sep 26, 2022

There is a more fully fledged custom function proposal in Houdini where the function will be defined in JS. I think that proposal would solve most of your needs.

UPDATE: I want to clarify— my main goal here in voicing my recommendations is evolving CSS itself and empowering CSS developers to build and use powerful functions with ease without needing to use JS if they so choose. Houdini is certainly the primary solution to the most complex use cases.

I'm certainly not suggesting CSS @custom-function cover all possible use cases, but if we can build CSS functions to be more powerful and flexible, I think we'd make them much more desirable to use for anyone. In many cases, it may be even simpler and more sensible to write a style-specific function in CSS rather than Houdini.

Even more important than utility cases, some of my recommendations such as using @return vs. result are more focused on the scalability of @custom-function rather than semantics. result works for the present use case of var substitution, but it could be harder t use for more logically-sophisticated use cases, whereas @return opens the door to better logic and short-circuiting and the future growth of @custom-function.


result vs. @return examples & reasoning

In short— using @return:

@custom-function (...) {
  @if (...) {
    @return 1; /* <-- this value gets used if the condition is true, per short-circuiting */
  }
  @return 2;
}

whereas, with result:

@custom-function (...) {
  @if (...) {
    result: 1;
  }
  result: 2; /* <-- this value ALWAYS gets used, per the cascade, even if the condition is true */
}

To support short-circuiting in this style, we're forced to use the @else clause, even if undesirable.

@custom-function (...) {
  @if (...) {
    result: 1;
  } @else {
    result: 2;
  }
}

For some use cases, this could seem a bit tedious, especially if you want to short-circuit before the bulk of the function's logic.

This…

@custom-function (...) {
  @if (...) {
    @return "";
  }
  /* LOTS of logic here */
}

…becomes…

@custom-function (...) {
 @if (...) {
   result: "";
 } @else {
   /* LOTS of logic here */
 }
}

Houdini vs. @custom-function examples & other notes

Here is an example of what an if-statement custom function might look like when composed with Houdini, pulled from the related proposal discussion

JavaScript (via Houdini)

class ConditionalEvaluation {
  static get genericTypeArguments() { return ['<T>']; }
  static get inputArguments() { return ['<boolean>', CSS.CustomFunction.lazyArgument('<T>'), CSS.CustomFunction.lazyArgument('<T>?')]; }
  static get returnType() { return '<T> | <null>'; }

  conditional([condition, ifTrue, ifFalse], styleMap) {
    if(!!condition) {
      return CSS.CustomFunction.evaluate(ifTrue);
    } else if (ifFalse) {
      return CSS.CustomFunction.evaluate(ifFalse);
    } else {
      return null;
    }
  }
}

registerCustomFunction("--if", ConditionalEvaluation, "conditional");

vs. the same example built using @custom-function

CSS @custom-function

@custom-function --if(--condition "<boolean>",  --ifTrue "<T>",  --ifFalse "<T> | <null>") "<T> | <null>" {
  @if (var(--condition)) {
    @return var(--ifTrue);
  }
  @return var(--ifFalse);
}

Here, the CSS implementation is just as explicit in terms of arg/return types (syntax) and less verbose.

Both can be invoked like this:

:root {
  /* storing boolean value in custom property */
  --is-dark-mode: media(prefers-color-scheme: dark);
}
selector {
  /* exclude `ifFalse` value to omit rule if `condition` is false */
  background-color: --if(var(--is-dark-mode), "black");

  /* boolean evaluation inline */
  --element-width: 50px;
  content: --if(var(--element-width) < 32px, "small", "large");
}

@brandonmcconnell
Copy link

brandonmcconnell commented Oct 11, 2023

A case for self()

Thinking through all the benefits of both declarative functions as well as the soonish-coming inherit() function, I think a self() function could be extremely useful to drive some styles based on other computed styles, without necessitating that all shared values flow through custom properties alone.

This would be extra useful in custom/declarative functions to derive styles from other values without needing to pass arguments at all in some cases.

One such example could be computing the natural nested border-radius for a descendant of another rounded element, which is essentially variable substitution already:

(expand/collapse source)
<parent>
  <child></child>
</parent>
parent {
  --br-tl: 30px;
  --br-tr: 48px;
  --br-br: 82px;
  --br-bl: 130px;
  --p-t: 20px;
  --p-b: 10px;
  --p-r: 26px;
  --p-l: 44px;
  border-radius: var(--br-tl) var(--br-tr) var(--br-br) var(--br-bl);
  padding: var(--p-t) var(--p-r) var(--p-b) var(--p-l);
}

child {
  border-radius: calc(var(--br-tl) - var(--p-t)) calc(var(--br-tr) - var(--p-r))
    calc(var(--br-br) - var(--p-b)) calc(var(--br-bl) - var(--p-l));
}

Using declarative functions and this new proposed self() function, this can be greatly simplified/abstracted:

(expand/collapse source)
@custom-function --get-nested-radius {
  result: calc(self(border-top-left-radius) - self(padding-top))  calc(self(border-top-right-radius) - self(padding-right)) calc(self(border-bottom-right-radius) - self(padding-bottom)) calc(self(border-bottom-left-radius) - self(padding-left));
}

parent {
  border-radius: 30px 48px 82px 130px;
  padding: 20px 10px 26px 44px;
  --nested-radius: --get-nested-radius();
}

child {
  border-radius: var(--nested-radius);
}

To prevent circularity issues, no value deriving another value using self() could be used at the same selector level for a related property.

For example, neither of these would work:

padding-left: self(padding);
padding: self(padding-left);

@mirisuzanne
Copy link
Contributor

@brandonmcconnell That should probably be tracked in it's own issue (if it doesn't have one already), and link back to this thread for use-cases.

@brandonmcconnell
Copy link

@mirisuzanne That's totally understandable. I'll create that and link it back here shortly. Thanks!

@brandonmcconnell
Copy link

brandonmcconnell commented Oct 11, 2023

I also think it's worthwhile to this proposal for declarative functions that they also support non-pure functions.

It would be great if functions could support var() references using that var ref in the context from which the function was invoked.

Perhaps to make these functions safer when not pure, we could require that var() usages inside functions provide a default/fallback value.

The same rules would also apply for attr(), as well as self() if that proposal makes any headway.

@brandonmcconnell
Copy link

fyi (for followers of this ticket) — @mirisuzanne opened a related ticket with a built-out explainer here: #9350

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants