w3c / csswg-drafts

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

Proposal: Custom CSS Functions & Mixins #9350

Open mirisuzanne opened 9 months ago

mirisuzanne commented 9 months ago

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:

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.

Loirooriol commented 4 months ago

I don't know of this ability existing in any other language

Not to defend Crissov's idea, but it sounds like arguments in JS:

function sum() {
  return arguments[0] + arguments[1];
}
sum(2, 3); // 5
FremyCompany commented 4 months ago

Maybe I should open a new issue but: What happens when one does this?

* { @apply --mixin-name(var(--something)) }

Would --something be the value of the property on the element? or its value inside the mixin? (which might not match, but it is weird)

css-meeting-bot commented 4 months ago

The CSS Working Group just discussed Proposal: Custom CSS Functions & Mixins, and agreed to the following:

The full IRC log of that discussion <miriam> https://css.oddbird.net/sasslike/mixins-functions/
<fantasai> miriam: explainer linked from issue
<fantasai> miriam: a lot of interest in mixins and functions, which have existed in preprocessors for a long time
<fantasai> miriam: these are two separate features that have syntax overlap and use-case overlap
<fantasai> miriam: they are both ways of wrapping up some CSS logic and repeating it in various places
<fantasai> miriam: Both have a name and take parameters, do some logic, and output some CSS
<fantasai> miriam: difference is that mixins exist in the declaration space, outputting entire declarations or even rule blocks
<fantasai> miriam: and functions exist in the value space, simlar to existing functions
<fantasai> miriam: Functions exist in CSS, custom functions would be a way for authors to define their own functions in a declarative way
<fantasai> miriam: would be limited in some ways to what you can do declaratively
<fantasai> miriam: takes what you can do e.g. in complex set of calcs, and grouping with a nicer syntax
<fantasai> miriam: mixins are a newere concept in CSS, but same idea at a declaration level, returning entire blocks
<fantasai> miriam: a lot of discussion in issue around syntax and details of execution
<fantasai> miriam: mixed interst in functions and mixins
<fantasai> miriam: question to group is, do we want to take any of this up on the standards track
<florian> q+
<emilio> q+
<fantasai> miriam: and if so where would we work on it?
<astearns> ack florian
<fantasai> florian: When we introduced CSS Variables, what was esepcially interesting was that the way they worked gave us powers you couldn't achieve in a preprocessor
<fantasai> florian: do you gain abilities in native CSS that you couldn't do in preprocessor?
<fantasai> miriam: You would be able to reference CSS conditions
<fantasai> miriam: preprocessor could also output conditions, so maybe not all that different?
<fantasai> florian: like a media query?
<fantasai> miriam: No, actually, main thing you get here is passing in a custom property
<fantasai> miriam: couldn't be able to do that in preprocessor
<astearns> ack emilio
<fantasai> TabAtkins: could maybe do that in preprocessor, but could get explosive in length
<fantasai> emilio: Clearly interesting for authors, but some questions
<fantasai> emilio: for mixins, when does the substitution happen?
<fantasai> emilio: e.g. applying to nested rules sounds fancy, but if you expand it after parsing then it becomes crazy complicated
<fantasai> emilio: how does that interact with cascade
<fantasai> emilio: if expanding during selector matching, then [missed]
<fantasai> emilio: I'm wondering if we know the answers to these questions?
<fantasai> emilio: So how much have these details been thought througH/
<fantasai> miriam: some looking into those questions. Anders has been playing around with ideas. No fixed proposals.
<fantasai> TabAtkins: idea is that mixins expand essentially at parse-time, but not relying on matching because that would be weird
<fantasai> TabAtkins: hopefully desugar into nesting
<fantasai> TabAtkins: functions theoretically desugar into inlining everything
<fantasai> TabAtkins: just some amount of variable scoping
<fantasai> TabAtkins: so idea right now is that mixins should resolve very early, parse-ish time
<fantasai> emilio: If you pass a variable in mixin, then reference..?
<fantasai> TabAtkins: You need to [...]
<Nicole> q+
<astearns> ack Nicole
<TabAtkins> s/.../uniquify the variable name so the ref can stick around to runtime/
<florian> q+
<fantasai> Nicole: Seems like something we should talk about
<fantasai> TabAtkins: any objection to starting a draft?
<fantasai> astearns: single draft, multiple drafts?
<fantasai> TabAtkins: mixins probably fit best in cascade, functions in variables, but since they share a lot of concepts maybe their own module
<fantasai> miriam: they're both kindof like a macros
<fantasai> s/macros/macro
<fantasai> matthieud: Putting them together might make things more confused.
<fantasai> [discussion of functions vs macros and macros that are functions]
<TabAtkins> LISP-4-EVA
<fantasai> miriam: Each would have a name-defining at-rule, and they would share defining parameters.
<fantasai> miriam: so might make sense to have in a shared space
<fantasai> astearns: Could work on them together in ED, and then decide whether to split out at FPWD
<chrishtr> +1 to a new draft
<astearns> ack florian
<fantasai> <fantasai> +1 to that approach
<kbabbitt> q+
<fantasai> florian: We used to say CSS doesn't have functions, but functional notations that look like functions.
<fantasai> florian: but a bunch of them actually are functions, e.g. trig functions
<astearns> ack kbabbitt
<fantasai> florian: this goes further in that direction, but we already crossed the bridget
<fantasai> kbabbitt: So question of timing impacts CSSOM
<florian> s/the bridget/the bridge already
<fantasai> kbabbitt: if I walk through a stylesheet now, 1:1
<fantasai> kbabbitt: like C macros
<fantasai> kbabbitt: if I change how they expand, does that change the CSSOM, would that cause breakage?
<fantasai> TabAtkins: functions would get preserved in OM
<florian> s/look like functions./look like functions, but are just convenient syntactic groupings, without any particular notion of returning anything/
<fantasai> TabAtkins: wrt mixins, could expand or not
<fantasai> TabAtkins: but need to not be depending on run-time behavior
<fremy> q?
<fremy> q+
<astearns> ack fremy
<fantasai> fremy: Something I'm missing from the explainer, if we assume that mixins are something that you can simulate and do the replacement
<fantasai> fremy: would be good to have an example of what it will actually look like
<fantasai> fremy: I have a lot of questions better answered by examples
<fantasai> fremy: That's a request for next revision, a clear example of this is what author writes, and this is how it gets "compiled"
<TabAtkins> yeah, that's very doable
<fantasai> astearns: any other questions/concerns about starting this work?
<fantasai> astearns: proposed resolution to start an Editors Draft on mixins and functions, with Miriam and Tab editing
<chrishtr> q+
<chrishtr> custom add-ins
<fremy> or just mixins?
<fantasai> [bikeshedding spec names]
<bkardell_> css-fun
<fremy> @bkardell lol :)
<fantasai> emilio: css-cpp, for css pre-processor
<fantasai> css-custom, css-custom-functions-and-mixins, css-custom-functions
<kbabbitt> css-fun-mix ?
<florian> css-macros
<fremy> css-mixins +1
<fantasai> astearns: proposes css-mixins as the shortname
<fantasai> PROPOSED: Start ED of css-mixins for CSS Custom Functions and Mixins
<fantasai> RESOLVED: Start ED of css-mixins for CSS Custom Functions and Mixins
<astearns> ack chrishtr
<fantasai> chrishtr: Does anyone have initial feedback on miriam's syntax proposal?
<emilio> q+
<fremy> +1, I think @miriam did a great job "distilling" all the feedback in a common ground
<fantasai> astearns: Suggest we take what's in the issue as a starting point, and people can file specific issues on specific concerns or suggestion
<astearns> ack emilio
<fantasai> emilio: syntax seems fine offhand, but maybe we need some other function, depending on how mixins end up working
<fantasai> emilio: we may need to differentiate actual variables to mixin arguments
<fantasai> emilio: in the mixin example in the explainer, there's e.g. background: var(--space). But could be a variable set on the element
<fantasai> emilio: so var inside mixins vs external variables
<fantasai> emilio: I think if we differentiate argument vs external variables
<fantasai> TabAtkins: same discussion as for function
<fantasai> TabAtkins: my preference is lexical variables, you only get those that are specifically forwarded to you.
<fantasai> TabAtkins: those exist and nothing else does
<fantasai> TabAtkins: keeps a simpler model, you know names you have access to
<fantasai> TabAtkins: and makes JS-backed functions/mixins work without having to pass the entire style to the function
<fantasai> miriam: That's been the most active part of the discussion lately, would be great to extract into its own issue
<fantasai> emilio: I would see it as a nice feature, to use external variables
<fantasai> emilio: if you want JS-backed, then yeah you need to specify dependencies somehow, because JS can do whatever
<fantasai> <fantasai> +1 to emilio
<fantasai> fremy: I think this discussion is one of the questions I had
<fantasai> fremy: proposal makes sense in one way, but do we use a var() function as part of the arguments then when does var() get replaced?
<fantasai> fremy: as an author if you use var(--background) you want the var [missed this]
<fantasai> fremy: and not get mixed up
<fantasai> emilio: I think Tab's proposal, you would replace the variable with another variable reference
<fremy> --function(var(--x))
<fantasai> emilio: as you pass it in
<fantasai> TabAtkins: JS-backed mixin would have typedOM concept of unresolved variable to manipulate
<fremy> var(--x) is from the element
<fantasai> TabAtkins: but functions could give a real value
<fremy> but for a mixin, that's less clear
<fantasai> TabAtkins: I would prevent lexical and dynamic scope
<fremy> so I would prefer arg(--x) vs var(--x) even if var(--x) is not allowed in the function otherwise
<fantasai> emilio: I think specifying dependencies explicitly is good, even if you don't do JS stuff.
<florian> q?
<astearns> ack fantasai
<emilio> fantasai: you could do the var substitution in various way
<emilio> ... only locally defined vars / params
<emilio> ... first local, then global
<emilio> ... you could have var() and arg()
<fremy> q+
<emilio> ... I think they're worth exploring
<florian> q+
<emilio> ... I think I tend to agree that being able to pull the variables without passing them in makes sense
<emilio> ... to avoid passing them over and over
<fantasai> emilio: I think Tab's proposal would declare the variables you want in the function definition, not to have to call them in
<astearns> ack fremy
<fantasai> s/call them in/pass them every time at the call site/
<fantasai> fremy: It still remains a question, if you say var() is replaced, especially for mixins, you can't replace var() before
<emilio> fremy: if you see var() is replace, specially for mixins you cannot replace var() before
<fantasai> emilio: I think you can substitute them eagerly, just not by the resolved value
<fantasai> emilio: you would subsitute with the variable reference
<fantasai> emilio: e.g. if the arg us --foo, gets passed var(--radius) then you'd replace var(--foo) with var(--radius)
<fantasai> fremy: then if you have another variable --radius it would get mixed up
<fantasai> emilio: no at that stage, all your references are unresolved element variable references, no function variable references
<fantasai> emilio: I think this works
<astearns> ack florian
<fantasai> florian: Especially if you have the ability to define variables that are local to the mixing, we run into the kind of problems you have with hygienic macros vs non hygienic macros in lisp
<fantasai> florian: accidentally capturing variables from the environment would be bad
<fantasai> florian: so either don't capture at all, or capturing by opt-in seems more reasonable than just pulling everything in
<fantasai> astearns: any other concerns?
<fremy> (except if you have different functions names, then there is just no risk of conflict)
<fantasai> astearns: ok done with this topic for now, let's get a draft and then poke hols in it
<fantasai> s/hols/holes/
<TabAtkins> Yes, given
<TabAtkins> ```
<TabAtkins> @mixin --foo(--arg1) using (--outer-var1) { color: var(--arg1); background: var(--outer-var1); }
<TabAtkins> .foo { @apply --foo(var(--my-color)); }
<TabAtkins> ```
<TabAtkins> you'd effectively expand to:
<TabAtkins> ```
<TabAtkins> .foo { color: var(--my-color); background: var(--outer-var1); }
<TabAtkins> ```
tabatkins commented 4 months ago

Not to defend Crissov's idea, but it sounds like arguments in JS:

Yes, tho note that arguments is as deprecated as JS can do (it doesn't exist in strict mode), and the similarish feature, spread arguments, is explicitly for when you're taking a bunch of arguments you're gonna be treating all the same.

If anyone actually wrote function foo(...args) { return doSomething(args[0], args[1]); }, they'd be doing The Wrong Thing, most assuredly.

tabatkins commented 4 months ago

@FremyCompany

Given

@mixin --foo(--arg1) using (--outer-var1) { 
  color: var(--arg1); 
  background: var(--outer-var1); 
}
.foo { @apply --foo(var(--my-color)); }

you'd effectively expand to:

.foo { color: var(--my-color); background: var(--outer-var1); }

(But i think substitution probably shouldn't be reflected in an author-observable way; the OM would just say the .foo rule has a CSSApplyRule child.)

FremyCompany commented 4 months ago

@tabatkins

But then, what if you wrote @apply --foo(..., var(--arg1))? One might expect that this would be using the arg1 from the mixin since the value is inserted in the mixin. But somehow it would track it's from outside and not get replaced inside the mixin. I dunno, I guess it's pretty confusing to me that some var(--***) will be resolved in the mixin, while others will be resolved after the mixin, on the element itself. It's pretty difficult to keep track of that. Maybe habit would make it easier, but I'm not sure.

I mean, I totally get that it could be implemented that way by treating var(--***) differently in the mixin depending on whether the name refers to an argument or an "using" import, but it's needlessly complicated. I'm still strongly in favor of a clean arg(--arg1) vs var(--outer-var1) where the behavior is always consistent per function.

FremyCompany commented 4 months ago

I'll also note that back in the days, we didn't use $ for custom property variables (in part) because you wanted to use that for mixins ;) I honestly wouldn't mind @mixin --foo ($arg1, $arg2) { ... with usage color: $arg1 and/or color: $(arg1) ... }.

tabatkins commented 4 months ago

It's pretty difficult to keep track of that. Maybe habit would make it easier, but I'm not sure.

I'm not sure what you're finding confusing here. Inside the mixin, you have access to precisely the variables listed in your arglist (or your using() list), and nothing more. There is no reason to care at all how the user of your mixin is spelling what they're passing in.

Could you elaborate a bit more on what exactly the problem you're having is?

[using $ inside of mixins/functions]

I'd forgotten about that! Yeah, that's actually pretty legit I think. Since the current plan is for the args to have default values declared in the arglist, you don't need that functionality at the usage site anymore.

Potential issues, tho:

If we go with $foo: 1 for local variables, that does fix a problem in mixins, where we probably want to be able to distinguish between "local variable declared for use inside the mixin only" and "custom property being set by the mixin for usage elsewhere". If the mixin used $foo: 1 it would be a local variable, if it used --foo: 2; it would be an exported custom property (but also be usable from within the mixin as var(--foo), as usual).

kizu commented 4 months ago

@tabatkins

the strong convention of nearly every programming language in the world suggests that going completely lexical is ideal - the only names visible to the function body are listed in its arg list.

I don't think it is useful to compare most programming languages with the declarative way CSS handles things as applied over HTML, or maybe I don't see which exact issues requiring the using() will prevent.

This also puts us in a good spot for future expansion into JS-backed custom functions, since JS is strictly lexical as well.

When thinking about CSS functions called from JS, I'd think they'd require passing the element context to them to evaluate over; otherwise I'm not sure how useful they could be?

The scope it's called in. In your example, --foo() will see an undefined --var1 (aka the guaranteed-invalid value) and a --var2 of 10. […] If we had to explicitly pass all of them as variables, I'd definitely agree. As written, tho, you just have to state them once per function definition.

That will mean that any function that uses something will require any other function that will want to include it inside to also list all the dependencies.

Also, design tokens are generally going to be global

In my practice, there were common cases where some semantic design tokens are not, in fact, global, but can be overridden on any element in the DOM. The simplest example of this is theming: we could swap the dark & light theme on some element (swapping the values of hundreds of tokens), and will want nested functions to take this into account. Other example: typography, where we could want to set the “size” for some container, and then apply different set of dependent tokens that will be used inside.


Re: using(), I would really want to look at the examples of practical problems it will solve. Let me try to ask it in a different way.

Let's look at an example with using and without:

@function --foo() {
  @return var(--bar);
}
@function --foo() using (--bar) {
  @return var(--bar);
}

As I see it: without using, the var(--bar) in the first example will be always guaranteed-invalid, right? I don't think this is ever useful? Wouldn't requiring using here be redundant?

The only useful case for having something like using will be if we'd want to grab some variable and redefine it, like if we'd have another function that would want to use a variable which name clashes with what we'd want to retrieve from the outside.

@function --foo() {
  @return var(--bar);
}

@function --baz() using (--bar as --outer-bar) {
  --bar: something;
  --a: --foo(); // will be `--a: something`
  --b: var(--outer-bar); // We can still access outer `--bar`
}
mirisuzanne commented 4 months ago

I am excited to start discussing these details in distinct issues, rather than a single mega-thread. Others should feel free to do the same. :)

Issues can be marked with [css-mixins] in the title, and there's now a related css-mixins label.

tabatkins commented 4 months ago

When thinking about CSS functions called from JS, I'd think they'd require passing the element context to them to evaluate over; otherwise I'm not sure how useful they could be?

I was referring to a CSS function whose behavior is written in JS (like custom layout/custom paint), not calling a CSS function from JS.

(I just wanted to clear up this confusion; the rest of the questions should be asked in a fresh issue, now that we have a label for it.)

trusktr commented 4 months ago

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

Having an import (like JS, we import what you need, not everything is simply a global) would be great. it would be easier to reason about things that way. The functionality in our file is determined by what we imported. More of a language-level thing.

@import --foo from './my-mixins.css'

/* --foo is usable only within this file, not in any other file. */ 
.some-class {
  @apply --foo;
}

I believe we should stop trying to make everything global.

With ES Modules and now CSS Modules, maybe this is more possible?

The current @import is problematic, and providing an alternative would align with the above:

The idea there is to have an import mechanism that is more like ES Modules: we import what we need into our files, the engine determines the dependency graph for us, and modules do not re-evaluate more than once.

devingfx commented 4 months ago

Why introduce a new syntax for mixins?
Why not just being able to declare that a selector acts like if it has some other selectors on it? Providing arguments with custom vars...

/* mixins but without any special syntax */
.foo { color: var(--color, red); }
.bar { background: var(--bg, red); }
[round] { border-radius: 5px; }

#myfoo1 {
  color: black; /* fallback, overriden because declared before @same-as */
  @same-as [round].foo;
  padding: .3rem; /* other normal stuff */
}
#myfoo2 {
  @same-as .foo.bar;
  --color: green;
  --bg: white;
  color: orange;   /* override @same-as because declared after */
}
foo { /* OMG he use unknown elements!! :X */
  @same-as div.foo;
  --color: rebeccapurple;
}
<div id="myfoo1">Fool</div>  = color: red, background: red and radius: 5px
<div id="myfoo2">Baroof</div> = color: orange and background: white
<foo>C'est un foo!</foo>  = color: rebeccapurple

Would be the same as if HTML markup is:

<div id="myfoo1" class="foo" round>Fool</div>
<div id="myfoo2" class="foo bar">Baroof</div>
<div class="foo">C'est un foo!</div>

It would be really the same as if HTML markup is modified, nothing is done on the CSS. (I'm even OK if browser implementors really adds the classes and attributes in the DOM...).

Meaning also that incompatible selectors would just don't work:

div.foo { color: var(--color, red); }

#myspan {
  @same-as .foo.fa.fa-check;
}
<span id="myspan">Fool</div>  = color: inherit, not red because span.foo has no CSS matching (but have an icon...)

What a time to be alive!! (and to spend on CSS Functions spec only ;) )

LeaVerou commented 4 months ago

@devingfx As an author, I would love something like @same-as, but I suspect implementing it would be very tricky, even with severe restrictions about the type of selectors that can be used there.

Even beyond implementation, there are several conceptual issues too:

Lastly, even if @same-as were possible, it does not cover all use cases with mixins, as it does not support parameterization.

Crissov commented 4 months ago

Exactly, @sames-as – or @include in #3714 – could only really work with a special kind of selector that did not actually match anything in the (HTML) document directly.

$fg, .foo { color: var(--color, red); }
$bg, .bar { background: var(--bg, red); }
$br, [round] { border-radius: 5px; }

#myfoo1 {
  color: black;
  @same-as $fg $br;
  padding: .3rem;
}
#myfoo2 {
  @same-as $fg $bg;
  --color: green;
  --bg: white;
  color: orange;
}
foo {
  @same-as $fg; /* no class to element aliasing */
  --color: rebeccapurple;
}
LeaVerou commented 4 months ago

@Crissov …which loses the pave-the-cowpaths benefits of what was proposed for @same-as, so we may as well just go full blown mixin sysntax.

Crissov commented 4 months ago

Indeed, it basically boils down to @mixin --foo {} vs. $foo {}, at least when disregarding the JS aspects of #3714 (which I don’t care about particularly, but are the main purpose of that issue).

DarkWiiPlayer commented 4 months ago

@devingfx As an author, I would love something like @same-as, but I suspect implementing it would be very tricky, even with severe restrictions about the type of selectors that can be used there.

Even beyond implementation, there are several conceptual issues too:

  • Which selectors do you pull in? Do you pull in rules from different origins or different cascade layers? If so, do they maintain these associations?
  • What about selectors that mean the same thing but with different syntax? E.g. would @same-as .foo pull in [class~="foo"], :is(.foo), :where(.foo), :not(:not(.foo))? Or even [class="foo"], [class^="foo "], [class*=" foo "], [class$=" foo"]? What about subsets like [class="foo"] or [class^="foo"], [class$="foo"]? If not, you'd end up with different results than an element with an actual foo class, but if you do, I suspect figuring out the bounds of equivalency might be non-tractable.

Lastly, even if @same-as were possible, it does not cover all use cases with mixins, as it does not support parameterization.

So I admittedly haven't really thoguht too deeply about it, so maybe I am actually just missing something obvious here, but is there any real benefit to parametrising mixins when they are used instead of setting custom properties on the element and using those from within the mixin?

For the vast majority of use-cases, a simple setup like this would do the job:

button {
   border-radius: var(--button-corner-radius);
}
.button {
   --button-corner-radius: .2em;
   @same-as button;
}

The benefit I see to this approach is that it encourages setting custom properties on root nodes and letting them cascade, which makes me think ideally, even mixins using the proposed mechanism should still default to configuration via custom properties rather than arguments, whenever possible.

Short of using the same mixin twice with different configurations, which to me doesn't seem like something that'll happen much in practice, this mostly seems like a way of preventing these variables from cascading.

xactly, @sames-as – or @include in #3714 – could only really work with a special kind of selector that did not actually match anything in the (HTML) document directly.

This is true in theory, but in practice could be mitigated by prefixing selectors like .mixin-button; not the most elegant solution, admittedly, but it makes the problem way less severe.

As for your example, I think that is overlooking a point: If you want to make this airtight and prevent awkward prefixing, you only need to provide a selector type that can be used for this; you wouldn't have to prefix every mixin with a special selector. In your example, all the mixins already select something in the DOM, so those selectors could be re-used. At least the first two, assuming some really tight restrictions on which selectors to allow for mixins (restricting it to classes and ids sounds reasonable, although I see no reason not to just match the selector exactly).

Those are just some thoughts on this alternative proposal though, I still generally think the way it currently works is good and honestly wouldn't want bikesedding to get in the way of getting to use this feature soon. Also I believe I've seen some browser already started prototyping this anyway, so discussing massive changes at this point might just not be productive anyway. In summary: Either of these would be better than no mixins at all.

LeaVerou commented 4 months ago

I would want to point out that the differences between @mixin and something like @same-as are not just syntactic. The way I see it, the most powerful thing about @same-as is that it does not necessarily require a shared "contract" between the mixin and the mixin usage, because anything can is a potential mixin. This would enable libraries and components to adapt to the surrounding page with very little integration effort from authors. As a library author, I'd sacrifice mixin parameterization to get that in a heartbeat.

But like I said, I suspect it would not be feasible. That said, we should definitely explore feasibility, rather than ruling it out upfront.

kizu commented 4 months ago

@DarkWiiPlayer is there any real benefit to parametrising mixins when they are used instead of setting custom properties on the element and using those from within the mixin?

The main one is that arguments don't have to be unique. If we have two mixins, where each wants to accept the same-named “variable” --a, it will result in a conflict, while if it is passed as an argument, there won't be a name conflict, as we define the arguments inside the mixin call.

An option could be to make all the arguments transparently accept variables, so if you want to pass it explicitly, you'd do it when you call the mixin, otherwise it will fall back to the inherited value of this variable on the element.

DarkWiiPlayer commented 4 months ago

@LeaVerou

But like I said, I suspect it would not be feasible. That said, we should definitely explore feasibility, rather than ruling it out upfront.

At first glance, it seems like there's many ways this could be done:

There's obvious downsides to all of these, but I'm not seeing why this is necessarily not feasible. Even the third option, which probably adds the most work for implementations, still seems quite doable compared to many other modern CSS features that are being implemented. But as always: I don't build browsers, so I wouldn't know for sure.

From a purely wishful thinking CSS author perspective, I really like of defining styles on an element like button, then pulling those rules directly into a.button, because it benefits from the well known semantics of the <button> element; I think this would make code easier to read, while still leaving plenty of rooms for frameworks to use more esoteric nomenclatures like prefixed classes, etc.


@kizu

The main one is that arguments don't have to be unique. If we have two mixins, where each wants to accept the same-named “variable” --a, it will result in a conflict, while if it is passed as an argument, there won't be a name conflict, as we define the arguments inside the mixin call.

Could this not be mitigated by simply prefixing mixin-specific custom properties? To me it seems like accessing the same "variable" in different mixins could be mostly beneficial.

If I'm writing a website, I might use a --color variable in many mixins, have a --button-color one specifically for my button mixin, and my framework might use its own --fw-color variable, and a specific --fw-button-color one for its specific button mixin.

This isn't to say the idea of differentiating arguments and cascading variables is useless, I'm just trying to understand the nature of the difference: Does this added choice give me any actual new tools when building websites, or is it just a more convenient way to express my design ideas?

devingfx commented 4 months ago

I suspect implementing it would be very tricky

@LeaVerou I suppose that the algorithm of selector need to be inverted somehow to get the features (tagname, attributes, classes) to virtualy add to the element... But I'm pretty sure it can be spec'ed, isn't it?

Do you pull in rules from different origins or different cascade layers? If so, do they maintain these associations?

I'm not sure to understand what associations issue is there, but yes rules from other origins are pulled in because @same-as is not aware of it: It simply adds feature to the node, whatever this features are defined elsewhere or not or not yet. The "matches" algoritm apply as if any JS script added the features to the node at the time of this @same-as declaration apply.

What about selectors that mean the same thing but with different syntax? E.g. would @same-as .foo pull in [class~="foo"], :is(.foo), :where(.foo), :not(:not(.foo))? Or even [class="foo"], [class^="foo "], [class*=" foo "], [class$=" foo"]? What about subsets like [class="foo"] or [class^="foo"], [class$="foo"]?

Everyone would work because the node virtualy has the class foo !

but if you do, I suspect figuring out the bounds of equivalency might be non-tractable.

Not sure what the issue here... :S


@LeaVerou Lastly, even if @same-as were possible, it does not cover all use cases with mixins, as it does not support parameterization.

@kizu The main one is that arguments don't have to be unique. If we have two mixins, where each wants to accept the same-named “variable” --a, it will result in a conflict, while if it is passed as an argument, there won't be a name conflict, as we define the arguments inside the mixin call.

Like what @DarkWiiPlayer said in his comment, I'm not sure the complexity of parametrisation brings to the table that custom-vars already brings... I mean at least for the mixin part, for really complex case you still can use a more complex @function... @kizu I have the intuition that if 2 mixin use the same semantic variable name (and because author would know it comes from the element and it's not special to the mixin) it's ok for it to be the same var name, like by exemple:

.btn { background-color: var(--bg-color, var(--color, red)) }
.bordered { border: 2px solid var(--border-color, var(--color, blue)) }
.orchid { --color: darkorchid; }
#id1 {
  @same-as .orchid.btn.bordered;
}
#id2 {
  @same-as .btn.bordered;
  --bg-color: darkgreen;
  --border-color: green;
}

with a special kind of selector that did not actually match anything in the (HTML) document directly.

$fg, .foo { color: var(--color, red); }
$bg, .bar { background: var(--bg, red); }
$br, [round] { border-radius: 5px; }
...

@Crissov it defeats reusability, this is a no go for what I imagine @same-as is.


BTW, is this "light mixin" alternative worth a separate issue/discussion ?

devingfx commented 4 months ago

What about selectors that mean the same thing but with different syntax? E.g. would @same-as .foo pull in [class~="foo"], :is(.foo), :where(.foo), :not(:not(.foo))? Or even [class="foo"], [class^="foo "], [class*=" foo "], [class$=" foo"]? What about subsets like [class="foo"] or [class^="foo"], [class$="foo"]?

After re-lecture, I come back on this question. Did you mean: what about these selectors placed in the @same-as declaration?
Or what about them elsewhere in the case of a @same-as .foo ?

I answered for the 2nd case... And for the 1st case, yes it's pretty sure the selectors possible to write in a @same-as are to be constrained a bit... 1) No compound selectors : what's the meaning of @same-as .a > .b ? Make as if the node has a parent with class a ? This is a too big virtual change to the actual HTML markup ! The same apply to a + b, a ~ b, a b, ... 2) No * selector, would means: act like if the node is anything ? :upside_down_face: 3) What about OR aka , selectors? Could be fine and usefull to act as several other selectors like @same-as button, input[type=button], .btn. Maybe the order of applying "virtual features" matters, @same-as .btn, input[type=button], button may not be the same result... 4) Concerning pseudos, I guess a complete list of possibilities are to be studied because some are OK like @same-as :hover but some are not like @same-as ::before...

romainmenke commented 4 months ago

It would be better to split these ideas into dedicated issues. Even counter proposals can be discussed in a separate issue :)

kizu commented 2 months ago

I just published an article called “Layered Toggles: Optional CSS Mixins” that mentions this issue, so I'm backlinking it here as well: https://kizu.dev/layered-toggles/

It talks about a way to kinda implement mixins today by using cascade layers and cyclic toggles, which, while probably not suitable for production just yet, could be potentially a good way to play with what native CSS mixins could do.

matthew-dean commented 2 months ago

@LeaVerou

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

That's essentially what Less does, and as such, doesn't need a separate @function with a whole separate @returns at-rule at the end instead of @mixin. Functions are, after all, mixins where you're "selecting" a return value.

So, with your example:

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

And presuming a use like:

.foo {
  /* Not sure what the proposed syntax is here */
  value: --hypot(1, 2);
}

The Less equivalent is:

.hypot(@a, @b) {
  --result: calc(sqrt(pow(@a, 2) + pow(@b, 2)));
}
.foo {
  value: .hypot(1, 2)[--result];
}

In other words, in Less, you just select the property value you want returned by the mixin. Currently, that's by name, but in the future, might be done by (positive or negative) indexed position.

So, there's no reason CSS couldn't do something like:

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

.foo {
  value: apply(hypot(1, 2), --result);
}

I lot of people make good points that @return or @return just isn't flexible enough to support scenarios where the mixin might itself contain container / supports / media queries that affect which value is the "return" property value (unless of course, the @return only had one argument, which was the custom property name? 🤔 )

While I was initially skeptical of mixins in native CSS (and, by extension, functions), I think the killer feature about this is the ability to define and @apply rules (and, hopefully, nested rules) to classes to avoid the necessity of a Tailwind-like system to list dozens of classes in HTML. (Not saying Tailwind isn't useful for some people, just that it could be more general-purpose and applicable natively in CSS.) Having classes "inherit" (or "extend" as the equivalent in Less/Sass) other classes (or, in this case, mixins) would be a huge win.

The one thing I would say is my personal preference that $foo is not used for variable declaration, now that it's used in both Less and Sass (and others) for variable access / definition. I know pre-processors always run this risk of future syntax clashes with CSS, but I feel like it's reasonable in CSS to prepend variable / mixins declarations with at-rules. I really feel like @mirisuzanne kinda nailed the syntax the first time, although I would make one tweak:

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

As there is the preceding @mixin and @apply, there isn't any reason to cloud the semantics of single property names / values with mixin names / values. As in, like named grid lines / columns, the names would never clash with any future CSS syntax (the names are user-defined by definition), so I feel the -- is just unnecessary noise for the mixin (/ function) name itself, and it feels cleaner without it IMO.

Edit:

Btw, one reason why Less uses plain mixins and then "selects" the return property is so that:

  1. Any ruleset can be a mixin
  2. Any property can be selected as a return value (similar to an object lookup in JS).
  3. Mixins can be, essentially "datasets" and don't need separate map / array / list constructs, specialized functions, and additional at-rules like Sass

I'm not saying this proposal should include those things; just that more versatility (without too much complexity) can often serve more use cases. You could add "global" (namespaced) variables, functions, and mixins to CSS with a versatile mixin syntax if you treat a mixin as a block that: a) takes in data, b) returns a dataset, c) can be referenced in whole or in part.

For instance, you could do:

@mixin colors {
  --primary: blue;
  --secondary: white;
}

.button {
  background: apply(colors, --primary);
  color: apply(colors, --secondary);
}

At the end of the day, mixins are a collection of keys & values, with or without parameters. So the ability to access individual values can lead to really powerful patterns and style organization, such as this spitballing example:

@layer my-thing {
  @mixin ui {
    @mixin shadows {
      --depth-1: rgba(149, 157, 165, 0.2) 0px 8px 24px;
    }
  }

  .dropdown {
    box-shadow: apply(ui shadows, --depth-1);  
  }
}
mirisuzanne commented 2 months ago

the names would never clash with any future CSS syntax (the names are user-defined by definition)

There have already been proposals for CSS-provided mixins, which would lead to this naming conflict. I don't think it's safe to say these are guaranteed to be user-defined long-term.

I do see advantages to the mixins-as-functions approach. The colors example is especially compelling to me. But it does make the simple function syntax significantly more verbose:

.example {
  background: --stripes(powderblue, pink, white);
  background: apply(--gradient from --stripes(powderblue, pink, white));
}

I'm curious how much it would complicate (or simplify?) implementation.

DarkWiiPlayer commented 2 months ago

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.

Prefixing works, but I'm wondering if there's any proposal out there to provide some sort of name scoping. I know CSS modules are a thing but from what I understand those are only meant for using CSS inside JS, but I'm specifically looking for something that lets me, as a CSS author, write a mixin like --button and not have to worry that a user might <link> another stylesheet that also defines that mixin.

Without something like that, I feel like this feature would be a lot less convenient than it could ideally be.

brandonmcconnell commented 2 months ago

@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 commented 2 months ago

@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 commented 2 months ago

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

@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 commented 2 months ago

@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 commented 2 months ago

@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 commented 2 months ago

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

@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: https://github.com/w3c/csswg-drafts/issues/10178