Skip to content

[css-values-5] A way to dynamically construct function calls (<dashed-function> etc) #12806

@LeaVerou

Description

@LeaVerou

Prior art / related:

We already have ident() to construct <ident> values, however there is still no path from an <ident> to a function call, which comes up frequently, and will come up even more frequently once we have mixins and custom functions (see Use cases below).

For context (@tabatkins feel free to correct me if anything here is wrong), since <function-token> includes both the function name and the opening paren, something like this:

--foo: linear-gradient;
background: ident(var(--foo))(white, black);

…would be parsed as an <ident> <(-paren> <ident> <ident> <)-token> and not as the intended <function-token> <ident> <ident> <)-token>.

Now we don't actually want a <function-token> factory in CSS, as that would be incredibly awkward and would break custom property parsing. Instead, we probably want a way to construct the entire function call by passing an <ident> name and a <declaration-value> for the argument(s).

Perhaps, something like:

<call()> = call( <custom-ident>, <declaration-value>)

Alternative names: function-call(), function(), apply()

This would operate on the syntax level: it would literally construct a <function-token> from the ident provided, consume a comma, add all other tokens after it, tuck a <)-paren> at the end, done. Usual IACVT behavior if cycles, invalid etc.
My hope is that making this a syntax-level feature akin to ident() could circumvent a lot of the issues around defining e.g. a mixin-specific feature AND allow it to address multiple other pain points.

Alternatively, we can skip the comma, but with a comma we could later expand to other prelude parameters.

Example Use cases

Dynamic functions and mixin calls

#10006 is an obvious use case, and by far the most important one. In theory, dynamically calling mixins without parameters is already possible via ident(), but this would allow calling mixins with parameters as well.

Passing design systems parameters to components

This can be a game-changer for components & design systems (#10948), as pages can simply

Pave the cowpaths for block @if

This could also pave the cowpaths for block @if without block @if

We already have if(), and we've resolved to accept revert-rule in #10443 (comment) . Together with this, it means we can finally have block conditionals:

.callout.note {
	/* conditionally apply mixin without encoding the condition in the mixin */
	@apply call(if(style(--is-foo: bar): --bar-mixin));
}

This would also eliminate the need for scoped mixins or functions, since CSS variables are scoped, so the scoping can be emulated through references.

Encapsulation / currying

In design discussions we often bring up arguments such as "foo-bar(<args>) and foo(bar <args>) are not meaningfully different".
Except, currently, they are. Because in the latter, bar can become part of a variable that is passed around, but in the former that is not the case.
E.g. you can define a relative color transformation fully in a variable and then apply it via color(from var(--some-color) var(--transformation)) provided the color space is a color() color space. If it's a color space that can only expressed as a function, you cannot do that, every usage point needs to know and repeat the function name.

However, often the function itself is part of the information we want to encapsulate.
Suppose we wanted to implement darkening shades via color-mix(in oklab, <color>, black 10%) or via oklab(from <color> calc(l - 0.1) c h) etc. Currently, turning this into variables would still require that authors using the variables know what general algorithm is used to darken colors:

Method 1:

:root {
	--darker-prefix: in oklab, 
	--darker-suffix: , black 10%;
}

.foo {
	background: color-mix(var(--darker-prefix) var(--color) var(--darker-suffix);
}

Method 2:

:root {
	--darker: calc(l - 0.1) c h;
}

.foo {
	background: oklab(from var(--color) var(--darker));
}

With call(), the calling point doesn't need to know how colors are darkened — it can become an implementation detail:

Method 1:

:root {
	--darker-prefix: color-mix, in oklab;
	--darker-suffix: , black 10%;
}

.foo {
	background: call(var(--darker-prefix) var(--color) var(--darker-suffix));
}

Method 2:

:root {
	--darker-prefix: oklab, from ;
	--darker-suffix: calc(l - 0.1) c h;
}

.foo {
	background: call(var(--darker-prefix) var(--color) var(--darker-suffix)); /* same! */
}

Of course, simply encapsulating is done more elegantly with custom functions. The power of something like this is that the actual encapsulated transformations can cascade just like regular variables. For example, --darker may have a different meaning in different contexts, in dark mode, within a callout, etc.

In some cases we can emulate some of that through functions that reference CSS variables on the element, but not in all.

Graceful degradation for new CSS functions

This also comes up when using features in a PE way. E.g. for a feature like gradient color space interpolation, you can do:

:root {
	--in-oklab: ;
	@supports (background: linear-gradient(in oklab, white, black) { --in-oklab: in oklab; }
}

And then define gradients like linear-gradient(to right var(--in-oklab), white, black) and get graceful degradation almost for free.

However, if the feature is a new CSS function we cannot do that. E.g. adopting light-dark() currently is painful if one needs broad browser support, because there is no easy way to fall back to something reasonable without duplicating every single color. And given the amount of color declarations a design system needs to define, that is a significant issue.

A lot of this can also be addressed via custom functions, but with something that cascades, one can choose different fallbacks for different contexts.

Alternative design: use() to wrap in arbitrary tokens

Since this operates on a syntax level, perhaps we could broaden it even more. Why restrict to wrapping in <function-token> and <)-token> when you can wrap in any arbitrary tokens? 😀

Then it could become like a mini cascading function of sorts, but with CSS variable scope and a single predefined argument (or even multiple, via nth-item() (#11103)).
The challenge is that then we need a special token to substitute the value into with var() semantics but without naming conflicts, e.g. var(args):

<use()> = use([ <declaration-value> | 'var(args)' ]+)

Used like (note how much more elegant the call is compared to the call() version):

:root {
	--darker: oklch( from var(args) calc(l - 0.1) c h);
}

.foo {
	background: use(var(--darker) var(--color));
}

Though it seems that even if we pursue this route, there might still be value in call() as a more straightforward MVP.

Challenges

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions