Sass: Functions
In this blog post I want to round out my series about Sass features by writing about Sass functions. If you haven't yet, I'd recommend taking a look through previous posts, which cover @use and @forward, Nested Property Declarations, Parent Wrapper Classes, Placeholders, and Mixins. Over the course of these blog posts, I've built out a small, utility class-based CSS library and in the process gone deep on each of these features. Today's blog post won't go as far into the library architecture as those posts did, but it will reference some items covered in previous posts.
Example: px-to-rem function
Over the course of this blog post series, I've used a px-to-rem
function several times:
/* styles/functions/_px-to-rem.scss */@use "sass:math";@function px-to-rem($size) {$remSize: math.div($size, 18px);@return $remSize * 1rem;}
This function takes in a pixel value (example: 21px
) and returns the calculated REM version of that value. It calculates it by dividing 21px
by 18px
(which is our base font size), then multiplying that result against 1rem
. The function then returns the REM value. 21 / 18 * 1 calculates out to something like 1.166666666 (repeating), so this function call returns 1.1666666667rem
.
We use this in conjunction with variables and a mixin in styles/axioms/_typography.scss
:
/* styles/axioms/_typography.scss */@use "../functions/px-to-rem" as *;$font-size--500: 21px;$line-height--500: 1.3968253968253967;@mixin f500 {font-size: px-to-rem($font-size--500);line-height: $line-height--500;}
And then we actually use the f500
mixin within styles/patterns/_typescale.scss
:
/* styles/patterns/_typescale.scss */@use "../axioms/breakpoints";@use "../axioms/typography";/*---Name: TypescaleBase:f: TypescaleModifiers:500: Typescale 500Breakpoints:-md: medium-lg: large-xl: extra-large---*/@mixin typescale($breakpoint-modifier: "") {.f500#{$breakpoint-modifier} {@include typography.f500;}}@each $breakpoint-modifier in breakpoints.$breakpoints-map {@if ($breakpoint-modifier == "-sm") {@include typescale;} @else {@include breakpoints.breakpoint($breakpoint-modifier) {@include typescale($breakpoint-modifier);}}}
All of this compiles to the following:
/* styles/css/styles.css */.f500 {font-size: 1.1666666667rem;line-height: 1.3968253968;}@media screen and (min-width: 480px) {.f500-md {font-size: 1.1666666667rem;line-height: 1.3968253968;}}@media screen and (min-width: 769px) {.f500-lg {font-size: 1.1666666667rem;line-height: 1.3968253968;}}@media screen and (min-width: 1025px) {.f500-xl {font-size: 1.1666666667rem;line-height: 1.3968253968;}}
Granted, this is a little bit of spaghetti Sass, but I hope it's illustrative of the way that Sass functions are intended to compute values as well as how functions fit into the larger ecosystem of mixins, variables, and flow control directives like @each
.
Function arguments
Like mixins, functions are made more powerful by the ability to pass them arguments, enabling a user to fine-tune the value returned each time a function gets called. We already saw this in the px-to-rem($size)
function.
Another example would be a create-transition
function that we could use to create CSS transitions. The transition
CSS property is shorthand for transition-property
(the property we want to apply the transition to), transition-duration
(how long the transition should take to complete), transition-timing-function
(a function for calculating intermediate values for properties affected by a transition), transition-delay
(how long the transition should wait before starting), and transition-behavior
(whether transitions will start for properties whose animation behavior is discrete).
This is a lot to remember, and typically all of these properties don't get used. We can set up a function to return some of these values in a more reusable manner, so that way we can use similar transition properties throughout our app:
@function create-transition($duration, $property, $ease) {@return $property $duration $ease;}.button {transition: create-transition(0.2s, all, cubic-bezier(0.4, 0, 0.7, 0.2));}
This compiles to the following:
.button {transition: all, 0.2s, cubic-bezier(0.4, 0, 0.7, 0.2);}
Optional arguments with default values
Of course the example above on its own isn't super helpful, but we can modify our function to give the arguments default values. This makes them into optional arguments:
@function create-transition($duration: 0.2s, $property: all, $ease: cubic-bezier(0.4, 0, 0.7, 0.2)) {@return $property $duration $ease;}.button {transition: create-transition();}
This still compiles to:
.button {transition: all, 0.2s, cubic-bezier(0.4, 0, 0.7, 0.2);}
This means our animations can be both consistent and flexible, if we just pass arguments values that we want to override:
.button {transition: create-transition(ease-in-out);}
This compiles to:
.button {transition: all, 0.2s, ease-in-out;}
Discouraged pattern: Setting global variables
Say we have a primary link color saved to a variable, but we want to override that variable's value within an <article>
element. You might think to use a function to do this like so:
$primary-link-color: #344ceb;@function set-primary-link-color($new-color) {$primary-link-color: $new-color !global;@return $primary-link-color;}.lighter-theme {content: set-primary-link-color(#34a4eb);}.article a {color: $primary-link-color;}
The above compiles to:
.lighter-theme {content: #34a4eb;}.article a {color: #34a4eb;}
We are technically achieving what we want, but this approach is considered an anti-pattern. Sass functions are intended to calculate and return a value (in other words, a function should be pure), but in this instance the function is changing a global variable outside of its own scope using !global
. This function is not pure, it has a side-effect.
The preferred practice for something like this is to use a mixin, and to @include
it where you need to have that side-effect:
$primary-link-color: #344ceb;@mixin set-primary-link-color($new-color) {$primary-link-color: $new-color !global;}@include set-primary-link-color(#34a4eb);.article a {color: $primary-link-color;}
The above compiles to:
.article a {color: #34a4eb;}
@include
signals intent that you are introducing a side-effect, which is considered a better approach for this sort of thing.
Built-in functions
Within its various modules (sass:color
, sass:map
, sass:math
, etc.), Sass has a lot of functions built-into the language. Way too many to list here.
Just within the sass:color
module, there are things like color.grayscale($color)
, which returns a gray color with the same lightness as the provided $color
, or color.scale()
, which allows you to lighten or darken a color as desired.
Support for plain CSS functions
A few weeks ago I embarked on an extensive Sass library cleanup at work, which is what originally inspired this little series of deep dive blog posts into the Sass language. As I switched from @import
statements to @use
, I ran into a lot of broken builds when referencing variables or mixins that I wasn't properly @use
ing:
.article {h1 {font-size: px-to-rem($font-size--500);line-height: $line-height--500;}}
This code errored out on the build and wouldn't compile at all, giving me the following error:
Error: Undefined variable.╷3 │ font-size: px-to-rem($font-size--500);
As one might predict, the $font-size--500
and $line-height--500
variables were undefined, but if I @use
the typography axiom/namespace...
@use "./axioms/typography";.article {h1 {font-size: px-to-rem(typography.$font-size--500);line-height: typography.$line-height--500;}}
...then it does compile:
.article h1 {font-size: px-to-rem(18px);line-height: 1.4814814815;}
But there's still a very subtle, hard-to-catch bug here: rather than calling the px-to-rem
function and calculating the correct REM value, px-to-rem
is spelled out in the compiled CSS. That function exists in my library, but isn't standard to CSS, so this introduces a bug in the final CSS, and Sass did absolutely nothing to warn me (or error out) along the way. To fix this, I need to @use
that function as well:
@use "./axioms/typography";@use "./functions/px-to-rem" as *;.article {h1 {font-size: px-to-rem(typography.$font-size--400);line-height: typography.$line-height--400;}}
This finally compiles to proper CSS:
.article h1 {font-size: 1rem;line-height: 1.4814814815;}
I fixed the bug, but I was curious about this behavior. Why does Sass slam on the breaks and refuse to compile for undefined mixins and variable names, but it permits unrecognized function names to make it to the compiled CSS?
It turns out that this is a feature and not a bug to help future-proof the language against new, plain CSS functions. Any frontend dev who keeps an eye on the CSS language will know that we've been getting a ton of new functionality in the base language (much of which has been inspired by Sass!). We of course have calc()
, attr()
, clamp()
, and so on, but it's a very active language. If Sass maintainers were to try and maintain a full list of reserved function names for the sake of protecting project maintainers from using undefined or improperly referenced custom functions, it would probably quickly become unmaintainable, and the pace of Sass version upgrades would quickly get out of hand.
So, Sass polices what it's reasonably able to: variable names and mixins, and will let unrecognized function names slide because they might just be CSS language features.