Placeholders in Sass
Continuing on with my deep dive into Sass, I'll be digging into Sass placeholders this month. I'm not going to lie, I didn't think there was really too much to write about on this topic, but as I got into it I just kept finding more and more to cover. Hopefully you find value in this too!
Example project setup
This blog post largely stands on its own, although it does somewhat build on the same project and some concepts from the previous blog posts. If you'd like to read those, please check out @use and @forward in Sass, Nested Property Declarations in Sass, and Parent Wrapper Classes in Sass. For the examples in this blog post, I've modified things so that the _spacing
and _typography
partials are in a base
directory and the _buttons
partial is in a components
directory:
sass-project├── node_modules├── package.json└── styles/ ├── base/ │ ├── _spacing.scss │ └── _typography.scss ├── components/ │ └── _buttons.scss ├── css/ ├── functions/ │ └── _pxToRem.scss └── styles.scss
The contents of my package.json
file are as follows:
// package.json{ "name": "sass-project", "version": "1.0.0", "scripts": { "sass": "sass styles/styles.scss:styles/css/styles.css" }, "dependencies": { "sass": "^1.83.4" }}
I've slightly modified styles/base/_spacing.scss
and styles/base/_typography.scss
from previous blog posts to be simplified for the examples in this blog post:
/* styles/base/_spacing.scss */@use "sass:map";@use "../functions/pxToRem";
$size--100: 2px;$size--200: 4px;$size--300: 8px;$size--400: 16px;
$space-map: ( 0: 0, 100: pxToRem.pxToRem($size--100), 200: pxToRem.pxToRem($size--200), 300: pxToRem.pxToRem($size--300), 400: pxToRem.pxToRem($size--400),) !default;
@function Space($value) { @return map.get($space-map, $value);}
/* styles/base/_typography.scss */$font-size--100: 11px;$line-height--100: 1.7;$font-size--200: 13px;$line-height--200: 1.64;$font-size--300: 16px;$line-height--300: 1.5;$font-size--400: 18px;$line-height--400: 1.48;
$font-size-base: $font-size--400;
@mixin f100 { font-size: $font-size--100; line-height: $line-height--100;}
@mixin f200 { font-size: $font-size--200; line-height: $line-height--200;}
@mixin f300 { font-size: $font-size--300; line-height: $line-height--300;}
@mixin f400 { font-size: $font-size--400; line-height: $line-height--400;}
styles/functions/_pxToRem.scss
is the same as it has been previously, I just updated the import path for typography
:
/* styles/functions/_pxToRem.scss */@use "sass:math";@use "../base/typography";
@function pxToRem($size) { $remSize: math.div($size, typography.$font-size-base); @return $remSize * 1rem;}
As always, the entry point is styles/styles.scss
. For the purposes of this blog post, this file will just import the button
partial:
/* styles/styles.scss */@use "./components/buttons";
styles/components/_buttons.scss
is currently an empty file, and that is where we will begin.
Adding a Sass placeholder
A placeholder is a Sass-specific selector that works very similarly to a typical CSS class selector, but on its own is not compiled in the CSS that your Sass project outputs.
Let's put some code into styles/components/_buttons.scss
to demonstrate I'm talking about. I'll start by defining some standard button
, .button
, and #button
selectors with a background-color: red;
rule:
/* `styles/components/_buttons.scss` */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
I have a general button
selector that selects all <button>
elements, a class selector that selects all elements with the class of button
(ex. <a href="/" class="button">Button</a>
), and an ID selector that selects the element with the class of button
(ex. <span id="button">Button</span>
). When I compile my Sass, here is the output:
/* styles/css/styles.css */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
This is all basic CSS. However, I'll further modify styles/components/_buttons.scss
to include a %button
placeholder:
/* `styles/components/_buttons.scss` */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
%button { background-color: teal;}
Similar to how a class selector uses a .
period symbol or an ID selector uses a #
hash symbol, the Sass placeholder selector uses the %
percentage symbol. This is non-standard CSS, it's syntax specific to Sass.
With just that change made, I will recompile my Sass, generating the following output:
/* styles/css/styles.css */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
The output is the same, and there is no %button
selector. Ostensibly, adding the placeholder didn't do anything at all. The reason for that is because on its own a Sass placeholder doesn't compile. Let's modify our code so that it does do something:
/* `styles/components/_buttons.scss` */button { background-color: red;}
.button { @extend %button;}
#button { background-color: red;}
%button { background-color: teal;}
Within the .button
class selector, I've replaced background-color: red;
with @extend %button;
. This instructs Sass to take the contents of the %button
placeholder selector and drop those styles inside of the .button
class selector. Now if I compile the Sass, this is the result:
/* styles/css/styles.css */button { background-color: red;}
#button { background-color: red;}
.button { background-color: teal;}
We now see that the .button
class selector has the background-color: teal;
rule instead of background-color: red;
. We can define a reusable ruleset within a Sass placeholder selector. On its own that placeholder and its ruleset won't make its way into your compiled CSS, but we can extend an actual CSS selector to use that placeholder.
Something subtle you might have noticed is that the placement of the .button
class selector moved. Before using the placeholder, it sat between the button
element selector and the #button
ID selector in the compiled CSS:
/* styles/css/styles.css */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
After extending the %button
placeholder, the .button
class selector is below the #button
ID selector in the compiled CSS:
/* styles/css/styles.css */button { background-color: red;}
#button { background-color: red;}
.button { background-color: teal;}
It seems that at the time of writing this, the selector that uses a placeholder will be compiled where the placeholder is defined, not where its selector is defined. The above example is a result of defining the %button
placeholder at the end of the file:
/* `styles/components/_buttons.scss` */button { background-color: red;}
.button { @extend %button;}
#button { background-color: red;}
%button { background-color: teal;}
If I move the %button
placeholder to the top of the file...
/* `styles/components/_buttons.scss` */%button { background-color: teal;}
button { background-color: red;}
.button { @extend %button;}
#button { background-color: red;}
...then the compiled .button
class selector also makes it way to the top of the output CSS file:
/* styles/css/styles.css */.button { background-color: teal;}
button { background-color: red;}
#button { background-color: red;}
And if I move the %button
placeholder to the middle...
/* `styles/components/_buttons.scss` */button { background-color: red;}
%button { background-color: teal;}
.button { @extend %button;}
#button { background-color: red;}
...then the .button
class selector is back in the middle:
/* styles/css/styles.css */button { background-color: red;}
.button { background-color: teal;}
#button { background-color: red;}
This, of course, may change in future versions of Sass. In the past, the library has altered how and where it compiles things. There are probably more complex rules for how and where Sass compiles things. I simply call it out because this sort of thing has the potential to cause specificity issues stemming from where you think something like .button
might be defined vs. where a placeholder that it uses is defined.
@extend
ing other selectors
As a side note, you can @extend
non-placeholder selectors in Sass. For example, you can extend a .button
class:
/* `styles/components/_buttons.scss` */button { @extend .button;}
.button { background-color: red;}
#button { @extend .button;}
.some-other-class { @extend .button;}
This compiles to:
/* styles/css/styles.css */.button, .some-other-class, #button, button { background-color: red;}
@extend
isn't really the focus of this blog post, but I wanted to call out that this is something that can be done.
What's the point of doing this?
This is all well and good, but from the above examples all we're winding up with is a Sass file that's longer than the outputted CSS. So why would we use this instead of just writing plain CSS?
That is a great question. The above examples are somewhat contrived to illustrate how Sass placeholders work, not necessarily how they might actually help us make our code cleaner. To illustrate the benefits, let's take a step back and work through a classic button example using plain CSS and a BEM-like class naming system.
Say we have two button variants: Primary and Secondary. They share certain styles like padding, border width and radius, and text size, but have different background colors and hover styles.
The markup for our two buttons might look like this:
<button class="button button--primary"> <span class="button__text">Primary</span></button>
<button class="button button--secondary"> <span class="button__text">Secondary</span></button>
With BEM, we can use the .button
class to define our base Block styles that both buttons share:
.button { padding: 8px 16px; border-width: 2px; border-style: solid; border-radius: 4px;}
.button:hover { cursor: pointer;}
We can then use the .button__text
class on the inner element to style the Element within the button:
.button__text { font-size: 13px; line-height: 1.64;}
Finally, to differentiate between the Primary and Secondary variants, we use .button--primary
and .button--secondary
Modifier classes to provide each respective variant with their distinct styles:
.button--primary { background-color: teal; border-color: teal; color: black;}
.button--primary:hover { background-color: cadetblue; border-color: cadetblue;}
.button--secondary { background-color: black; border-color: black; color: white;}
.button--secondary:hover { background-color: gray; border-color: gray;}
Put all together, this basic system looks like this:
/* Block styles */.button { padding: 8px 16px; border-width: 2px; border-style: solid; border-radius: 4px;}
.button:hover { cursor: pointer;}
/* Element styles */.button__text { font-size: 13px; line-height: 1.64;}
/* Modifier styles */.button--primary { background-color: teal; border-color: teal; color: black;}
.button--primary:hover { background-color: cadetblue; border-color: cadetblue;}
.button--secondary { background-color: black; border-color: black; color: white;}
.button--secondary:hover { background-color: gray; border-color: gray;}
We can rewrite the above using Sass like so:
/* `styles/components/_buttons.scss` */%button { padding: 8px 16px; border-width: 2px; border-style: solid; border-radius: 4px; &:hover { cursor: pointer; } .button__text { font-size: 13px; line-height: 1.64; }}
.button { @extend %button; &--primary { background-color: teal; border-color: teal; color: black; &:hover { background-color: cadetblue; border-color: cadetblue; } } &--secondary { background-color: black; border-color: black; color: white; &:hover { background-color: gray; border-color: gray; } }}
We've combined the Block and Element rulesets into a %button
placeholder, so that all of the styles shared between the two button variants are found in one place. Within the .button
class selector, we @extend %button;
to bring those shared style rulesets in, and then create our .button--primary
and .button--secondary
modifier classes using Sass's special &
ampersand selector.
To take this one step further, I can use Nested Property Declarations to further group styles within the %button
placeholder:
/* `styles/components/_buttons.scss` */%button { padding: 8px 16px; border: { width: 2px; style: solid; radius: 4px; } &:hover { cursor: pointer; } .button__text { font-size: 13px; line-height: 1.64; }}
.button { @extend %button; &--primary { background-color: teal; border-color: teal; color: black; &:hover { background-color: cadetblue; border-color: cadetblue; } } &--secondary { background-color: black; border-color: black; color: white; &:hover { background-color: gray; border-color: gray; } }}
It's debatable, but this maybe makes things a little more readable. Lastly, I dislike having several pixel values hardcoded, especially when I have those values available as Sass mixins elsewhere in my codebase. Let me swap them out with @use
:
/* `styles/components/_buttons.scss` */@use "../base/spacing";@use "../base/typography";
%button { padding: spacing.Space(300) spacing.Space(400); border: { width: spacing.Space(100); style: solid; radius: spacing.Space(200); } &:hover { cursor: pointer; } .button__text { @include typography.f200; }}
.button { @extend %button; &--primary { background-color: teal; border-color: teal; color: black; &:hover { background-color: cadetblue; border-color: cadetblue; } } &--secondary { background-color: black; border-color: black; color: white; &:hover { background-color: gray; border-color: gray; } }}
With all of that in place, here is our compiled CSS:
/* styles/css/styles.css */.button { padding: 0.4444444444rem 0.8888888889rem; border-width: 0.1111111111rem; border-style: solid; border-radius: 0.2222222222rem;}.button:hover { cursor: pointer;}.button .button__text { font-size: 13px; line-height: 1.64;}
.button--primary { background-color: teal; border-color: teal; color: black;}.button--primary:hover { background-color: cadetblue; border-color: cadetblue;}.button--secondary { background-color: black; border-color: black; color: white;}.button--secondary:hover { background-color: gray; border-color: gray;}
So I might have gone a little bit overboard with the Sass features at the end there. I do find it fun to start with raw CSS and see how much of modern Sass I can use to achieve the same effect, all while reorganizing my code in a way that (at least to me) makes more sense. I hope you'll entertain me and perhaps even find value in walking through that transformation.
Anyway, the key point from all of this is that Sass placeholders are designed to be extended. It's a way of grouping common styles in one place when we would typically define a general purpose CSS class. The added benefit is that if the %button
placeholder is defined but isn't extended anywhere, it doesn't wind up in the compiled CSS. You can create all kinds of utility placeholders, and Sass will only output those that are used.
Gotchas
There are a few pitfalls to be aware of when it comes to Sass placeholders, specifically around complex selectors and media queries.
Complex selectors
From the Sass docs:
[A]ny complex selector [...] that even contains a placeholder selector isn’t included in the CSS, nor is any style rule whose selectors all contain placeholders.
Let's go back to this example:
/* `styles/components/_buttons.scss` */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
%button { background-color: teal;}
Which compiles to:
/* styles/css/styles.css */button { background-color: red;}
.button { background-color: red;}
#button { background-color: red;}
Say you want to add some hover styles to the .button
class:
/* `styles/components/_buttons.scss` */.button { background-color: red;}
.button:hover { font-weight: bold;}
%button { background-color: teal;}
%button:hover { color: yellow;}
As things stand, since we're not extending the %button
placeholder, none of our %button
or %button:hover
styles will compile. We'll just wind up with:
/* styles/css/styles.css */.button { background-color: red;}
.button:hover { font-weight: bold;}
However, even if we try grouping %button
with the .button:hover
selector, things won't wind up how we hope:
/* `styles/components/_buttons.scss` */.button { background-color: red;}
.button:hover,%button { font-weight: bold;}
%button { background-color: teal;}
%button:hover { color: yellow;}
I'm hoping that the :hover
styles will have both font-weight: bold;
and color: yellow;
, but we still just wind up with:
/* styles/css/styles.css */.button { background-color: red;}
.button:hover { font-weight: bold;}
If I try @extend %button;
within .button:hover
...
/* `styles/components/_buttons.scss` */.button { background-color: red;}
.button:hover { font-weight: bold; @extend %button;}
%button { background-color: teal;}
%button:hover { color: yellow;}
...then I wind up with both %button
and %button:hover
styles applied on :hover
:
/* styles/css/styles.css */.button { background-color: red;}
.button:hover { font-weight: bold;}
.button:hover { background-color: teal;}
.button:hover { color: yellow;}
Which isn't exactly what I want.
Alternatively, if I try @extend %button:hover;
:
/* `styles/components/_buttons.scss` */.button { background-color: red;}
.button:hover { font-weight: bold; @extend %button:hover;}
%button { background-color: teal;}
%button:hover { color: yellow;}
Then I get an error when I try to compile:
Error: compound selectors may no longer be extended.Consider `@extend %button, :hover` instead.See https://sass-lang.com/d/extend-compound for details.
╷7 │ @extend %button:hover;
The closest thing we can get with this approach is just to @extend %button;
within .button
:
/* `styles/components/_buttons.scss` */.button { background-color: red; @extend %button;}
.button:hover { font-weight: bold;}
%button { background-color: teal;}
%button:hover { color: yellow;}
Which compiles to:
/* styles/css/styles.css */.button { background-color: red;}
.button:hover { font-weight: bold;}
.button { background-color: teal;}
.button:hover { color: yellow;}
We still can't quite get .button
to have background-color: red;
, font-weight: bold;
, and color: yellow;
on hover - background-color: teal;
still overrides the default style. In this instance, we should just be aware of all of these caveats and rewrite our %button
placeholder to truly hold styles we want to share and extend.
Media queries
The other pitfall to be aware of is that Sass placeholders are not permitted within media queries.
Say you have two placeholders, %button-teal
and %button-orange
that you want to switch between them based on a media query:
/* `styles/components/_buttons.scss` */.button { @extend %button-teal; @media screen and (min-width: 769px) { @extend %button-orange; }}
%button-teal { background-color: teal;}
%button-orange { background-color: orange;}
If you try to compile this, you'll get an error:
Error: From line 4, column 5 of styles/components/_buttons.scss: ╷4 │ @extend %button-orange; │ ^^^^^^^^^^^^^^^^^^^^^^ ╵You may not @extend selectors across media queries.
You can restructure to put the media query within your placeholder:
/* `styles/components/_buttons.scss` */.button { @extend %button;}
%button { background-color: teal; @media screen and (min-width: 769px) { background-color: orange; }}
Unfortunately it's kind of an all-or-nothing situation with this approach.
Why not use a mixin?
Sass placeholders are awesome, and as we can see very powerful, but they often take a second seat to Sass mixins. Both offer ways to tuck CSS rulesets away in something that is reusable. So when might you use a placeholder instead of a mixin?
This is a longer discussion (one I might dip into in a future blog post), but I want to highlight one of the main ways that a placeholder would be a better choice than a mixin, which is grouping selectors that use identical rulesets.
Say we have the following rulesets we want to apply in multiple places:
display: flex;align-items: center;justify-content: center;
One way we might go about this is to throw this into a Sass mixin and include it wherever we need:
@mixin center { display: flex; align-items: center; justify-content: center;}
.container { @include center;}
.table-cell { @include center;}
If you compile this, you'll see that it works:
.container { display: flex; align-items: center; justify-content: center;}
.table-cell { display: flex; align-items: center; justify-content: center;}
Both .container
and .table-cell
get the ruleset that we want.
Now, let's see the difference when we refactor the same code to use a Sass placeholder:
%center { display: flex; align-items: center; justify-content: center;}
.container { @extend %center;}
.table-cell { @extend %center;}
When we compile this, here is what we get:
.table-cell, .container { display: flex; align-items: center; justify-content: center;}
Once again, .table-cell
and .container
both have the same ruleset, but since they use a placeholder, Sass will confidently combine them into a single selector list instead of two distinct selectors.
You get a similar situation if you augment your mixin/placeholder with an additional rule within one of the class selectors that uses that mixin/placeholder:
@mixin center { display: flex; align-items: center; justify-content: center;}
.container { @include center;}
.table-cell { @include center; margin-top: 40px;}
This will yield:
.container { display: flex; align-items: center; justify-content: center;}
.table-cell { display: flex; align-items: center; justify-content: center; margin-top: 40px;}
Whereas...
%center { display: flex; align-items: center; justify-content: center;}
.container { @extend %center;}
.table-cell { @extend %center; margin-top: 40px;}
...will yield:
.table-cell, .container { display: flex; align-items: center; justify-content: center;}
.table-cell { margin-top: 40px;}
If you're repeating a large number of rules within a mixin that gets used in multiple places, you might be generating a compiled CSS stylesheet that is unnecessarily large. There is very likely a way to refactor from mixins to placeholders that could drastically reduce the size of your compiled CSS.
Summary
- Sass placeholders are designed to be extended. It is a way to write CSS rulesets always compile to the same thing no matter where they are extended
- Sass placeholders are fairly strict, but this enables Sass to group selectors that use the same placeholders into selector lists. This can dramatically reduce the size of your compiled CSS files
- Sass placeholders use
%
in their syntax, similar to.
for CSS class selectors and#
for CSS ID selectors%
is Sass-specific syntax; on its own a Sass placeholder will not compile to anything. It must be extended to compile- It is best to extend a placeholder directly within the selector(s) you want to use. Using the placeholder alongside other selectors may throw compilation errors or compile CSS that doesn't do what you want it to
- You cannot use media queries inside of a Sass placeholder