vielle.dev

CSS functions and mixins

tl;dr and my 2 cents


The CSS @function rule is now available in chrome 139, with a definition for @mixin in the editors draft. (Referenced First Public Working Draft, Referenced Editors Draft)
I wanted an excuse to read into a spec, and this seems quite an interesting one so I thought why not write a post about it! (this is definitely not also because I need something to post when i deploy my site so it doesn’t look empty lmao)

All ye who travel beyond this point, head this warning:
This spec got hands and idk how to shut up so this is a long ish post. Sorry! Also sorry about any syntax highlighting bugs. This is an experimental spec and I’m using nesting too, which might cause visual bugs with my syntax highlighter. This should resolve with time though. I hope.


1. What’s in a function anyway?

CSS has a bunch of functions (rgb, anchor, blur, and var to name a few), which are all free of side effects and consistent*, but are fully defined in the spec, without any way for developers to specify their own functions.

*CSS random, as discussed in this webkit post, isn’t always consistent (it’s a random function after all) however it is 1) not in anything except safari TP so I’m not gonna count it and 2) I Cant Find Any Actual Spec. So. also not gonna count it.

Imagine this css:

h1 {
  font-size: 3em;

  @media (max-width: 1200px) {
    font-size: 2em;
  }
  @media (max-width: 600px) {
    font-size: 1em;
  }
}

img {
  width: 1000px;

  @media (max-width: 1200px) {
    width: 800px;
  }
  @media (max-width: 600px) {
    width: 450px;
  }
}

.screen-mode::after {
  content: "desktop";

  @media (max-width: 1200px) {
    content: "tablet";
  }
  @media (max-width: 600px) {
    content: "mobile";
  }
}

While this isn’t the most realistic css (the font size probably doesn’t need to change, the image could be more fluid with %/vw/vh/etc, and you would basically never need to do that with ::after), it’s useful for demonstration of a potentially useful function.

Below we’re going to start defining a function which will take in 1 to 3 parameters, and return values matching the media queries above (ie: if it matches 600px it’ll return --mobile, falling back to --tablet if not defined, and falling back to --desktop if thats not defined, etc) It’ll make more sense when we define it

@function --media(--desktop, --tablet, --mobile) {
  /* function body here */
}

This CSS defines a new function, which we could later use like width: --media(1000px, 800px, 450px), however currently the arguments don’t matter, and the result is the “guaranteed-invalid value” (same as if you use something like var(--not-set) without a fallback) and so won’t work.

For this to be useful, we need a result. result is a property which can be used within @function to define the output of the function. A simple negation function could look something like this:

@function --negate(
  --value type(<length> | <number> | <length-percentage>): initial
) {
  result: calc(-1 * var(--value));
}

But wait, I hear you ask, what does all that stuff after --value mean???
CSS functions let you define a type and default value for a function. I’ve used the type definition of type(<length> | <number> | <length-percentage>) to tell the browser that if you pass in a value that isn’t either a length, number, or length percentage, then it should treat the value as invalid. (note: browser dev tools for this aren’t the best right now, but this will come with time)
The : initial

Going back to our --negate function, usage like --negate(10px) returns -10px, and --negate(-5%) returns 5%, however something like --negate(twenty) wouldn’t work, as twenty is not of type number, and even if we didn’t have the type, calc(-1 * twenty) makes no sense, and would be invalid.

So we can see that result simply defines the output for the function. This is great for making complex, repeated calculations more legible, and for repeating reused syntax (result: 10px 10px 5px var(--colour, black) could be used for a --shadow function), but doesn’t solve our function, which does media query things. Thankfully, @function supports @media inside it (and some other @rules)

@function --media(
  --desktop,
  --tablet: var(--desktop),
  --mobile: var(--tablet)
) {
  result: var(--desktop);

  @media (max-width: 1200px) {
    result: var(--tablet);
  }
  @media (max-width: 600px) {
    result: var(--mobile);
  }
}

This above code returns desktop, unless the screen is <1200px, in which case it returns tablet, and if the screen is <600px, it returns mobile. If mobile isn’t defined, it’s the same as tablet, and if tablet isn’t defined, its the same as desktop.

We can now replace our original example with an updated --media() function!

h1 {
  font-size: --media(3em, 2em, 1em);
}

img {
  width: --media(1000px, 800px, 450px);
}

.screen-mode::after {
  content: --media("desktop", "tablet", "mobile");
}

Another thing worth noting about @function is that it can access custom properties from the “calling scope”:

@function --double--x() {
  result: calc(2 * var(--x));
}

h1 {
  --x: 2em;
  font-size: --double--x();
}

Keep in mind that arguments “shadow” the custom properties.

@function --double--x(--x) {
  result: calc(2 * var(--x));
}

h1 {
  --x: 2em;
  /* wont work because --x is not passed in */
  font-size: --double--x();
}

If you want to be able to use custom properties when no argument is provided, you can set the default value to “inherit”

@function --double--x(--x: inherit) {
  result: calc(2 * var(--x));
}

h1 {
  --x: 2em;
  /* works because --x is inherited from context */
  font-size: --double--x();
}

As you can see; there’s quite a lot to functions. If you want more specific and detailed information, or anything not explained here (like how to pass in comma seperated lists or more specific type syntax), your best bet is to reference the spec (and eventually MDN) and just experiment!

Mixin? Mixin what? Mixin. Now.

(I don’t know what the title means and at this point I’m too afraid to ask [my brain])

As mixins are experimental and subject to change (and have no browser support yet) I’m not going to go into masses of detail like I did with @function, and instead just hit the highlights.
Also these examples might be stupid I’m not justifying the feature its Fine

Mixin Arguments

The most basic mixin definition is like this:

@mixin --copy-paste() {
  /* properties go here */
}

@mixin is defined in basically the same way as @function:

@mixin --horrible(
  --background <color>: red,
  --accent <color>: blue,
  --scale <number>: 1
) {
  /* properties go here */
}

Unlike @function, mixin properties are exposed under env(), not var(), so theres no risk of conflict and the properties arent injected outside the body. If you want values to be able to be accessed from outsize the mixin, you can still use custom properties.

Mixins can also accept blocks of properties as an argument:

@mixin --mobile(@contents) {
  /* properties go here */
}

And can accept both at the same time:

@mixin --weird(--bg: <color>, @contents) {
  /* properties go here */
}

Mixin Application

When no args are passed/needed, you can do this to @apply a mixin:

body {
  @apply --copy-paste;
}

Passing arguments is basically the same as custom functions:

body {
  @apply --horrible(orange, purple, 2);
}

@content can be used like this:

body {
  @apply --mobile {
    background-color: red;
  }
}

And you can use arguments and @contents together like this:

body {
  @apply --weird(red) {
    color: blue;
  }
}

Mixin Bodies

Mixin bodies are basically inserted wherever @apply is used. Just list the properties you want to include inside the mixin and they’ll be inserted, including custom properties.

You can access the arguments to the function via a dashed indent in env():

@mixin --horrible(
  --background <color>: red,
  --accent <color>: blue,
  --scale <number>: 1
) {
  background-color: env(--background);
  color: env(--accent);
  border: calc(5px * env(--scale)) groove env(--accent);
  border-radius: 50%;
  float: right;
  &:hover {
    background-color: env(--accent);
    color: env(--background);
    border-color: env(--background);
  }
}

You can insert whatever properties were passed into a mixin using the @contents at rule:

@mixin --mobile(@contents) {
  @media (max-width: 600px) {
    @contents;
  }
}

You can also provide a default value for @contents:

@mixin --mobile(@contents) {
  @media (max-width: 600px) {
    @contents {
      &::before,
      &::after {
        display: block;
        background-color: red;
        color: black;
        contents: "Warning: Unused `@apply --mobile`. Include a declaration list";
      }
    }
  }
}

If no declaration list is passed to the mixin, the fallback will be used instead.

In a mixin, if you use custom properties, they are inserted along with other properties. This is useful if you want values to be accessible to the user (ie: exposing a colour derived from input for use elsewhere), but also pollutes the namespace. As such, you should avoid using custom properties when you don’t explicitly want the user to read/override things. As such, use @env to define environment variables scoped to the mixin body.

@mixin --mix-colour(--col1, --col2) {
  @env --mix: color-mix(in oklab, env(--col1), env(--col2));
  color: env(--mix);
}

In this example env(--mix) is only avaliable inside the --mix-colour body, and doesnt pollute the calling context.

Thats about the gist on mixins, its very new and experimental and has 0 implementation so go read the spec yourself lol

Listen to me speak now please thank you

First note: I think these are really good so far, maybe some slight tweaks needed for mixins. I will say that with mixins I’m not the BIGGEST fan of using env(), but if I remember correctly it originally started with an @result at rule or something like that, so I’m pretty sure theres a reason we got an @env etc instead. That said I dont really mind env() to much so its Fine lmao.

I also think its worth considering named @contents blocks instead, maybe something like this:

@mixin --media(--desktop @contents, --mobile @contents) {
  @media not (max-width: 600px) {
    @contents --desktop {
      desktop-fallback: here;
    }
  }
  @media (max-width: 600px) {
    @contents --mobile {
      mobile-fallback: here;
    }
  }
}

body {
  @apply --media() {
      desktop-properties: here;
    } {
      mobile-properties: here;
    }
  }
}

The current syntax of having a single trailing @contents and single trailing {/*properties*/} for cases where only one @contents is needed. This might need some tweaking for legibility etc, but some way to add multiple @contents blocks would be nice. I agree with the enforcing of them being at the end of the argument list though.

Another suggestion, which may be defered to level 2: expose functions to CSS houdini.

For example, something like this:

// index.js
CSS.functions.addModule("css-arrays.js");
// css-arrays.js
registerFunction(
  "--get",
  class {
    static get arguments() {
      return [
        {
          type: "<string>#",
        },
        {
          type: "<number>",
          default: 0,
        },
      ];
    }

    eval(array, index) {
      return array[index];
    }
  },
);

css-arrays.js would run in a custom Worklet environment, most likely a plain ECMAScript environment with no APIs, which may be created and destroyed on each call. It wouldn’t open the door to advanced functionality like --anchor, but could make “simpler” functions ponyfillable, like arrays/objects, or even things like color-mix.

This is NOT a formal proposal, just a suggestion. Might make an issue in the repo later if I can be bothered.


Anyway, to conclude: I’m extremely happy with the reccomended API right now, and I would be happy to see this or something very similar land in browsers. Also let us do functions with JS it would be v good for ponyfilling.