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 · 94 comments
Open

Proposal: Custom CSS Functions & Mixins #9350

mirisuzanne opened this issue Sep 13, 2023 · 94 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.

@brandonmcconnell
Copy link

@mirisuzanne With the mixins-as-functions approach, would we still also be able to apply a mixin at the top-level of a property, or would we have to explicitly name the desired properties?

My usual use case for mixins is creating a bucket of styles to bulk apply to other rules, as is common in SCSS (and LESS iirc), like this example from your explainer:

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

.page {
  @apply --center-content;
}

Another question I think this begs is if @apply could also be used here as a means of spreading groups of properties, native and custom alike.

Simpler use cases without parameters could look as simple as this:

:root {
  --center-content: {
    display: grid;
    place-content: center;
  };
}

.page {
  @apply --center-content;
}

Essentially, @apply will attempt to spread any group of properties. @mixin, whether defined with @mixin or not, though using @mixin would provide extra capabilities like parameters, default values, etc.

While not a common use case, something like this could also be done inline:

.page {
  @apply {
    display: grid;
    place-content: center;
  };
}

I wonder if this could provide more flexibility for simpler use cases. There may be other factors here I’m not considering, in which case @mixin being required for use with @apply may be a reasonable limitation.

I think a primary need would be the ability to @apply to receive all resulting properties by default unless specific properties are named. I do agree that the ability to pull specific properties from a mixin would be powerful and help to avoid naming collisions still desiring some of the effects of a particular mixin.

@brandonmcconnell
Copy link

brandonmcconnell commented Apr 10, 2024

@DarkWiiPlayer That's a good call-out. At the very least, mixins and functions should be able to override one another as custom properties do, so you can create and use a mixin, and someone consuming your stylesheet can either use your mixin as well, or create another of the same name, which would not affect your mixin local to your styles, but affect only their own styles.

Perhaps these can be scoped to their nearest @scope or @layer block, allowing more control and effective local access. I definitely see the need and relevance here.

I have an open proposal closely related to that, that you might consider speaking into: csswg-drafts/#10178

@mirisuzanne
Copy link
Contributor Author

I was not imagining any change to how mixins work as mixins. Only a functional syntax for requesting a specific value from the mixin.

It's not a viable option to define mixins inside selectors. That is the reason the previous mixin proposal was abandoned.

@brandonmcconnell
Copy link

@mirisuzanne Thanks for the additional context.

I figured it might be a good fit for simpler use cases and provide natural cascade inheritance/scoping support, but I can imagine a few holes in that approach.

@Crissov
Copy link
Contributor

Crissov commented Apr 10, 2024

@DarkWiiPlayer I guess #6099 counts as a proposal ”to provide some sort of name scoping“, but nobody liked it because vendor prefixes are sacred.

@matthew-dean
Copy link

@DarkWiiPlayer

I recently had a discussion under a dev.to post about CSS and the topic of scoping came up again, which reminded me of a problem I've already occasionally faced with custom properties that I expect will only get worse with this new feature and that's name collisions.

It's definitely a good callout, and I feel like @layer could provide this kind of scoping, if every layer shared a mixin scope (which Less does for namespaced mixins). Therefore, if you wanted to override a mixin, you could do it within a new referenced @layer of the same name. However, that does imply you could only call (or "apply") the mixin within the same layer.

A downside of @layer, though, is that if you use @layer anywhere on a page, you pretty much have to use @layer everywhere, for every bit of imported styles, so on second thought it may not be the best way to namespace / scope things. 🤔

@DarkWiiPlayer
Copy link

I see the appeal of using @layer for scoping but I think that would be extending the scope of what @layers are; worse yet, this new aspect of layers would work differently from its original one, with styles inside layers only getting overridden but functions/mixins being unavailable in higher layers regardless.

I've also thought about @scope, as the name already seems somewhat fitting, but that also just feels wrong, as the "scope" in that case refers to the DOM, but here a more useful approach seems to be lexical scoping based on the structure of the CSS, not the HTML.

Or maybe this is also worth its own discussion? Would it be more convenient when writing CSS to scope functions and mixins based on what is being styled (Certain elements and children), or should they be scoped to where they appear in the CSS (only within a certain stylesheet or more granular unit)?

@brandonmcconnell
Copy link

@DarkWiiPlayer Yes, I think it would be more constructive to discuss scoping in a separate issue as it doesn't directly pertain to the spec for mixins.

This issue I opened is relevant:
#10178

@brandonmcconnell
Copy link

@mirisuzanne I was reading back over some updates in your official proposal under the "Other result syntaxes for functions" section. While I see the value in using a property-like keyword like result, I do see some possible reason for concern in that over a @return at-rule.

A special function-definition-specific result property would follow many of the same conventions as the cascade, and one might think specificity would come into play here as well, though I'm not sure that would be a factor based on the implications of this paragraph of the "Function rules" section:

As far as I can tell, only custom properties, args/variables, and conditional rules are useful inside a function definition. Functions have no output besides their returned value, so nested selectors, built-in properties, and name-defining rules are not necessary or meaningful. I don’t think there’s any need for these things to invalidate the entire function, but they should be ignored and discarded.

If specificity plays no role in functions, then we can forget my concern here, but if it does, I think there may be some reasoning left around how we handle situations where users want a certain condition bearing lower specificity to take precedence over a condition of higher specificity.

Thanks for all your work on this!

@mirisuzanne
Copy link
Contributor Author

Since functions exist inside the value space of a property:

  • It would be meaningless to nest selectors inside the function
  • The cascade will need to finish before the function itself resolves

Both the function-definition (if multiple rules define the same function name) and function-call (the property where the function is used) will participate in aspects of the cascade – but there really is no way for function internals to impact that.

Technically result would be a 'descriptor' belonging to an at-rule (like @property { syntax: …; }) and not a CSS property belonging to an element.

@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 :))

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