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

Proposal: Custom CSS Functions & Mixins #9350

Open
mirisuzanne opened this issue Sep 13, 2023 · 107 comments
Open

Proposal: Custom CSS Functions & Mixins #9350

mirisuzanne opened this issue Sep 13, 2023 · 107 comments

Comments

@mirisuzanne
Copy link
Contributor

mirisuzanne commented Sep 13, 2023

Background

Read the full explainer

There's an existing issue for Declarative custom functions, which forms the basis for much of this proposal. I'm opening a separate issue here, in order to make a broader proposal building on that, which includes both functions and mixins.

From a language perspective, mixins and functions are somewhat distinct – they live at different levels of the syntax, and come with different complications. It may make sense to handle them in different levels of a specification (likely functions first) or even different specifications altogether. Function-specific discussion could move back to the existing thread for that work. However, the features also have a lot in common from the author/syntax perspective, so I wanted to consider them together here, without cluttering the main thread.

Intro

Both Mixins and Functions provide a way to capture and reuse some amount of logic. That can be used for the sake of developer shorthands, and also as a way of ensuring maintainability by avoiding repetition and encouraging consistent use of best practice patterns. For many years, authors have been using pre-processors to perform this sort of CSS abstraction – or experimenting with custom property tricks like the space toggle hack, and recently style queries. There's also an open issue for Higher level custom properties with many mixin-like use-cases.

By providing a native CSS solution for these use-cases, we can help simplify web tooling/dependency requirements – while also providing access to new functionality. Mixins and functions in the browser should be able to accept custom property arguments, and respond to client-side media, container, and support conditions.

The overlapping syntax basics

Both functions and mixins need to be defined with a (custom-ident) name, a parameter-list, some amount of built-in-logic, and some output to return. The difference between the two is where they can be used in CSS, based on the type of output they provide:

  • Functions return CSS values (like a string, color, or length) and can be used inside a CSS declaration
  • Mixins return entire CSS declarations or even rule blocks (including selectors and other at-rules)

For the basics, I'm proposing two new at-rules following a similar pattern:

/* custom functions */
@function <name> [(<parameter-list>)]? {
  <function-logic-and-output>
}

/* custom mixins */
@mixin <name> [(<parameter-list>)]? {
  <mixin-logic-and-output>
}

The parameter lists should be able to define parameters with a (required) name, an (optional) default, and potentially (optional) <syntax>. In order to allow custom-property values with commas inside, we likely need a ; delimiter both in defining and passing arguments, where values are involved. To re-use existing custom-property syntax, we could do something like:

@function --example (--named-parameter; --name-with: default-value) { 
  /* if further description of a parameter is necessary */
  @parameter --named-parameter {
    default: 2em;
    syntax: "<length>";
  }
}

[Edited] @emilio has suggested potentially having parameter names only in the parameter list, and then @parameter-like rules in the body of the function/mixin when default values or syntax descriptor are needed. That would remove the need for ; delimiters in the prelude entirely. I'm not attached to all the details of the syntax here, but borrowed from existing structures. If we don't need the syntax definition for parameters, or can add that later, it might allow us to simplify further.

Internally, both syntaxes should allow conditional at-rules such as @media, @container, and @supports. That's one of the primary functions that CSS-based functions and mixins could provide. I don't think non-conditional or name-defining at-rules would serve any purpose, and should likely be discarded.

Functions

Normal properties inside a function would have no use, and could be discarded and ignored. However, it would be useful for functions to have internally-scoped custom properties. To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

In addition to allowing (scoped) custom properties and conditional at-rules, a function would need to define one or more resulting values to return. I like the at-rule (e.g. @return) syntax suggested in the original thread, though the result descriptor could also work. If more than one value would be returned, the final one should be used (to match the established last-takes-precedence rules of the CSS cascade).

An example function with some conditional logic:

@function --at-breakpoints(
  --s: 1em;
  --m: 1em + 0.5vw;
  --l: 1.2em + 1vw;
) {
  @container (inline-size < 20em) {
    @return calc(var(--s));
  }
  @container (20em < inline-size < 50em) {
    @return calc(var(--m));
  }
  @container (50em < inline-size) {
    @return calc(var(--l));
  }
}

h1 {
  font-size: --at-breakpoints(1.94rem, 1.77rem + 0.87vw, 2.44rem);
  padding: --at-breakpoints(0.5em, 1em, 1em + 1vw);
}

Functions would be resolved during variable substitution, and the resulting computed values would inherit (the same as custom properties).

Mixins

Mixins, on the other hand, will mostly contain CSS declarations and nested rules that can be output directly:

@mixin --center-content {
  display: grid;
  place-content: center;
}

body {
  @apply --center-content;
  /*
    display: grid;
    place-content: center;
  */
}

I don't believe there is any need for an explicit @return (though we could provide one if necessary). Instead, if there is any use for mixin-scoped or 'private' custom properties, we could consider a way to mark those specifically. Maybe a flag like !private would be enough?

Another possible example, for gradient text using background-clip when supported:

@mixin --gradient-text(
  --from: mediumvioletred;
  --to: teal;
  --angle: to bottom right;
) {
  color: var(--from, var(--to));

  @supports (background-clip: text) or (-webkit-background-clip: text) {
    --gradient: linear-gradient(var(--angle), var(--from), var(--to));
    background: var(--gradient, var(--from));
    color: transparent;
    -webkit-background-clip: text;
    background-clip: text;
  }
}

h1 {
  @apply --gradient-text(pink, powderblue);
}

There are still many issues to be resolved here, and some syntax that should go through further bike-shed revisions. Read the full explainer for some further notes, including a suggestion from @astearns for eventually providing a builtin keyframe-access mixin to help address the responsive typography interpolation use-case.

@andruud
Copy link
Member

andruud commented Sep 13, 2023

@function --at-breakpoints [...]

OK, that seems approachable. Having a @container as a conditional thing means we'll evaluate the container query (and other queries) later than usual, but I don't think it's substantially worse than container units, which already evaluate quite late.

Mixins return [...] rule blocks (including selectors and other at-rules)

[...] mixins [...] would be resolved during variable substitution

Those two things are not compatible. But it's possible that we'll able to impose enough restrictions on this to make it feasible, and still get something useful. For example, limit mixins to only declarations, or only declarations and nested &-rules (which would allow your nested @supports example).

including a suggestion from @astearns for eventually providing a builtin keyframe-access mixin to help address the #6245 (comment) use-case

That's an interesting idea. :-)

@lilles
Copy link
Member

lilles commented Sep 13, 2023

I think it will be hard to support @mixins with selectors inside the @mixin. Selectors are typically matched from right to left in impls and would be started inside the @mixin rule. If you have something like:

@mixin --foo {
  .a { }
}

.b {
  @apply --foo;
}

.c {
  @apply --foo;
}

In that case we would need to start matching from the .a in the mixin and branch out to the nested .b and .c selectors. This branch is dynamic based on how @mixin rules cascade. I think that is pretty hard to do in the current implementation in Blink. Especially for style invalidation, but also for efficient selector matching. The implicit & for @supports, @media, etc. should be fine.

It was not clear to me from the "Mixins, on the other hand, will mostly contain CSS declarations and nested rules that can be output directly" that nested selectors would be allowed, but the clearfix example in the full explainer uses nesting.

@mirisuzanne
Copy link
Contributor Author

mirisuzanne commented Sep 13, 2023

If nesting is possible it would be popular. Many existing mixins are used to establish things like consistent hover/focus handling (nested pseudo-classes) or like icon-buttons, including styles for the nested icon, etc.

(updated so that the variable-substitution reference is specific to functions)

@romainmenke
Copy link
Member

romainmenke commented Sep 13, 2023

I always saw @mixins more like syntactic sugar.

Where resolving would be roughly equivalent to wrapping in &{} and inserting that where @apply used to be.

Is there a specific use case where the result would be substantially different?

(I am definitely viewing this to much from the perspective of preprocessors.)


Edit :

(updated so that the variable-substitution reference is specific to functions)

ack

@lilles
Copy link
Member

lilles commented Sep 13, 2023

I always saw @mixins more like syntactic sugar.

Where resolving would be roughly equivalent to wrapping in &{} and inserting that where @apply used to be.

Is there a specific use case where the result would be substantially different?

That's a whole different story and much simpler to implement. It would not allow you to apply @mixins across stylesheets, which might be a showstopper?

@romainmenke
Copy link
Member

It would not allow you to apply @mixins across stylesheets, which might be a showstopper?

That would be unfortunate. Dev tools will often bundle or chunk stylesheets in ways that aren't immediately clear or controllable by authors.

It would make mixins much harder to use.

@kizu
Copy link
Member

kizu commented Sep 13, 2023

Really happy to see this proposal! As an author, I always wanted to have mixins and functions in CSS, and can't wait for the day when we would get anything closer to it. I don't mind the exact syntax, so would mostly focus on features and usage.

To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

Is this a strong requirement? I can see a lot of use cases for the implicit access for external custom properties:

  • Design-tokens. They could be potentially solved with env() in some cases, but not always: often the tokens could be calculated based on other custom properties, like a color token that chooses between a light and dark theme, and while it might be initially defined on the :root (or as env() in the future), there are use-cases when we override a whole group of tokens somewhere down the tree, like with a .dark-mode className.

    Our custom function could want to reuse such tokens, but if external custom properties are not available, that won't be possible. I'd propose making it so the custom properties defined inside the function (and as its parameters) to override the external custom properties.

    It would be up to authors to avoid the conflicts, and would be similar to how things work, for example, in JS, where we have access to the variables from the outer scope unless we would override them.

  • “context”-like API. Custom properties have inheritance, which is very useful when you want to pass some values deep down some chain of elements.

    I can see how this aspect can be useful for functions as well: if we'd say something like “custom properties defined in a function body is scoped to everything inside that function, and is available inside any functions called inside the function body, overriding the external ones”. This would work similarly to how scopes and variables work in JS, for example, and would be how things would be expected to work.

Some other questions/thoughts, but very preliminary, as I would need to re-read the proposal (and I just skimmed the explainer for now, would need to read it properly) a few times and think long about it:

  1. For mixins, I'll need to think more about them, but my main question: would we be able to somehow pass the return of one mixin as an argument to another? See “block mixins” from stylus, for example: https://stylus-lang.com/docs/mixins.html#block-mixins (Disclosure: I was a maintainer for Stylus for a while, and was behind some of its weirder features like block mixins (specs-wise, not implementation-wise), so I'm biased).

  2. When thinking about functions and mixins, one thing that I immediately remember I wanted to have in CSS — an ability to have arrays/lists and maps. That might be worth a separate issue (maybe there was one? I did not yet try to find one), but in many many mixins in preprocessors developers are used to have some way of retrieving a value from a map, or manipulating a list of values. Again, this is probably a very separate topic, but I just have to mention it, as maybe having these in the back of our heads when thinking about mixins could help.

  3. Similarly, with conditions in CSS. Having conditionals based on container-queries is good, but we'd for sure would want an ability to do things based on input conditionally, with value comparison etc. Again, probably a slightly separate thing, but I saw it was mentioned as a question in the explainer, so I want to mention it as something that I, as an author, would want to see possible eventually in CSS.

That's it for now. I would try to find time to read the whole explainer properly, and would come back with more feedback.

@mirisuzanne
Copy link
Contributor Author

@lilles Some follow-up about nesting in mixins… I don't think that constraint would be a blocker for making this a useful feature. I'm curious if there's any useful distinction between between nested selectors that change the subject, and those that only add additional constraints? For example, selectors like & > p or & + p would require finding a new selector subject, while &:hover adds a constraint to the existing subject.

To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

Is this a strong requirement?

@kizu I would consider it a strong authoring requirement that nothing inside the function should have accidental consequences on values outside the function. I don't know that we need such a strong requirement in the other direction. I don't know if there would be implementation concerns about allowing external values into a function, but I think you're right that it could work for authoring.

  1. […] would we be able to somehow pass the return of one mixin as an argument to another?

I'm not familiar with the feature in Stylus, but I think this is similar to the @content feature in Sass, and the similar feature discussed in the explainer?

Similarly, there's a section on argument conditions and loops – roughly your points 3 & 2, though I don't go into a lot of detail on lists/maps. In both cases, I considered them potential future extensions rather than essential aspects of a basic function/mixin feature.

@tabatkins
Copy link
Member

First, I've talked this over with Miriam already, and broadly am happy with the elaboration on my earlier idea of making functions basically just "fancy variables".


Edited before posting: whoops, yeah, the edited version of argument syntax makes me a lot happier. I think we can still put syntax into the arglist, fwiw.

@function --foo (--bar "<string>", --baz "<number>") {...}

I think that still reads reasonably? And if it is too long and hard to read, you can always move it to @parameters.

(I presume that anything without a default value would be a required arg, making the function invalid if not passed? Since args in the arglist wouldn't have a default, that would match up with the usual practice of required args coming first, followed by optional args.)


To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

I disagree with this, and think it's probably pretty important to allow functions to access the variables present on the element. I don't see any way for there to be an accidental conflict; every name that could potentially conflict is function-local and controlled by the function author, no?

Tho, hm, we'd have to be a little careful about nested functions. If we did make ambient variables available we'd have to decide whether all functions saw the element's vars, or saw the function's vars too. The latter does have an accidental shadowing concern.

But I feel like it's important to allow this, because otherwise you can't replace existing variables (that reference other vars) with functions unless you explicitly pass those variables, which can be annoying. In particular, it means you couldn't use custom units unless they were explicitly passed to the function, which feels extra-obnoxious.

However, it would be useful for functions to have internally-scoped custom properties.

Yes, this sounds great. Temp vars are useful.

Internally, both syntaxes should allow conditional at-rules such as @media, @container, and @supports. That's one of the primary functions that CSS-based functions and mixins could provide.

I agree that these are useful, but their usefulness isn't specific to functions. We've talked about a media() function that lets you do MQ-specific value resolution; should we just rely on that rather than having a special syntax just for functions?

(I'm not particularly against doing the @return and allowing conditional rules, fwiw. Just wondering if it would be better to lean on the generic functionality.)


Unmentioned here is what to do about recursive functions. Without the ability to test and branch on the value of one of the input variables, I think a recursive call is guaranteed to be cyclic just like a custom-property self-reference is, right? So presumably that should be detected and auto-failed using the existing anti-cyclic variable tech. If we later allow for the possibility of useful recursion we can relax the requirement and impose a stack limit or something. (Then we can repeat the TC39 arguments about tail recursion, yay!)

@mirisuzanne
Copy link
Contributor Author

I presume that anything without a default value would be a required arg, making the function invalid if not passed?

I would not presume that, since guaranteed invalid is a reasonable default value. Is there a strong reason that should need to be specified explicitly in an @parameter rule?

think it's probably pretty important to allow functions to access the variables present on the element.

I'm open to this. As mentioned above, my stronger concern is that changes to an external variable don't accidentally escape the function.

Internally, both syntaxes should allow conditional at-rules such as @media, @container, and @supports. That's one of the primary functions that CSS-based functions and mixins could provide.

I agree that these are useful, but their usefulness isn't specific to functions. We've talked about a media() function that lets you do MQ-specific value resolution; should we just rely on that rather than having a special syntax just for functions?

That comment was not about a special syntax for functions, but a normal syntax allowed inside functions. The only potential need for a special syntax would be if we want to allow some (restricted) parameters in function at-rules. But this proposal does not include that as an initial requirement. (It's also not specific to the @return syntax, but should work no matter how the returned value is declared).

@jimmyfrasche
Copy link

Functional languages tend to have a 'let' <vars>+ 'in' <expr> syntactic form whose result is <expr> which can make use of the 1 or more <vars> that are private to the let-expression.

I've wanted CSS functions for a long time but some of the places I've wanted them I'd only use it once so I could reuse internal computations without leaking a lot of one-time custom properties (my solution in practice is to just shrug and leak a lot of one-time custom properties and hope it doesn't cause any problems later).

If there were a CSS version of let-expressions you could just tell people using functions to use that if they need private stuff and it would be handy for the odd case where you have a handful of things you want to refer to more than once but a function would be overkill.

@tabatkins
Copy link
Member

Mixins return [...] rule blocks (including selectors and other at-rules)

[...] mixins [...] would be resolved during variable substitution

Those two things are not compatible. But it's possible that we'll able to impose enough restrictions on this to make it feasible, and still get something useful. For example, limit mixins to only declarations, or only declarations and nested &-rules (which would allow your nested @supports example).

Right, I assumed that they'd be applied before any value stages, essentially equivalent to doing a preprocessor. As far as I can tell there's nothing dynamic about mixins (save the values passed in themselves, but they're effectively just uniquely-named variables). (And note that we already established, back in the last attempt at @apply, that passing around declaration blocks via computed values is not viable.)

That, I think, would allow us a lot of freedom in what to allow inside the mixin without adding a lot of complexity. However, it limits our ability (somewhat) to do branching/etc based on the values. We could still do, say, a @for based on a static value (like @apply --foo(5)), but not on a dynamic one (like @apply --foo(var(--bar)) or @apply --foo(counter(list-item)). But that's something to worry about in the future; we're not doing conditionals or loops yet.

@tabatkins
Copy link
Member

I would not presume that, since guaranteed invalid is a reasonable default value.

Sure, but we could spell that initial, like @parameter --optional { default: initial; }.

And it's not a reasonable default value if you have a syntax. Without the "required" behavior, we'd have to require that with a syntax you also have to provide an initial value.

As mentioned above, my stronger concern is that changes to an external variable don't accidentally escape the function.

Agreed, that's definitely required, but I don't think anything could reasonably cause that. You can't affect any properties on the element from within a function.

[stuff about conditionals]

Yeah, I don't have an issue with the proposal, was just thinking aloud. I think doing conditionals as proposed is better (plus we don't have a plan for an inline CQ function anyway).

@lilles
Copy link
Member

lilles commented Sep 18, 2023

@lilles Some follow-up about nesting in mixins… I don't think that constraint would be a blocker for making this a useful feature. I'm curious if there's any useful distinction between between nested selectors that change the subject, and those that only add additional constraints? For example, selectors like & > p or & + p would require finding a new selector subject, while &:hover adds a constraint to the existing subject.

I don't think that would be different. The common thing is that nesting in this case dynamically combines parts of the selectors from the mixin and the apply and that would require the implementations to connect those pieces at a later stage than the parser, and that the multiple applications branch into multiple ancestor selectors. For nesting it's much easier since it's done at parse time and the selector representation can be fully built at parse time.

How complicated and how much of a re-organization of the selector matching/invalidation code this is depends when we can resolve the mixin applications.

@lilles
Copy link
Member

lilles commented Sep 18, 2023

Mixins return [...] rule blocks (including selectors and other at-rules)
[...] mixins [...] would be resolved during variable substitution

Those two things are not compatible. But it's possible that we'll able to impose enough restrictions on this to make it feasible, and still get something useful. For example, limit mixins to only declarations, or only declarations and nested &-rules (which would allow your nested @supports example).

Right, I assumed that they'd be applied before any value stages, essentially equivalent to doing a preprocessor. As far as I can tell there's nothing dynamic about mixins (save the values passed in themselves, but they're effectively just uniquely-named variables). (And note that we already established, back in the last attempt at @apply, that passing around declaration blocks via computed values is not viable.)

IIUC, it is a requirement that mixins can be applied across stylesheets, so that the connection between the mixin and the apply has to be re-done as new stylesheets are added and requires some cascade order definition for which mixin is found first for a given name.

As mentioned in a different post I think this can be challenging for an implementation if we allow selectors inside the mixin. At least if the mixin contains a selector which becomes part of multiple nested selectors.

@tabatkins
Copy link
Member

I'm sorry, Rune, but I don't understand what connection your reply has to what I said. Could you elaborate? I suspect one or both of us is misunderstanding what the other is saying.

@lilles
Copy link
Member

lilles commented Sep 18, 2023

I'm sorry, Rune, but I don't understand what connection your reply has to what I said. Could you elaborate? I suspect one or both of us is misunderstanding what the other is saying.

Sorry, I tried commenting on the "essentially equivalent to doing a preprocessor" part. I read "preprocessing" as something that can be done at parse time, which would limit apply to reference mixins in the same sheet.

@tabatkins
Copy link
Member

tabatkins commented Sep 18, 2023

Ah, ok, no, I meant it more in the "not dynamic based on the DOM" way; it only requires information that is available to a preprocessor (the full set of stylesheets for a page). I imagine it would be roughly equivalent to just inserting the rules with JS (after uniquifying the input variable names so they can be replaced with normal custom props set in the selector). So, an expensive operation to perform, and it might need to be repeated as you discover more stylesheets, but still generally a one-and-done operation, after which you just have normal style rules.

@justinfagnani
Copy link

@lilles

That's a whole different story and much simpler to implement. It would not allow you to apply @Mixins across stylesheets, which might be a showstopper?

I've found a lot of people, including myself sometimes, would prefer to have variables act like references that are lexically scoped, as opposed to names than can be dynamically overridden based on HTML structure and CSS selectors applied to it.

I suspect this will even be more true of functions and mixins where the intent of the author is to import and use a specific function or mixin, not inherit the definition via a property.

This is one reason I filed #3714 to try to get to a place where we could make something like a named declaration, and export/import that to/from various places, ie importing rulesets into JS, or importing a mixin into another stylesheet.

The idea from #3714 applied to mixins might look something like:

utils.css:

@mixin $center {
  display: grid;
  place-content: center;
  }

component-a.css:

// assuming we could add `{name} from` support to `@import` syntax
@import {$center} from url("./utils.css");

body {
  @apply $center;
  /*
    display: grid;
    place-content: center;
  */
}

This would act a bit more like preprocessor mixins as well, and play nicely with static analyzers, minifiers, etc.

@justinfagnani
Copy link

@mirisuzanne this is awesome!

My first big question about mixins and @apply are how this avoids the problems @tabatkins talks about in https://www.xanthir.com/b4o00 Is it because function and mixin declarations aren't applied with selectors and don't inherit? What is the scope of a mixin declaration then, global?

Also, one of the use cases for the older @apply was that components authors could use it in their styles to allow component users to override arbitrary properties on certain elements. Is that use case supported with @mixin?

@tabatkins
Copy link
Member

They avoid the @apply issues because they're not defined using the custom property mechanism and passed around that way. They're first-class citizens acting at the appropriate syntax level.

What is the scope of a mixin declaration then, global?

Yup, same as in Sass/etc.

Is that use case supported with @mixin?

No. ::part() was defined to handle that case instead.

@LeaVerou
Copy link
Member

LeaVerou commented Oct 18, 2023

Coming to this quite late (I only discovered it yesterday from @mirisuzanne’s mention in #8738). Sharing some thoughts below.

Commonalties between mixins and functions

I’m not sure about defining functions and mixins together. They have fundamentally different use cases, and I’m worried defining them together could hold one of them back from its full potential.

  • Functions are about computation reuse: you provide input values, you get an output value. It makes sense for them to be side effect free, just like native CSS functions.
  • Mixins are about style reuse. They have input but no output. They (ideally) need access to the context, and the full power of CSS.

I'd suggest the opposite process: iterate on them separately then find the common concepts and spec those together at the end.

Use cases

For both mixins and functions, there are two fundamental use cases, and we need to ensure both are served well:

  1. Local, specific purpose mixins and functions to reduce coupling and duplication and facilitate maintainability within a stylesheet
  2. Libraries of mixins and functions intended to be imported in projects and be more broadly useful. Examples:

These are a spectrum. One may start with a local mixin/function, then decide that they are more broadly useful.

An observation is that mixins also facilitate encapsulation. Today if you have multiple classes that share styling, either you need to alter the structure of your CSS code, OR just ask people to use both the base class and the more specific classes (e.g. class="callout warning"). Bootstrap icons does the latter too: every icon needs both a .bi class, AND a .bi-{icon id} class . Mixins can facilitate this both internally, by having a local --bi mixin, but also by exposing all icons as mixins. There could even be mixins like --icon-before and --icon-after so that people can apply them directly.

Requirements

Must-have, even in the MVP

  • I think being able to reference variables from the outside in mixins is essential.
    1. This is how functions work in most programming languages: local vars shadow outside vars, but outside vars are still accessible.
    2. As @kizu mentioned, there are plenty of very valid use cases.
    3. Easier to debug
    4. If outside vars are accessible, one can always get the behavior in the current proposal by setting them to initial in the mixin, but if they are not there is no way to get the opposite behavior
    5. It’s probably fine if functions have to be side-effect free, since mixins can always be used instead, when context is needed.

Must-have, but we could be left out in L1 if really needed

  • Nesting. Without it mixins can only accommodate the simplest of use cases. It can wait for L2, but the syntax must be designed to allow it.
  • Non-conditional @-rules in mixins. Think of fonts, animations, custom properties, font palettes, @scope etc.

Important but not essential

  • Scoping. We’ve seen time and time again how anything global becomes a pain for authors. If possible, mixins defined within other rules should be scoped to those subtrees, same as a custom property defined in a nested rule. If not possible yet, then we should completely disallow mixins within other rules, so that it can become possible in the future.

Syntax

Mixins

The current syntax proposed uses a @mixin rule to define the mixin and an @apply rule to apply it. I suspect it may not be feasible implementation-wise, but I would really love to investigate whether using a property-like syntax would be possible for using the mixin. Instead of parentheses, the parameters would be passed in as a property value. I will open a separate issue for this, as it seems a significant deviation from this proposal.

Using parameters in conditional rules

The proposal asserts that we could not use var() to refer to parameters in conditional rules. @tabatkins can correct me but I think we could simply allow them in the grammar, and have prose that only permits them in mixins, only when they refer to params passed?

Function return value: @-rule or descrptor?

I like an at-rule syntax (e.g. @return) rather than a result descriptor.

  • It helps distinguish the final returned value from any internal logic like custom properties and nested rules
  • Result is not a property, but looks a lot like one

It’s more of a property than an @-rule IMO. It's something that has a value and (optionally) a type. There is no precedent for @-rules that have values and types, but plenty for descriptors.

Yes, a generic @-rule is more distinguishable than a descriptor, but that is a syntax highlighting problem, not a language problem.

Being a descriptor allows it to cascade (e.g. to provide alternatives based on browser support — though the utility of that is limited due to var()) and be type checked like a custom property.

Actually, come to think of it, do we even need a dedicated descriptor? What if the result was held by a custom property, and which one was part of the function definition:

@function --at-breakpoints(
  --s: 1em;
  --m: 1em + 0.5vw;
  --l: 1.2em + 1vw;
) returns --result {
  @container (inline-size < 20em) {
    --result: calc(var(--s));
  }
  @container (20em < inline-size < 50em) {
    --result: calc(var(--m));
  }
  @container (50em < inline-size) {
    --result: calc(var(--l));
  }
}

And I like @FremyCompany’s idea as a default: if returns is missing, use the custom property that is named the same as the function.

Oh, or what if what it returns is actually a proper value?

@function --hypot(--a, --b) returns var(--result) {
	--result: calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2));
}

Then you can make the braces optional and have a shortform function definition:

@function --hypot(--a, --b) returns calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2));

Questions

Do mixins cascade down Shadow DOM boundaries? If so, they may help fix the issue of styling Shadow DOM (e.g. having form controls that are styled like the surrounding page with reasonable DX).

@mirisuzanne
Copy link
Contributor Author

Great feedback - I've already started working some similar adjustments into the explainer. You can open additional issues if you want for individual aspects, but I don't consider this issue specific to the initial syntax - happy to keep iterating on that.

I also don't think mixins/functions should get too tangled. But it did feel useful to explore them together for this initial write-up. That has already helped point to where they are distinct (as you mentioned) and what they seem to share:

  • a way of defining parameters with (optional) default values and types
  • a relationship to external custom properties (one-way flow in but not out)
  • potentially down the road, additional control-flow and/or the ability to use (some) parameters in global at-rules

But at this point, those all look like things that should be pretty portable. If we get them right in one feature, we should be able to reuse them for the other.

@andruud
Copy link
Member

andruud commented Oct 27, 2023

I think being able to reference variables from the outside in mixins is essential.

@LeaVerou Do you just mean something like this, or do you have something more advanced in mind?

@mixin foo {
  color: var(--color);
}

#is-green {
  --color: green;
  @apply foo;
}

#is-blue {
  --color: blue;
  @apply foo;
}

@vrubleg
Copy link

vrubleg commented Nov 8, 2023

A random idea. What if it were allowed to apply a mixin using ++ prefix?

#test
{
    --varname: value;
    ++mymixin: arguments;
    property: value;
}

Looks almost like a custom property =)

@tabatkins
Copy link
Member

Mixins aren't properties, tho. And adding a brand new syntax needs some significant justification.

@tabatkins
Copy link
Member

I think being able to reference variables from the outside in mixins is essential.

  • This is how functions work in most programming languages: local vars shadow outside vars, but outside vars are still accessible.

This isn't true, tho. What you're describing is usually called "dynamic scope", where a function can get access to the values of variables in the context it was called. Most languages use "lexical scope", where a function has access to variables in the context it was defined, and then when it's called the only additional information comes from the arguments themselves. Dynamic scope is very rare these days, as it's both easy to cause weird errors and harder to optimize. Instead we just pass arguments.

(And CSS variables don't exist in the global context in which @function is executed to define the function, so it wouldn't have lexical access to anything.)

Giving functions access to outside variables would also make it more difficult to work with functions generically - every outside variable it references is effectively an extra argument, and one that gets passed implicitly without having to know anything is happening. That means you can't name your custom properties arbitrarily, since a function you want to call might also use that property for its own purposes.

(The argument is different for mixins, which definitely swizzle your local "state" already by mixing in more properties. Nothing wrong with allowing them the ability to output styles that refer to outside variables.)

Nesting. Without it mixins can only accommodate the simplest of use cases. It can wait for L2, but the syntax must be designed to allow it.

By this do you mean letting a mixin use nesting to target styles at other elements? Then yeah, absolutely.

Non-conditional @-rules in mixins. Think of fonts, animations, custom properties, font palettes, @scope etc.

Only insofar as these rules already work when nested inside of style rules. When they do, they should work in mixins; when they don't, they shouldn't.


@function --at-breakpoints(
  --s: 1em;
  --m: 1em + 0.5vw;
  --l: 1.2em + 1vw;
) returns --result {
  @container (inline-size < 20em) {
    --result: calc(var(--s));
  }
  @container (20em < inline-size < 50em) {
    --result: calc(var(--m));
  }
  @container (50em < inline-size) {
    --result: calc(var(--l));
  }
}

I don't see what benefit we gain here from using a (configurable?) custom property here. Could you elaborate on why this is better than just using @return calc(...) in each location?

Being a descriptor allows it to cascade (e.g. to provide alternatives based on browser support — though the utility of that is limited due to var())

Right, it can't cascade in that way. We have no idea what the return value is - that requires contextual information about exactly how and where it's used, and we don't do that for typed custom properties, so we don't do that here either. The best we can do is know what type it's meant to resolve to, and verify that it does, so DevTools can complain.

and be type checked like a custom property.

This doesn't require it to be a descriptor. We just need to know the expected type somehow.

@function --hypot(--a, --b) returns calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2));

The syntax already allows essentially this:

@function --hypot(--a, --b) { @return calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2)); }`

Scoping. We’ve seen time and time again how anything global becomes a pain for authors. If possible, mixins defined within other rules should be scoped to those subtrees, same as a custom property defined in a nested rule. If not possible yet, then we should completely disallow mixins within other rules, so that it can become possible in the future.

This conflicts very heavily with some core concepts, unfortunately. If you have to do selector matching to even know that a mixin is available, that makes it much more difficult to then apply the mixin, and have it interact with the cascade properly.

Disallowing @Mixins from being defined inside of style rules is, luckily, the default case - they're not allowed by the Nesting spec unless we say so. ^_^

@mirisuzanne
Copy link
Contributor Author

Giving functions access to outside variables would also make it more difficult to work with functions generically… The argument is different for mixins…

Lea only listed this as a requirement for mixins, so I don't think there's any disagreement here.

Non-conditional @-rules in mixins. Think of fonts, animations, custom properties, font palettes, https://github.com/scope etc.

Only insofar as these rules already work when nested inside of style rules. When they do, they should work in mixins; when they don't, they shouldn't.

This gets to the question of top-level mixins, which are a fairly common pattern in Sass. I regularly use mixins to generate code (like font rules and keyframes) at the root of the CSS document, not in any nested context. I'm not sure how that would work with a nesting-driven approach to mixins, but there is certainly a use-case for it. On the other hand, it's a use-case that may not have any advantages running on the client. And it's most often used to access flow control and loops, which we don't have at this point.

@mirisuzanne
Copy link
Contributor Author

I have some opinions about syntax – I don't see any gain from customizing the name of a return-value descriptor, or forcing it to match the function name – but for the most part my goal with this issue was to get confirmation that we want to pursue something along these lines. Functionally, I don't see a big difference between @apply foo(parameters); or eg ++foo: parameters;, and I'd be happy to bikeshed all of these details in more focused issues. So my focus here is on getting a resolution about taking up the broader project (either functions, or mixins, or both), and then we can open those other issues and continue with more focused discussions on syntax and behavior.

@tabatkins
Copy link
Member

Agenda+ to do an intro session for the spec at TPAC

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed Proposal: Custom CSS Functions & Mixins.

The full IRC log of that discussion <emeyer> TabAtkins: We agreed to take on functions & mixins specification
<emeyer> …At this point, we have a first-draft spec for functions, but not mixins
<emeyer> …Not looking for resolutions, just a quick runthrough
<emeyer> …Custom functions are defined using an @function rule with a double-dashed identifier
<emeyer> …Something it resolved, a value is passed in, calculations are done, the result is passed out
<emeyer> …The full syntax is a little larger
<emeyer> …IN addition to a parameter list, there’s a dependency list
<emeyer> …Particularly from Lea’s feedback, it’s common to want to use defined system variables
<emeyer> …Having to pass them to every function is frustrating
<emeyer> …Functions can declare they will implicitly pull in extra variables
<emeyer> …Can also declare its return type
<emeyer> …If the custom function resolves to the wrong thing, we can detect that and act on it
<emeyer> …After the fact, if we see something wrong, we invalidate
<emeyer> …at use time
<lea> q+
<emeyer> …Just like in the other descriptor, if there are multiple results, the last one is what resolves to the value
<emeyer> …Conditional rules are permitted
<emeyer> …Things like media queries can go inside
<kbabbitt> draft is at https://drafts.csswg.org/css-mixins-1/
<emeyer> florian: Local variables, order doesn’t matter?
<emeyer> TabAtkins: Correct, they’re orderless
<emeyer> florian: I suspect this is correct, but is likely to confuse
<emilio> q+
<emeyer> TabAtkins: We hope that the fact it looks like regular styles will guide people to the right behavior
<emeyer> lea: SSometimes you need to access certain contexts, but the grammar looks to me like it’s easier to pass in by using
<emeyer> TabAtkins: You don’t pass in anything that way
<emeyer> …They’re automatically added to arguments at the call site
<emeyer> lea: It might be useful to have an aggregate syntax
<emeyer> TabAtkins: Please open an issue for that
<emeyer> s/SSometimes/Sometimes/
<astearns> ack lea
<emeyer> TabAtkins: We have a return type for the function itself
<lea> q+
<emeyer> …You can also declare grammars for individual arguments
<emeyer> …If you call with the wrong thing, it will invalidate the functoin
<emeyer> …There’s a syntax that looks like CSS grammar
<florian> q+ to ask what happens if you have multiple return descriptors and only one of them matches the declared type
<emeyer> …Authors don’t like wrapping in a string
<astearns> q+
<emeyer> …Basically, the grammar looks like custom property registrations
<emeyer> …There’s no change in functionality, but you don’t need to wrap in quotes
<lea> q+ to ask about system colors (canvas, canvastext) and currentcolor, relative units
<emeyer> …So you declare functions, they can use conditional queries
<emeyer> …If you rewrite appropriately, this should be equivalent to dropping in a nesting block
<Penny> (There is a noise suppression setting on the Zoom control panel set to enabled, but making changes is password protected. We'll need to engage tech support to disable it.)
<emeyer> …Questions?
<emeyer> emilio: This is a descriptor, not a property?
<emeyer> TabAtkins: Right
<emeyer> emilio: We need to sort out how this operates on the CSSOM
<emeyer> TabAtkins: Yeah, there could be some clashes there
<emeyer> emilio: This should look a lot like a style rule, right?
<emeyer> TabAtkins: Body will be llike a style declaration
<emeyer> emilio: That makes implementation... fun, but okay
<emeyer> s/llike/like/
<emeyer> lea: Making sure, system colors would resolve based on the color scheme when this is used?
<astearns> ack emilio
<astearns> ack lea
<Zakim> lea, you wanted to ask about system colors (canvas, canvastext) and currentcolor, relative units
<emeyer> TabAtkins: Yes, they’re based on the element the function is applied to
<matthieud> q+
<emeyer> …Because of the clash between functions and wider variables, vars in the body are references to (missed by scribe)
<astearns> ack florian
<Zakim> florian, you wanted to ask what happens if you have multiple return descriptors and only one of them matches the declared type
<lea> q+ to say the current grammar for using allows declaring types, is that intentional?
<emeyer> florian: If you have declared integer type and multiple returns, do you check them all against type and return the last valid or only check the last one returned against the type?
<emeyer> TabAtkins: I’m not sure, but I think we have flexibility
<fantasai> I think we want to match the type.
<emeyer> …We want to be consistent with other things so probably the second, but we’re not locked in either way
<emeyer> fantasai: I think we should take the last one that does match the type
<romain> +1
<emeyer> …They might try to do forward opt-in, which would break if we don’t do it that way
<kbabbitt> +1 fantasai
<astearns> ack astearns
<emeyer> astearns: The new non-string representation will also be available for custom property registration?
<emeyer> TabAtkins: Yes
<astearns> ack matthieud
<emeyer> matthieud: Can you only define functions at the root?
<emeyer> …And if it’s allowed, (missed by scribe)
<emeyer> TabAtkins: They are global and we don’t have a way to store the function and use it elsewhere
<astearns> s/(missed by scribe)/(something about closures)
<emeyer> lea: If they’re global only, we should not allow them to nest so we have options later
<emeyer> …Same for mixins, where scope is valuable
<fantasai> s/(missed by scribe)/can we do closures/
<astearns> ack lea
<Zakim> lea, you wanted to say the current grammar for using allows declaring types, is that intentional?
<emeyer> …THe using grammar allows types, is that intentional?
<emeyer> TabAtkins: Yes
<emeyer> lea: How does that work if it’s a registered variable?
<emeyer> TabAtkins: It still has the token representation
<emeyer> lea: So you could recast to a different type
<emeyer> TabAtkins: You can already do that by subbing
<keithamus> q?
<keithamus> q+
<miriam> q+
<astearns> ack keithamus
<emeyer> keithamus: Curious how these translate across shadow boundaries
<fantasai> s/subbing/subbing a registered variable into an unregistered variable and re-subbing into a differently-registered variable/
<emeyer> TabAtkins: It’s a great question
<emeyer> …Been thinking how to expose shadow DOM, and the answer should work but what that means exactly is undefined
<emeyer> …BY the time this ships we should have a reasonable answer
<astearns> ack miriam
<emeyer> miriam: Are we saying the types in the parameter list are not just for validation, they’re actually setting the type of what comes in?
<emeyer> TabAtkins: No, they just validate, they don’t otherwise change behavior
<emeyer> …We don’t have a way to trigger animations from within a function
<emeyer> astearns: Any other questions?
<emeyer> …I’m assuming this mostly spec noodling
<emeyer> TabAtkins: Right, nothing is imminent
<emeyer> astearns: Are there any resolutions you need?
<emeyer> TabAtkins: No

@romainmenke
Copy link
Member

romainmenke commented Sep 27, 2024

@LeaVerou said:

If they’re global only, we should not allow them to nest so we have options later
Same for mixins, where scope is valuable

I don't think we can do that.
Because then you couldn't import a stylesheet with @function rules when the import has conditions. Such a @function rule would effectively be nested in all the conditions of the import statement.

I don't think can disallow nesting and decide later.
Because then we would break backwards compat if people already have conditional imports to @function rules.

@mirisuzanne
Copy link
Contributor Author

mirisuzanne commented Sep 27, 2024

It's not obvious to me that a scoped condition is the same as nesting. Already we have rules like @import that can't be nested, but can be part of imported. And I believe @scope is the first import condition that is somewhat nesting-like. If this is an issue (at very least, it should be clarified) it needs to be addressed more broadly on #7348 - not just for functions and mixins.

@LeaVerou
Copy link
Member

@romainmenke What I was talking about was @function and @mixin rules that are nested within other rules. I think you may have interpreted it differently?

@romainmenke
Copy link
Member

romainmenke commented Sep 27, 2024

Yeah, I was thinking of wrapping in any rule, including things like @scope, I think you mean nesting in style rules? (which in hindsight is quite obvious :))

@JaneOri
Copy link

JaneOri commented Nov 6, 2024

Two quick questions...

  1. vars containing values with commas would spread across params on a call to a function, right?
--args: 1, 2, 3;
--val: --custom-fn(var(--args)); /* called with 3 arguments? */
  1. If my custom function only takes 1 param, is it okay to call it and still pass in 10 values where the last 9 are ignored?

if yes on q 1, (and helped along with a 'yes' on q 2) this opens the door to arrays which means CSS libraries (with minor restrictions) can begin providing shorthand properties in their API! That's gonna be sick!

@function --at-0 (--p0) { result: var(--p0); }
@function --at-1 (--p0, --p1) { result: var(--p1); }
@function --at-2 (--p0, --p1, --p2) { result: var(--p2); }
...

.library-border {
  --size: --at-0(var(--api-shorthand));
  --style: --at-1(var(--api-shorthand));
  --color: --at-2(var(--api-shorthand));
  border: var(--size, var(--api-size, 1px))
    var(--style, var(--api-style, solid))
    var(--color, var(--api-color, black));
}
<div class="library-border" style="--api-shorthand: 2px, solid, hotpink;">
  I have a 2px solid border that's hotpink!
</div>

@mirisuzanne
Copy link
Contributor Author

Those are interesting questions. I think it would be good to open separate issues for them at this point. I don't have an immediate answer, but would be interested in the discussions/implications.

@tabatkins
Copy link
Member

vars containing values with commas would spread across params on a call to a function, right?

Per current specs, yes. (And I think that's good.) If you want to make sure that a variable only expands into one argument, you can call the function like --custom-fn({var(--args)}).

If my custom function only takes 1 param, is it okay to call it and still pass in 10 values where the last 9 are ignored?

Yeah, we should raise this as a separate issue, as there are multiple possible answers and none of them are "obvious".

@matthew-dean
Copy link

matthew-dean commented Nov 11, 2024

It's for this reason that Less started leaning towards separation of args & params with semi-colons, and one of the major risks of bringing "function-like" constructs to CSS (which I still feel is not needed, especially not before worklets would be expanded to allow custom functions defined in JS/WASM).

Once you start trying to merge the two, you find that, unlike typical programming languages, CSS has many many syntaxes to express the concept of lists. Some lists are space-separated, some are comma-separated, and for some reason, newer & revised color functions use a combination of space and slash-separated. Declaration lists are semi-colon separated, except when they aren't (such as when inter-mixed with child at-rules).

When it comes to mixins and functions, then, you run into a conceptual ambiguity. What did the user actually mean? Often times, a comma-separated list in CSS still represents a single conceptual "value".

For example, imagine a user writes (forgive me if this isn't the current proposed syntax):

// file1.css
@function --custom-font-fn(--fonts) { result: var(--fonts); }

// file2.css
.rule {
  font-family: --custom-font-fn(Arial, sans-serif);
}

In this case, a developer coming along may assume 1 of 2 things: either the person writing the function definition made a mistake, or the person writing the call made a mistake. Clearly the function requires one value, and that value is clearly a list of one or more font family names... except it isn't clear. It's not intuitive, syntactically, what the output will be. Will sans-serif be tossed away? Or interpreted as a single value. I realize you can document the function syntax to make this clear, yet my point is that programming languages reduce ambiguity in these contexts via the list syntax itself.

Less resolved, in discussions, that the construct that was less ambiguous in CSS is semi-colon separation. In every other case, the "value" sent to a mixin / function just felt ambiguous.

For the Less team, the clearest seemed to be: ideally, mixins / functions should have always been called with semi-colon separators between arguments. But, for backwards-compatibility, they could be used instead of commas to reduce ambiguity.

So, when semi-colons are forced, then it's clear where they "slot" in the call:

  // it's much clearer this is not a "font family list value", but two clearly separated values,
  // since CSS can't assign a value with semi-colon separators to a property
  output: --custom-font-fn(Arial; sans-serif);

As an alternative, Less combines the concept of "escaping" with parens, so it allows a single list value like:

  output: --custom-font-fn(~(Arial, sans-serif));

It looks like the proposal is doing something similar, but with braces? e.g. Or at least I'm inferring that from:

--custom-fn({var(--args)})

So this?

  output: --custom-font-fn({ Arial, sans-serif });

For me, braces around a comma-separated list seems non-CSS-y to me, since we don't see that elsewhere, but I guess either way there's some net new (unfortunately) in how CSS defines a list. It seems like either way we'll add another "list definition syntax" to CSS, which I wish wasn't the case, since there's already so many.

But anyway, just thought I'd share how other people have approached this issue, in case that's helpful!

@JaneOri
Copy link

JaneOri commented Nov 13, 2024

@mirisuzanne @tabatkins thank you both! I've opened the issue

@matthew-dean it's a fair assumption for most devs that it would be one argument looking at it from outside of normal-for-css behavior; might want to bring it up again in the new thread - though I do hope it remains CSS-y and spreads to multiple

#11190

@matthew-dean
Copy link

@JaneOri

It's probably worth its own breakout thread / discussion for CSS to define, in spec, just what a list is, and how / when it's delimited in one way vs. another. As far as I know, there's no syntactic explanation; CSS is mostly a collection of micro-syntaxes that mostly conform to another, and depending on when a property / value set was introduced, can have slightly different syntactic conventions. So there are rough intuitive understandings of when something is a space-separated vs. comma-separated vs. slash-separated list vs. semi-colon separated, but unless I've missed it in the time I've spent with CSS specs, there's not really a theory of the syntax that really explains it... and then on the "function-like" side of things, the semantics of comma-separated lists (distinct argument values) is ever-so-slightly different from the semantics of comma-separated lists in properties (a single, comma-separated value), except when they aren't (e.g. modern color syntax). Space-separated lists tend to be reserved for shorthand values, except when they aren't (e.g. modern color syntax).

Case in point, the modern color module spec provides no explanation that I can see for why the syntax changed, what the motivation is/was, or, most importantly, what CSS currently considers to be an "argument" for a "function", or what it considers to be a list.

The reason I bring this up is that mixins / functions are ultimately built upon this CSS list ambiguity—CSS has no clear syntactic definition of a list, or when to use one list "type" over another. With defined functions, that's sort of alright, because CSS is full of micro-syntaxes, each function can essentially define it's own syntax. But with custom mixins and functions, you're suddenly painted into a corner. There is no "intuitive" way to define mixins / functions in CSS that is going to make sense to all developers, because CSS has no "generic" function argument list rules.

So, IMO, I feel like this is missing a very fundamental piece of discussion, which is to clarify, in CSS specs, what the list types are and what their intended use cases are. If the modern color syntax follows a new syntactic theory of lists, what is it? Why are some "arguments" separated by spaces, and some by slashes? (By the way, it's not just about those color functions -- there are other CSS function and list inconsistencies; its just the easiest go-to example.)

If CSS is going to essentially expand itself as having programmatic constructs, then I strongly believe it needs to more clearly define its syntax, specifically around lists. Without it, I don't believe the @function feature makes much sense; it imports an allegorical idea that can't be built on the same foundation. Mixins make some sense (if they don't take arguments)—having chunks of styles referenced in other styles is conceptually simple—but anything with arguments I think needs to supply this underlying work. Just because it exists in Sass, and to some degree, exists in Less, doesn't mean its built on foundational principles.

At the very least, I think CSS would best be served by chunking this work into these parts:

  1. CSS Mixins Level 1 - Implement (argument-less) mixin syntax.
  2. Discuss and define CSS list syntax. Figure out all the historical "intuitions" about lists but redefine them with a set of rules to be applied moving forward for any new specs.
  3. CSS Mixins Level 2 - Decide on and possibly implement arguments for mixins.
  4. CSS Mixins Level 3 - Decide on and possibly implement argument "spread" syntax (should be back-ported to a new draft of the "list" spec), where that be with "..." or parens or braces or what have you.
  5. CSS Mixins Level 4 - Decide on if "functions" make sense, and if so, add to mixin syntax / spec.

@matthew-dean
Copy link

matthew-dean commented Nov 13, 2024

Just to provide another example where lists are inconsistently-defined in CSS, let's take Custom Properties.

The var() function is defined like this:

var( <custom-property-name> [,] [<declaration-value>])

The spec goes on to explain:

Note: The syntax of the fallback, like that of custom properties, allows commas. For example, var(--foo, red, blue) defines a fallback of red, blue; that is, anything between the first comma and the end of the function is considered a fallback value.

Internally, it's fine. Once you know how var() works, you know how it works. From a language and grammar perspective, it's terribly inconsistent. What you have in the case of var(--foo, red, blue) is, as noted in the spec, two arguments separated by three commas. If other functions worked like this, okay fine, except they don't. Commas in other functions denote the number of arguments. Except when they don't.

Which then brings us back to something like: --custom-fn({var(--args)})

So now, it's yet another inconsistency added to other CSS list inconsistencies. That is, there's the notion of coalescing a comma-separated value into a single one... except, if that were a defined concept in CSS, then you would presumably see it in var() (var(--foo, { red, blue })) and you don't. I get that var() was created before this use case, but that's partly my point -- these specs keep adding to the "concept" of lists without ever formally defining it in syntax. IMO it's just unwise to further expand this concept without a formal definition.

Once the formal definition for CSS lists is complete, two things are going to immediately become easier:

  1. It's going to be immediately more obvious how arguments should be passed into mixins, and defined in mixins.
  2. It's going to be instantly obvious which parts of CSS do not conform to the syntax and concept of lists and, like color functions, should be updated to allow a new, consistent syntax.

Side note: this doesn't even touch on the fact that sometimes a comma in CSS is not semantically a separator between distinct values but is an operator, such as as an OR in media queries.

@kyr0
Copy link

kyr0 commented Dec 12, 2024

From a language and grammar perspective, it's terribly inconsistent.

@matthew-dean @mirisuzanne I second that. Introducing a new sub-grammar for this use-case seems inconsistent indeed.

If we're talking inconsistencies in lists, the following solution comes to mind:

output: --custom-font-fn(Arial, sans-serif);
output: --custom-font-fn("Some Font with Spaces", sans-serif);
output: --custom-font-fn("Some Font, with Spaces, and comma", sans-serif);
output: --custom-font-fn(var(--some-font-defined-by-some-var), sans-serif);
output: --custom-font-fn(--another-custom-fn(var(--some-font-defined-by-some-var)), sans-serif);

I think this is the straight-forward way.

Did I overlook something? It seems quite consistent and in-line with the existing syntax?

@mirisuzanne
Copy link
Contributor Author

The conversation about lists and commas has been happening at a higher level in other issues - and I imagine any future features (like custom functions and mixins) will follow whatever patterns are established for built-in functions – which are now also starting to accept more complex values.

@kyr0
Copy link

kyr0 commented Dec 12, 2024

Thank you for your response.

Btw. despite what I read in this thread.. I'd like to add:

"var()" is a "built-in function". "calc()" is a "built-in function". People are used to call functions without a -- prefix.
Therefore, defining a -- prefix as a requirement to custom functions is an inconsistency too. -- is traditionally used to define a var or to dereference it by name.

Example: --some-var: #ffffff; used as a declaration.
Example 2: var(--some-var) used to dereference.

Together with the lists topic this leads to multiple conclusions.

  • Simply do not allow to use literal lists in argument lists of functions. var() can be used here?
  • Simply don't declare functions with --.
  • Simply don't come up with a new "return" keyword that does not has the keyword signature of "@", because also this would be an inconsistency.

To give a complete, "consistent" example of what I mean (and an alternative):

/* CSS standard "list", since the last millennium (see alternative below, could be used only when "needed") */
--default-custom-fonts: "Some Font with Spaces", sans-serif;

/* a function with default values assigned */
@function custom-font-fn(
  --font-family: var(--default-custom-fonts) /* lists not allowed here, using a var */
) {
  /* consistent keyword definition for return statements; if less syntax is wanted, why not introduce arrow functions too? */
  @return {
      font-family: var(--font-family);
      /* max define much more... even some nested mixins etc. */
  }
}

.foo {
  @mixin custom-font-fn(); /* uses the default here*/
  color: #ffffff;
}

Alternative: If lists must be defined, the most common syntax to define lists across all programming languages is []. So it seems to make sense to use [] for lists/arrays in a literal form, and not so much {} which is mostly used to define object-like data structures. So: --default-custom-fonts: ["Some Font with Spaces", sans-serif]; could be allowed in the future, while the past grammar still works. It would introduce a new grammar, but at least it would be one almost any developer is familiar with.

Of course, this is derailing a few ideas, but I think this is more in-line with what CSS used to look like and also more in-line with what the community built and is used to with CSS pre-processor grammar in the field. In the end, people seem to love to type less and also like to have a clear, concise syntax. Extensive use of -- makes any grammar increasingly hard to read, especially if it is used for different purposes (vars, functions, dereferencing, declaring, calling)

@mirisuzanne
Copy link
Contributor Author

mirisuzanne commented Dec 12, 2024

CSS uses -- "dashed identifiers" anywhere that there might be confusion between built-in and custom syntax. Custom properties are only the oldest and most established example of that pattern, but functions also fit. I don't think that's optional. The result vs @return syntax is still up for debate, but that should be discussed in a dedicated issue.

@bkardell
Copy link
Contributor

bkardell commented Dec 16, 2024

The easiest way to think about this is that CSS has always had several spots for extension in terms of vendor prefixes like

  • -{vendor}-{property}
  • :-{vendor}-{pseudoclass}
  • ::-{vendor}-{pseudoelement}
  • -{vendor}-{function}( )

Custom things in CSS are just "vendor prefixes without a vendor" so you wind up with --

@kolkov
Copy link

kolkov commented Dec 27, 2024

We also need to think about how all this will look in the DevTools browser, where everything will look like a JS debugger!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Thursday morning
Status: Monday afternoon
Development

No branches or pull requests