The Fundamentals and Dev Experience of CSS @function
The Fundamentals and Dev Experience of CSS @function êŽë š
CSS has introduced functions so authors can encapsulate and reuse property behaviors across their style sheets without duplicating the code or polluting the DOM with single-use intermediate --_variables[1].
There are a lot of really cool and useful things we can do with functions. In this fundamentals article, we will go over several CSS gotchas that form the bumpers on our bowling lane for the strike weâll hit in the follow-up, and get a good sense of what they are and what they arenât.

This is a custom CSS function:
@function --hello-world() {
result: "Hello World";
}
And this is how you call it:
body::after {
content: --hello-world() "!";
}
Soon, but not yet, we will be able to set multiple different properties with distinct values from a single function call by returning a comma-separated result and splitting it into multiple properties.
Gotcha
For now, functions can only return a single property value (or just part of one).
If you set the value of a --variable by calling a custom function, you can reference that --variable any number of times and copy the same singular result wherever you need it.
Function Encapsulation
You can set intermediate variables inside the function to help with the final result:
@function --the-answer() {
--a: 4px;
--b: 10;
--c: 2px;
result: calc(var(--a) * var(--b) + var(--c));
}
Those intermediate variables do not leak onto the element; they are internal, private variables:
body {
padding: --the-answer();
/* --a, --b, and --c are NOT defined here! */
}
Gotcha
Custom properties within functions are so private that not even the global registration can type them.
@property --a {
syntax: "<color>";
initial-value: hotpink;
inherits: true;
}
@function --the-answer() {
--a: 4px; /* uses the value 4px and doesn't break */
--b: 10;
--c: 2px;
result: calc(var(--a) * var(--b) + var(--c));
}
body {
padding: --the-answer();
--a: 4px;
background: var(--a);
}
The bodyâs padding is 42px and the background is hotpink.
Function Arguments
You can call functions with arguments:
body {
padding: --the-answer(99);
}
Gotcha
The function above fails silently instead of a more friendly DX that leaves you with something to debug. You cannot call a function with more parameters than the @function defined.
They might, hopefully, improve the DX here and instead just ignore extra parameters in the future! đ€đœ
Fortunately, defining any number of arguments is easy:
@function --the-answer(--arg1, --arg2, --arg3) {
--a: 4px;
--b: calc(var(--arg1) - var(--arg1) + 10);
--c: 2px;
result: calc(var(--a) * var(--b) + var(--c));
}
Gotcha
If you call a function with too few arguments, it also fails silently instead of leaving you with something to debug.
But, you can give the arguments default values, and then they become optional:
@function --the-answer(--required, --arg2: 0px, --arg3: initial) {
--a: 4px;
--b: calc(var(--required) - var(--required) + 10);
--c: calc(clamp(1px, round(var(--arg2)), 1px) * 2);
result: calc(var(--a) * var(--b) + var(--c));
}
body {
padding: --the-answer(99);
}
The bodyâs padding is a resilient 42px.
The initial value is particularly useful as a default because it allows you to use var(--arg3, fallbacks) in the implementation and branch the behavior.
It would be great if initial became the default argument instead of the silent-failure DX.
You could also branch by using if(style()) on the arguments with several more gotchas.
Argument Typing and Fake Arguments for Typed Encapsulation
You can specify argument types, and as a hack before official alternatives, intentionally add superfluous, undocumented, unused, typed parameters (with defaults) to have pseudo-registered var behavior inside the function (since the global registration doesnât reach inside):
@function --divide-by-3(--a <number>, --_pi-ish <integer>: -1) {
--_pi-ish: calc(3.14);
/* ^ becomes 3 because it's an integer type */
result: calc(var(--a) / var(--_pi-ish));
}
Typed vars inside a function have a critically fantastic benefit over the usual registered var â they become initial if the value doesnât compute into the specified type, which means you can use computed fallbacks!
Gotcha
In the global behavior, a registered variable referenced with the var() function causes the var() fallback to become unreachable. đ”âđ«đȘŠ
@property --a {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
body {
--a: pizza;
--divide-by: 3;
opacity: var(--a,
calc(
1 / var(--divide-by)
)
);
}
Opacity resolved to 0 because the calc() in the fallback is unreachable.
Compare this to a custom function implementation:
@function --opacity(--a <number>, --divide-by <integer>: -1) {
--divide-by: calc(3.14);
result: var(--a, calc(1 / var(--divide-by)));
}
body {
opacity: --opacity(pizza);
}
Opacity resolves to 0.3333 because pizza isnât a number so --a became initial and the fallback calc() was executed instead.
Gotcha
Without that calc() wrapping 3.14, an integer-typed argument will fail to initial because the decimal syntax is rejected as non-integer before computed value time.
@function --opacity(--a <number>, --divide-by <integer>: -1) {
--divide-by: 3.14;
result: var(--a, calc(1 / var(--divide-by, 2)));
}
body {
opacity: --opacity(pizza);
}
Opacity resolves to 0.5 because pizza isnât a number so --a became initial, the fallback calc() was executed, and --divide-by also used its fallback of 2 because the 3.14 assignment failed.
Comma-Separated Arguments
Gotcha
The only place in all of CSS where a variable doesnât effectively expand in place is in the parameters when calling a custom function.
body {
--rgb: 0, 255, 0;
background: rgb(var(--rgb));
}
The background is bright green.
@function --rgbFn(--r, --g, --b) {
result: rgb(var(--r), var(--g), var(--b));
}
body {
--rgb: 0, 255, 0;
background: --rgbFn(var(--rgb));
}
The function call failed because all 3 parameters were stuffed into the âr argument. I am very hopeful this will be fixed (w3c/csswg-drafts).
Gotcha
There is an implemented syntax to deliberately cause anti-spreading by wrapping them in curly braces.
@function --rgbFn(--rgbArg) {
result: rgb(var(--rgbArg));
}
body {
--r: 0;
--g: 255;
--b: 0;
background: --rgbFn({ var(--r), var(--g), var(--b) });
}
The background is bright green. For consistency, it was originally planned and briefly even implemented by Anders of the Chrome team (who has implemented almost every awesome feature Iâve played with over the years!) that comma-separated var() values auto-spread just like normal, so you would wrap var() with the braces intentionally for the same anti-spread effect.
@function --rgbFn(--rgbArg) {
result: rgb(var(--rgbArg));
}
body {
--rgb: 0, 255, 0;
background: --rgbFn({ var(--rgb) });
}
The background is bright green.
This anti-spread around variables is still implemented, so it would be a great idea to wrap your comma-separated var() arguments (csvarguments) in curly braces ahead of the restoration/fix if they move forward with it. Though apart from a custom repeat function and a custom loop function, there are currently no use â because there is no processing possible yet â and so it must be used as-is. That is, there is no functionality a standard --var canât already do to a csvargument, making it pointless to pass to a custom function. So you probably havenât done that yet.
Once csvarguments spread for calling custom functions like they do for calling standard functions (and like they do for everything else in CSS), we will have hundreds of new possibilities available to us, including returning multiple values from a single function call since we could trivially make an --nth-item() function to pick each piece returned from a list.
@function --nth-item(--nth, --p0, --p1) {
result: if(
style(--nth: 0): var(--p0);
style(--nth: 1): var(--p1);
else: black;
);
}
body {
--x: 1;
--arrayOfArgs: skyblue, lime;
--bg: --nth-item(var(--x), var(--arrayOfArgs));
background-color: var(--bg);
}
Thatâs the majority of our lane! Weâre a bit in the weeds of the CaveatSandStorm but if you have followed this and can navigate these behaviors, youâre far, far along the path to mastering CSS variables and scraping the potential of custom CSS functions.
Function Results
Here are a few more notes for the foundation of custom CSS functions. We can also specify the type of the result with a returns directive after the arguments:
@function --opacity(--a <number>, --divide-by <integer>: -1) returns <number> {
--divide-by: calc(3.14);
result: var(--a, calc(1 / var(--divide-by, 2)));
}
body {
opacity: --opacity(pizza); /* 0.3333 */
}
Gotcha
Like the arguments, if your result doesnât match the return type, your function will return initial.
Functions can call other functions.
Gotcha
Functions canât currently call themselves. No recursion is allowed because CSS treats it as cyclic and fails to initial.
Gotcha
Functions can return a value and you canât pass that value back into the same function elsewhere. This feels like a bug and is alarming. Until thatâs fixed, most math-based custom functions that arenât trivial calc()s are DOA along with anything empowering dynamic composition. Pre-publish edit: Tab has chimed in on the Chrome technically-not-a-bug that I filed and identified it as a spec-level-bug and they will fix it soon (w3c/csswg-drafts)!
@function --add-a-quarter(--a <number>) returns <number> {
result: calc(var(--a) + 0.25);
}
body {
--quarter: --add-a-quarter(0);
--half: --add-a-quarter(var(--quarter));
opacity: var(--half);
}
--half is initial đ”âđ«đȘŠđ (this will work correctly at a later date!)
The Gotcha Cascade
To review the developer experience of CSS Custom Functions, here are all the gotchas we ran into just covering the basics.
- Soon, but not yet, we will be able to set multiple different values from a single function call.
- Variables internal to a function are so private that not even the global registration can type them.
- Calling a function with too many parameters fails silently instead of returning something for you to debug.
- If you call a function with too few arguments and those arguments donât have default values, it also fails silently instead of leaving you with something to debug.
- In the global behavior, a registered variable referenced with the
var()function causes thevar()fallback to become unreachable. - Without
calc()wrapping3.14on hardcoded assignment to anintegertyped argument, it will fail toinitialbecause the decimal syntax is rejected as non-integer before computed value time. - For the moment (
w3c/csswg-drafts), the only place in all of CSS where a variable doesnât effectively expand in place is in the parameters when calling a custom function. - There is an implemented syntax to deliberately cause anti-spreading of csvarguments by wrapping them in curly braces.
- Like the arguments, if your function
resultdoesnât match itsreturntype, your function will returninitial. - Functions canât currently call themselves. No recursion is allowed. CSS treats it as cyclic and fails to
initial. - Functions can return a value and you canât pass that value back into the same function elsewhere. This is a spec bug, and is being fixed by Tab (
w3c/csswg-drafts)! đ
Just the Beginning
Overall, the DX for CSS custom functions, as they are now, is ⊠not good. But thereâs a ton of potential and a lot you can do now, even if itâs mostly shallow.
Thatâs the foundation, next time I will dive into what Iâm most excited to share with you; The Scope of CSS @âfunction. Until then, I invite Open Contact (janeori.propjockey.io) đđœ.
CSS Library and Component Authors have long used the convention of underscores before or after a prefix on CSS variables to distinguish private/internal behavior vs dev-user exposed API variables. Now, we have an official lane for private variables! đ â©ïž