Home

5 Useful CSS functions using the new @function rule

Published on

Introduction

If you thought the if() function was cool, hold on to your hats because CSS functions just landed in Chrome 139! This capability is an absolute game-changer for writing more organized and dynamic CSS.

While custom properties let you store a dynamic value that you access with var(), a custom function can run logic. It accepts arguments you pass to it, and spits out a new value. You call them with a function syntax based on the name you gave it, like --my-custom-function() instead of the var() syntax.

Writing custom functions makes code much neater, especially for building with design systems. So I’m really excited to see this feature land. Let’s take a look at some real-world examples of where this can come in handy.

Negation function

Here is an example of a simple CSS custom function that negates a value, returning the negative (right from the spec):

/* Returns the negative of a value */
@function --negate(--value) {
  result: calc(-1 * var(--value));
}

Then, you can use the --negate() in a declaration, like so:

html {
  --gap: 1em;
  padding: --negate(var(--gap));
}

You can use custom properties in CSS functions, or direct values. You can also have default values, which you will see in some of the examples below.

Opacity function

A while ago, I wrote about a way to use the color-mix() function to create opacity variants for CSS colors (originally based on a tweet from Tailwind’s Adam Wathan asking if there was a more efficient way to do this).

Since then, we got relative color syntax in all browsers, which is the more efficient way. But now we have an even more efficient way: turn it into a CSS function!

This function will convert any color into an opacity variant of that color, and it accepts two values: a color, and an opacity value.

/* Return a semi-transparent value */
@function --opacity(--color, --opacity) {
  result: rgb(from var(--color) r g b / var(--opacity));
}

/* usage */
div {
  background-color: --opacity(red, 80%);
}

/* with custom properties (assuming theme variables) */
.card {
  border-color: --opacity(var(--color-secondary), var(--mostly-opaque));
}

So now you can do:

:root {
  --brandBlue: skyblue;
  --brandBlue-a20: --opacity(var(--brandBlue), 20%)
  /* nice :) */
}

Fluid typography function

Another great use of CSS functions is to make the very popular clamp() method for font sizing a bit more clear and legible. This is a great way to ensure that your text is always readable and looks good on any screen size.

So I wrote a function here to create fluid typography that scales with the viewport width, but also has minimum and maximum size limits. It also provides some options to scale your fonts at different rates. I want headers, for example, to scale faster than copy, so I’m using a 4vw value to size my headers (also the default value), and 0.5vw to size copy. This is shown in the form of a third, optional argument, which takes advantage of CSS if() to make a determination based on the input value.


@function --fluid-type(--font-min, --font-max, --type: 'header') {
  --scalar: if(style(--type: 'header'): 4vw; 
               style(--type: 'copy'): 0.5vw);
  result: clamp(var(--font-min), var(--scalar) + var(--font-min), var(--font-max));
}

h1 {
  --header-min: 24px;
  --header-max: 36px;
  font-size: --fluid-type(var(--header-min), var(--header-max));
}

p {
  --copy-min: 16px;
  --copy-max: 24px;
  font-size: --fluid-type(var(--copy-min), var(--copy-max), 'copy');
}
Toggle the CSS tab open and close to see the difference, or explore on Codepen.

I would say: --fluid-type(var(--copy-min), var(--copy-max)) is a bit easier to read than clamp(var(--copy-min), 4vw + var(--copy-min), var(--copy-max)), wouldn’t you?

Conditionally rounded border

This is one of my new favorite CSS tricks: conditionally removing a rounded border when an element approaches the edges of a viewport. This prevents layout weirdness as it goes full-width instead, and all without the need for any media queries!

I learned about this trick from Adam Argyle’s talk at CSS Day this year, which is also explained in further detail in this blog post by Ahmad Shadeed, and was originally shipped on Facebook by Naman Goel who was inspired by Heydon Pickering. (Love all of my CSS favorites being involved in this one!)


So let’s make it a function!

In this function, when the edge of the box reaches the edge of the viewport (inset by --edge-dist, and set to 4px by default), remove the border-radius. Otherwise, set it to the --radius. This makes use of default values, so you can use it with only one argument for the radius size, or two to override the edge distance default of 4px.

/* Conditionally apply a radius until you are (default: 4px, or specify second argument) from the edge of your screen */
@function --conditional-radius(--radius, --edge-dist: 4px) {
  result: clamp(0px, ((100vw - var(--edge-dist)) - 100%) * 1e5, var(--radius));
}

/* usage */
.box {
  /*  1rem border radius, default (4px) distance  */
  border-radius: --conditional-radius(1rem);
}

.box-2 {
  /*  1rem border radius, right at the edge (0px distance)  */
  border-radius: --conditional-radius(1rem, 0px);
}
Toggle the CSS tab open and close to see the difference, or explore on Codepen.

Layout sidebar function

You can also use custom functions with media queries to return different results based on specific conditions. This function creates a responsive sidebar layout, so you just have to call it once with --layout-sidebar(). On smaller screens, it will take up the full width, but on larger screens, it creates a sidebar of a specified width and a main content area that takes up the remaining space.

Like the conditional radius example above, we’re using a default value here: this time it’s 20ch for the --sidebar-width. This makes it optional to provide a value for the function. If a value is provided, that new provided value is used. If not, the function will fall back to 20ch.

/* Take up 1fr of space for the sidebar on screens smaller than 640px, and take up the --sidebar-width for larger screens */
@function --layout-sidebar(--sidebar-width: 20ch) {
  result: 1fr;
  
  @media (width > 640px) {
    result: var(--sidebar-width) auto;
  }
}

.layout {
  display: grid;
  /* uses fallback value of a 20ch sidebar-width */
  grid-template-columns: --layout-sidebar();
}
Toggle the CSS tab open and close to see the difference, or explore on Codepen.

Bonus: light-dark theming function

Have you ever wanted to use light-dark() for user theme-preference-based styling? But then ran into the unfortunate limitation that light-dark() only works for color values? What if you wanted to use it for something else like background images, or to adjust border-width?

Well, Bramus has you covered with his custom --light-dark() function, which uses a combination of if(), :scope, style() queries, and @function to make light-dark() more extensible. Here’s the base of the function:

/* Function returns the first value if the user's color scheme is light and the second if it is dark */
@function --light-dark(--light, --dark) {
  result: if(
    style(--scheme: dark): var(--dark);
    else: var(--light)
  );
}

First, --root-scheme is set based on the prefers-color-scheme media query at the :root level. This captures the OS setting:

:root {
  --root-scheme: light;
  --scheme: light;

  @media (prefers-color-scheme: dark) {
    --root-scheme: dark;
    --scheme: dark;
  }
}	

Then, using @scope and the inline if() function, this checks the [data-scheme] attribute. If the attribute’s value is system, it applies the value from --root-scheme to the element’s --scheme. Otherwise, it uses the value provided in the attribute (like “light” or “dark”):

@scope ([data-scheme]) {
  :scope {
    --scheme-from-attr: attr(data-scheme type());
      --scheme: if(
        style(--scheme-from-attr: system): var(--root-scheme);
        else: var(--scheme-from-attr)
      );
    color-scheme: var(--scheme); /* To make the native light-dark() work */
  }
}

And usage works like this:

[data-scheme] {
  color: light-dark(#333, #e4e4e4);
  background-color: light-dark(aliceblue, #333);

  border: 4px --light-dark(dashed, dotted) currentcolor;
  font-weight: --light-dark(500, 300);
  font-size: --light-dark(16px, 18px);
}
<div class="stylable-thing" data-scheme="light">

</div>

It’s a pretty complex function, and shows you just how powerful modern CSS is!

What's next?

The CSS Functions and Mixins Draft Spec is a really exciting one. We just got the @function rule, but there is so much more to come! One thing I’m excited for is @mixin and @apply, which would let you write blocks of CSS code which accept variables, and spit out dynamic styles. This would make it possible to create much more complex styles than functions can. For example, CSS functions only spit out one resulting value, whereas mixins can apply multiple styles at a time.

This is an extremely powerful styling feature! Especially when combined with the rest of the capabilities that we’ve recently seen land. I have a few ideas on how to make this really useful, and once those are getting close to landing (they are not yet), I’ll keep you updated!

The future is utils.css

Now you can have a functions.css or a utils.css with all of your custom functions, similar to a utils.js, and alongside a reset.css. Here is what that might look like, with some of these custom functions:

/* Returns the negative of any value */
@function --negate(--value) {
  result: calc(-1 * var(--value));
}

/* Return a semi-transparent value
Accepts color and opacity/alpha value */
@function --opacity(--color, --opacity) {
  result: rgb(from var(--color) r g b / var(--opacity));
}

/* Return a fluid typography statement
Accepts a minimum and maximum font-size value, and an optional --type value to set the scalar */
@function --fluid-type(--font-min, --font-max, --type: 'header') {
  --scalar: if(style(--type: 'header'): 4vw; 
               style(--type: 'copy'): 0.5vw);
  result: clamp(var(--font-min), var(--scalar) + var(--font-min), var(--font-max));
}