The Fundamentals and Dev Experience of CSS @function

Jane Ori Jane Ori on

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.

Image of a bowling lane with bumpers and a bowling ball edited on top of it with arrows bouncing in a zig-zag off of the rails 3 times before hitting the center of the pins likely for a strike

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 the var() fallback to become unreachable.
  • Without calc() wrapping 3.14 on hardcoded assignment to an integer typed argument, it will fail to initial because 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 result doesn’t match its return type, your function will return initial.
  • 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 💚👽.


  1. 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! 🎉 ↩︎

Want to expand your CSS skills?

2 responses to “The Fundamentals and Dev Experience of CSS @function”

  1. Gotcha:
    For now, functions can only return a single property value (or just part of one).

    This will always be the case. There is no plan to allow a function call to expand into multiple property values. (I have no idea where you got this, especially as a “soon, but not yet” thing. Could you link me to your source?)

    (Post-writing edit: Jane clarified to me that she’s referring to using an nth-item() function to extract individual chunks of a return value.)

    Gotcha:
    Custom properties within functions are so private that not even the global registration can type them.

    Not really a gotcha, tho this is indeed valuable to point out. Just means that, like in every programming language ever invented, your function argument names don’t collide with global variable names.

    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.

    What are you imagining it could do instead? We don’t have errors to throw/catch in CSS. It’s certainly intended that DevTools should be able to flag this, tho.

    Could you also expand on why you’d want it to silently ignore extra params? You’re miscalling the function; clearly you intended those extra arguments to do something. Is it better to just leave it silently incorrect, rather than signaling to the user that something is wrong?

    Gotcha:
    If you call a function with too few arguments, it also fails silently instead of leaving you with something to debug.

    Same “what do you think it could do instead?” question.

    It would be great if initial became the default argument instead of the silent-failure DX.

    And same “why do you want it to silently ignore missing params” question. Here, the function author clearly intended the argument to be required, and the user is miscalling it. I suspect that in virtually all cases, the result is the same anyway – the arg would be the guaranteed-invalid value, which will infect any usage of it, and the function will end up returning the GIV, which is what it does today when you call it with too few args.

    The difference is, DevTools can flag a too-few-args call as being invalid, while if all arguments were implicitly optional, it couldn’t.

    Gotcha:
    In the global behavior, a registered variable referenced with the var() function causes the var() fallback to become unreachable. 😵‍💫🪦

    This is being fixed – we resolved a little bit ago to make the ‘initial-value’ descriptor optional, meaning it can default to the GIV (and thus will trigger fallbacks). So this isn’t anything specific to functions. (The function behavior was part of the argument for it, tho, since function args could take a type but still default to the GIV.)

    (I need to actually go make that edit, whoops.)

    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.

    Nothing incorrect here, but it seems irrelevant to call out or to trigger at all. You’ve typed the hidden arg as <integer>, so it’s not clear why you’re trying to set it to 3.14.

    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.

    Incorrect, it’s all arbitrary-substitution functions, which custom functions are part of. This includes var(), attr(), and if(); they all act this way.

    Gotcha:
    There is an implemented syntax to deliberately cause anti-spreading by wrapping them in curly braces.

    Correct, tho I’d talk about this differently. The {} thing is unrelated to the spreading behavior; it’s about passing a value that contains a literal comma in it as a single argument. Like, mechanically it’s sorta the converse, but in practice it’s pretty unrelated. It belongs next to discussions about var-passing and spreading, but not in the middle of it.

    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,

    This seems to be completely missing discussion of the ... spread syntax here.

    All the gotchas in “Function Results” are fine, no notes.

    • Jane Ori Jane Ori says:

      Thank you so much for sharing your perspectives, Tab!

      Gotcha:
      Custom properties within functions are so private that not even the global registration can type them.
      Not really a gotcha, tho this is indeed valuable to point out. Just means that, like in every programming language ever invented, your function argument names don’t collide with global variable names.

      I’m referring to custom properties within the body of the function not having a way to be typed by @property – Reframing it to imply we’re talking about the function arguments here is a bit of a strawman though obviously not intentional.

      Could you also expand on why you’d want it to silently ignore extra params? You’re miscalling the function; clearly you intended those extra arguments to do something. Is it better to just leave it silently incorrect, rather than signaling to the user that something is wrong?

      Absolutely! If you get back something that’s clearly wrong, it’s far easier to debug because you can see what the function produced and backtrace it to the arguments.
      I’m not entirely sure about a scenario where it would be silently incorrect, presumably the function would return something invalid for the standard property it’s ultimately being used in.
      Once we have spreading in any form, it will be even more important to be able to debug because the arguments may be incorrectly produced by a separate function call before hand.

      [re: too few fail] Same “what do you think it could do instead?” question.

      It could be great if arguments not provided default to initial by default. Firstly for the same reasons above, we’d, be able to “result early” to bisect the function and find the failure at the point the missing argument is used. This is how I’ve been debugging them (and I’ve had to do a ton of debugging since I’ve written over 100 functions) since there are no devtools yet 🙂

      Second though, defaulting unprovided arguments to initial is just a nice-to-have since deliberately defaulting them to initial is such a useful pattern (described later in the article!).

      What are your initial thoughts/feelings here? Seem like a reasonable tradeoff?

      DevTools can flag a too-few-args call as being invalid, while if all arguments were implicitly optional, it couldn’t.

      This is an excellent counter point that I’d happily trade for if it gets implemented! I was writing about the current developer experience and didn’t conceive that we’d see useful CSS errors (somewhere?)

      we resolved a little bit ago to make the ‘initial-value’ descriptor optional

      Amazing! Yay!

      I need to actually go make that edit

      I’d love to see it, and I’m sure everyone else here too would, if you don’t mind commenting with a link to the change once it’s in! Thank you!!

      You’ve typed the hidden arg as , so it’s not clear why you’re trying to set it to 3.14.

      absolutely valid to call this out! I was mostly just demonstrating that the behavior is different from a property typed by @property since it becomes initial instead of initial-value’s value.
      Every difference is a caveat, though this specific one could have been better served if I authored it with a common typo in something like
      rgba(255, 0, 0 / 0.25)
      where the same gotcha is relevant.

      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.
      Incorrect, it’s all arbitrary-substitution functions, which custom functions are part of. This includes var(), attr(), and if(); they all act this way.

      Hm. Valid… though a bizarre argument… That’s like saying “for” “if” and “${}” in js are typical functions. The expectation of writing a custom function in CSS is to return a value, which is to be used in a property, so the parallel is to a “normal” function in all other languages, not some arbitrary mechanic built into the language…
      rgb(), all other color functions, light-dark(), etc etc etc are the direct parallel DX that custom functions are enhancing.

      It’s super weird to conform an author’d custom function to the language-directive / flow-control kind of “if()” or “var()” argument rather than to the most common mindset of something akin to “rgb()”.

      (IMO – Not claiming anything objective here!)

      mechanically it’s sorta the converse, but in practice it’s pretty unrelated

      If they spread by default (as you originally said was the expectation, and I agree it is), that anti-spread/converse DX understanding would be more clear.
      In my personal experience, it’s directly related.

      This seems to be completely missing discussion of the … spread syntax here.

      This article is about the current DX – As soon as it’s implemented, I PROMISE, I will not shut up about it and there will be plenty of articles and an avalanche of incredible things coming from it. It is the single most exciting CSS feature I have ever read the spec for. I can’t wait! 💚

      Thank you again, Tab, for taking the time to read this and share your feedback. Your spec work is pretty much my mind’s bible. The whole internet should appreciate what you’ve done for the web.

Leave a Reply

Your email address will not be published. Required fields are marked *

$966,000

Frontend Masters donates to open source projects through thanks.dev and Open Collective, as well as donates to non-profits like The Last Mile, Annie Canons, and Vets Who Code.