The Scope of CSS @function

Jane Ori Jane Ori on

We’re going to walk through some advanced patterns for using @function in CSS in this article that help you deliver awesome DX to your component or library users. You’ll need an understanding of CSS function foundations and limitations to follow along with the gold here.

Article Series

The Variable Scope of Custom CSS Functions

Variable scope in custom CSS @ functions is really fascinating. In normal CSS, --vars inherit from parent elements down to child elements. The children can freely use the inherited --vars.

html {
  --theme: light;
  --size-1: 1rem;
}
html div {
  --palette-1: if(
    style(--theme: dark): black;
    else: white;
  );
  font-size: var(--size-1);
}Code language: CSS (css)

Similarly, in JavaScript, a function defined inside of a context inherits all of the variables from the context it’s defined in, to be used freely within the function. Colloquially in JavaScript, this context that variables are inherited from is called the closure scope.

let html, div

html = () => {
  const theme = "light"
  const size1 = "1rem"

  div = () => {
    const palette1 = theme === "dark" ? "black" : "white"
    const fontSize = size1
    return { palette1, fontSize }
  }

  return { theme, size1 }
}Code language: JavaScript (javascript)

Traditional CSS var() usages relies heavily on patterns and expectations from your DOM structure.

That is, DOM structure determines what context a child expected to exist under and therefore determines which vars you can guarantee will be inherited from the parents and safe to use in the child.

CSS Functions Have a Fascinating Superpower with Scope

They experience an inherited sort-of-closure scope from wherever they’re called. This exposes their internal work to all of the variables in any context they’re called from, as if they were a child element in the DOM and inherited them!

In JavaScript, it’s as if the definition for the child function was kept as a string and passed through eval() wherever it’s used.

An evaluation scope, so to speak.

let html, div

const fn = `() => {
  const palette1 = theme === "dark" ? "black" : "white"
  const fontSize = size1
  return { palette1, fontSize }
}`

html = () => {
  const theme = "light"
  const size1 = "1rem"

  div = eval(fn)

  return { theme, size1 }
}

html().size1 === div().fontSize
// trueCode language: JavaScript (javascript)

In CSS:

@function --palette-1() {
  result: if(
    style(--theme: dark): black;
    else: white;
  );
}
@function --font-size() {
  result: var(--size-1);
}

html {
  --theme: light;
  --size-1: 1rem;
}
html div {
  --palette-1: --palette-1();
  /* white */
  font-size: --font-size();
  /* 1rem */
}

.evaluation-scope {
  --theme: dark;
  --size-1: 20px;

  --palette-1: --palette-1();
  /* black */
  font-size: --font-size();
  /* 20px */
}

.evaluation-scope div {
  --palette-1: --palette-1();
  /* black */
  font-size: --font-size();
  /* 20px */
}Code language: CSS (css)

This is Awesome

Any component you build could have CSS functions associated with it. Those functions only ever get called from within the component, so their implementation can rely on a guaranteed evaluation scope from your component. 🀌 I love this.

Pause here for a moment to feel it if it’s not understood yet, because we’re about to build on this concept in an even cooler way!

You can Decouple Evaluation Scope from the DOM Entirely!

If you imagine defining a CSS function that’s only ever meant to be called from inside of another function, the potentially complex internals of the parent function becomes a pseudo-permanent evaluation scope for the child function.

A single parent function might call any number of related inner functions based on simple if(style()) switches:

@function --parent(--whichFn, --arg1, --arg2) {
  /* execute a common set of operations */
  /* for this portable evaluation scope */
  /* that computes multiple shared vars */

  --wow: calc(var(--arg1) * 2);
  --pow: pow(var(--wow), var(--arg2));

  /* Then branch execution of child fns */
  result: if(
    style(--whichFn: childA): --childA();
    style(--whichFn: childB): --childB();
    style(--whichFn: childC): --childC();
    style(--whichFn: child64): --child64();
    else: "Unknown Function";
  );
}

@function --childA() {
  /* do something with the complex vars */
  /* from the portable evaluation scope */

  result: calc(var(--wow) + var(--pow));
}

@function --childB() {
  /* The same portable evaluation scope */
  /* but computes a different series of */
  /* operations unique to --childB() fn */
  --many-more-steps: 9;

  result: calc(
    var(--pow) -
    var(--wow) +
    var(--many-more-steps)
  );
}Code language: CSS (css)

This switch function “parent” could get a little heavy and gross for DX if you intend to expose it directly as an API endpoint in your library or component documentation. Each of the args might serve different purposes depending on the child, while still sharing a series of common steps.

But that’s only a concern if you stop there! Take it one tiny step further:

Create outerA and outerB functions as a sort of Partial Application (or taken all the way to pseudo curried functions if needed).

@function --outerA(--arg1, --arg2: 10) {
  result: --parent(childA, --arg1, --arg2);
}
@function --outerB(--arg1, --arg2: 1) {
  result: --parent(childB, --arg1, --arg2);
}Code language: CSS (css)

These outer functions become your API to use throughout the code base instead of using the switch function directly.

You keep the complexity of a switch board API hidden, you keep the child functions relying on it hidden (because they rely on the portable evaluation scope and can’t be called from anywhere else), and your final delivered DX is only using the individual outer functions. It’s 🀌 as close to trivial as you can get while packing in serious complexity.

In short, you create a Portable Evaluation Scope for internal use, and it’s completely hidden from your users’ DX. πŸ‘½

Real World Use Case

@propjockey/doubledash.css has bitwise operations on 16bit integers. Internally, there is a single “bitwise” switch function:

@function --dd-bitwise(--dd-int16-a, --dd-int16-op, --dd-int16-b: 0, --dd-int16-set-bit-to: 0) {
  --_dd-a-sign: sign(var(--dd-int16-a));
  --_dd-a-down-to-int: clamp(
    0,
    round(down, abs(var(--dd-int16-a)), 1),
    pow(2, 16) - 1
  );
  result: if(
    style(--dd-int16-op: right): calc(
      var(--_dd-a-sign) * clamp(
        0,
        round(down, var(--_dd-a-down-to-int) / pow(2, var(--dd-int16-b)), 1),
        pow(2, 16) - 1
      )
    ); else: --_dd-bw-split-a(
      var(--dd-int16-a),
      var(--dd-int16-op),
      var(--dd-int16-b),
      var(--dd-int16-set-bit-to)
    );
  );
}Code language: CSS (css)

If the operation is a right shift, it just uses division and powers to return the result, for all other operations, it adds a portable exposure scope layer by calling another internal --_dd-bw-split-a() which splits the int16-a argument into 16 individual bits.

Operations like bitwise “NOT” flip each of the bits, calc() it back into a new int, and that’s the result.

Other operations like bitwise “XOR” call a third internal function, adding another portable exposure scope layer to also split the int16-b argument into 16 more individual bits, and each bit from both evaluation scopes are xor’d together for the final result.

This pattern allows encapsulation and segregation of functionality, the split-b function only splits the argument into bits, and then it calls yet another function to handle the actual switch board from the original internal call to bitwise.

The exposed API in doubledash’s documentation has a bare minimum implementation and it’s the only thing dev users of the library ever see.

Portable Evaluation Scope as a feature

You may want to actually provide a complex portable evaluation scope for your developer users to take advantage of with their own functionality extended from it.

This necessarily exposes the switch function to your dev user so standardized arguments across the shared switch function set are more important.

In JavaScript terms, you’d pass a reference to your custom function (as a string) into the library function and it would eval(yourFnStr) in place. This is something you wouldn’t do in JavaScript because of security concerns, but evaluation scope is baked into CSS without security concerns so we get to have all the fun we want!

However, in CSS we can’t currently pass functions by reference; Just like vars, there is no dynamic/interpolated references allowed yet, so this doesn’t work:

@function --dev-user-fn() {
  result: var(--use-portable-exposure-scope) " world!";
}

@function --library-fn(--fn) {
  --use-portable-exposure-scope: "Hello";
  result: --fn();
}

body::before {
  content: --library-fn(--dev-user-fn);
}Code language: CSS (css)

It’s another unfortunate gotcha since it would be incredibly useful.

So to do this anyway because we ignore “can’t” as a philosophy, as the library or component author, we just set up the switch board function to call functions that aren’t defined.

Your developer user defines them. You document what your portable evaluation scope provides for them to use, and you’re both golden. πŸŽ‰

Gotcha: You do NOT want to simply pass arguments to your functions on the switch board because, as detailed in the previous article, if you call a function that doesn’t have each of those arguments defined, the call will fail without any way to debug it other than just knowing that’s why. This is bad DX. Portable evaluation scope instead allows your users to quickly define the function in minimal syntax, without parameters, and just use whichever variables they need.

In short, your dev user calls your switch board containing the portable evaluation scope, pass in the identifier, and it executes the corresponding function they’ve defined.

Real world use case

The world’s first 100% CSS loops in doubledash do exactly this.

It exposes a --dd-loop() function for dev users which takes a library-defined switch key like --dd-loop-id-7 as one of the arguments. Internally it does all the complexity to make it possible to iterate in a static cascading language, then ultimately checks the switch key and executes the corresponding dev-user-provided function that defines the body of their loop function.

Since there is no way to dynamically pass functions yet, the library provides 64 unused function slots in the switch so dev users can implement up to 64 global definitions for unique use cases of loops. The portable evaluations scope of the loop provides the current iteration index and current β€œx” value, among other details.

CSS does not compute what isn’t used.

As shown in the Pen, the switch board API function comes with a bonus. Dev users can set a variable like

--alias: --dd-loop-id-0;

so when they call your switch board to use the portable evaluation scope, they pass in their var(--alias) as the key and your switch board knows what to do with it automatically. This helps keep things organized if the dev user plans on using more than one or two loops.

DX Gotcha: you can’t provide variables to override the alias and let them pass the alias directly as an identifier because if(style()) can’t check if --arg === var(--alias-1), it must (currently) only check hardcoded values. Unfortunate DX but at least the var(--alias) option is a step in the right direction.

Wrapping the Portable Evaluation Scope

Your dev users can wrap the call to your switch board with a custom function of their own that defines parameters however they see fit, and those parameters become part of the evaluation scope of the child function they defined.

In this 100% CSS Pen, which is originally PostCSS artwork by Ana Tudor, the dev user’s --particles() function defines arguments, then calls doubledash’s library loop function, which exposes the arguments to the --dd-loop-id-0() function body as part of its portable evaluation scope.

This DX feels similar to defining a C header file separately from the implementation.

Just like the bitwise functions where we provided individual OuterA and OuterB functions, your switch board portable evaluation scope could be wrapped by the dev user multiple different ways as well. For example, --dot-particles() and --star-particles() could use the same underlying library function with different arguments defaulted.

It’s almost function overloading but the final functions need unique names.

Actually Overloading Signatures

If you define generic variable arguments like --arg1 and --arg2 and make them optional by providing default values, in your documentation, you can lie about what the arguments are called in different signatures.

For example, the simple --dd-repeat() function in doubledash does this when it makes the middle parameter optional as API documentation, but technically it’s the 3rd argument that’s optional.

Internally, it resolves the arguments to reasonable variable names for use deeper in the function.

@function --dd-repeat(--dd-total, --_dd-arg2, --_dd-arg3: initial) {
  --dd-data: var(--_dd-arg3, var(--_dd-arg2));
  --dd-joiner: if(style(--_dd-arg3): if(
    style(--_dd-arg2: none): ;
    style(--_dd-arg2: comma): ,;
    else: var(--_dd-arg2, );
  ); else: ;);

  ...

  result: ...;
}Code language: CSS (css)

As suggested in the previous article, using initial as the default argument value makes this specific case fairly easy.

Data uses argument 3, unless it’s not defined, then argument 2 is data.

Similarly, joiner uses if(style()) to check if argument 3 is defined and defaults to an inert <empty> whitespace if it’s not.

The End

Evaluation scope is a mighty powerful tool for custom CSS functions. Even though the foundational DX of custom CSS functions is currently overflowing with brutal caveats, this evaluation scope is absolutely a 10-pin strike worth exploring.

In the end, if you’re shipping DX that’s better than the underlying technology that you’ve bent to your will, it is a satisfying win.

Your documentation stays on the surface and says to your users:

Everything I can do, now we can both do without needing to do what I had to do because now this does all of that for both us, and neither of us have to worry about any of it. 😁

In other words, I make things that help people make things.

Please do reach out if you enjoyed this, have questions, or want to show off what you’ve done: I invite Open Contact πŸ’šπŸ‘½

Want to expand your CSS skills?

Leave a Reply

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

$966,000

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