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 --_variables1.
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";
}Code language: CSS (css)
And this is how you call it:
body::after {
content: --hello-world() "!";
}Code language: CSS (css)
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));
}Code language: CSS (css)
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! */
}Code language: CSS (css)
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);
}Code language: CSS (css)
The body’s padding is 42px and the background is hotpink.
Function Arguments
You can call functions with arguments:
body {
padding: --the-answer(99);
}Code language: CSS (css)
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));
}Code language: CSS (css)
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);
}Code language: CSS (css)
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));
}Code language: CSS (css)
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)
)
);
}Code language: CSS (css)
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);
}Code language: CSS (css)
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);
}Code language: CSS (css)
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));
}Code language: CSS (css)
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));
}Code language: CSS (css)
The function call failed because all 3 parameters were stuffed into the –r argument. I am very hopeful this will be fixed.
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) });
}Code language: CSS (css)
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) });
}Code language: CSS (css)
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);
}Code language: CSS (css)
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 */
}Code language: CSS (css)
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!
@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);
}Code language: CSS (css)
--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, 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! 🙏
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 💚👽.
- 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! 🎉 ↩︎
