dodiameer's code stuff

dodiameer's code stuff

Switching from Sass to (Post)CSS - why and how I did it?

Why?

Since the beginning of this year I've forced myself to become better at writing CSS by writing more maintainable and expandable CSS, and following best practices and conventions. CSS is the weak point of a lot of developers, myself included, and I did that to get rid of that weakness.

What I didn't expect was that CSS would become my stronger suit, and that I'd start to prefer writing CSS and making a nice looking UI instead of writing Javascript and focusing on business logic, but it is what it is.

After getting familiar with CSS to the point where it felt like writing it is slowing me down, not CSS itself, I started using Sass. It was nicer, quicker, and it's easier to write less code with it because of mixins, functions, loops, etc, it was easier to use BEM naming with it, and writing media queries became fun again with the include-media library.

It wasn't all roses though, as I ran into issues with it. Compilation time was sometimes really slow for no reason and would only go back to normal if I restart, imports sometimes had to be in a magical order for them to work, but the biggest issue is that it's not compatible with the official CSS spec.

What that means is when you write a variable, it's not actually a CSS custom property, it's just a variable in Sass that would then be replaced by its value when being processed, writing custom media queries doesn't follow the official spec, nesting doesn't follow the official spec, etc.

All this wasn't a big deal because back when Sass was first created, no one thought we would even have variables in CSS, but now if you ever wanna migrate to plain CSS it'll be difficult to do. With PostCSS, you're writing code that will likely be supported in browsers without needing a build-step in the future. That along with prefixing properties and purging unused CSS, PostCSS is just CSS with some convenience.

Still not convinced? Let's take a look at an example for media queries and I'll explain what's better about each approach.

Example - Media queries

Sass

In Sass, I use the awesome include-media library, usually with sm, md, lg, and xl breakpoints.

@use "include-media" as *; // Or @import "include-media";

$breakpoints: (
  "sm": 320px,
  "md": 768px,
  "lg": 1024px,
  "xl": 2048px
)

.card {
  background: red;

  @include media(">=lg") {
    background: blue;
  }
}

This will be the CSS output:

.card {
  background: red;
}

@media (min-width: 1024px) {
  .card {
    background: blue;
  }
}

What's nice about this is that it's less code to write and because this is a mixin it's easy to tell that this query belongs to this class only. Because you're using a library, you don't have to worry about the issue of adding/removing a pixel from your media queries to avoid weird issues (I'm not sure what the issue is known as in the community but I call it the "B+1" problem, inspired by GraphQL's "N+1")

The disadvantages are that this generates too many media queries if used in the way I showed, and that this completely ignores the official spec of CSS.

The issue with generating many media queries is this:

.some-class {
  background: red;

  @include media(">=lg") {
    background: blue;
  }
}

.related-class-thats-not-nestable {
  background: blue;
  @include media(">=lg") {
    background: red;
  }
}

Would generate this CSS:

.some-class {
  background: red;
}

@media (min-width: 1024px) {
  .some-class {
    background: blue;
  }
}

.related-class-thats-not-nestable {
  background: blue;
}

@media (min-width: 1024px) {
  .related-class-thats-not-nestable {
    background: red;
  }
}

When what I'd write would be:

.some-class {
  background: red;
}

.related-class-thats-not-nestable {
  background: blue;
}

@media (min-width: 1024px) {
  .some-class {
    background: blue;
  }

  .related-class-thats-not-nestable {
    background: red;
  }
}

I know this is a nitpick and probably won't be a problem in small projects, but for larger projects this would cause a significant effect on your output size.

PostCSS

Using the official custom-media plugin, this is what I have to write:

/* breakpoints.css, but you can keep it in the same file if you want */
/* ============================================== */
/* 
 *? --{breakpoint}-max is always 1px smaller than --{breakpoint} 
 *? so you don't have weird things happening on devices with that
 *? exact same width, you can do it the other way around too.
 */
@custom-media --sm (min-width: 320px);
@custom-media --md (min-width: 768px);
@custom-media --md-max (max-width: 767px); 
@custom-media --lg (min-width: 1024px);
@custom-media --lg-max (max-width: 1023px);
@custom-media --xl (min-width: 1280px);
@custom-media --xl-max (max-width: 1279px);

/* global.css */
/* ============================================== */

.card {
  background: red;
}

@media (--lg) {
  .card {
    background: blue;
  }
}

This will be the CSS output:

.card {
  background: red;
}

@media (min-width: 1024px) {
  .card {
    background: blue;
  }
}

More code to write, right? This is useless! Let's look at the output once this gets supported in browsers:

/* breakpoints.css, but you can keep it in the same file if you want */
/* ============================================== */
/* 
 *? --{breakpoint}-max is always 1px smaller than --{breakpoint} 
 *? so you don't have weird things happening on devices with that
 *? exact same width, you can do it the other way around too.
 */
@custom-media --sm (min-width: 320px);
@custom-media --md (min-width: 768px);
@custom-media --md-max (max-width: 767px); 
@custom-media --lg (min-width: 1024px);
@custom-media --lg-max (max-width: 1023px);
@custom-media --xl (min-width: 1280px);
@custom-media --xl-max (max-width: 1279px);

/* global.css */
/* ============================================== */

.card {
  background: red;
}

@media (--lg) {
  .card {
    background: blue;
  }
}

Yep, the exact same code we wrote! The code you write now will be supported in the future without PostCSS so you might not depend on it in the future. With Sass, you will always depend on it unless you plan on migrating (which will likely be a pain especially if you depend on mixins and functions a lot). You can also make the plugin output both the unsupported syntax and supported syntax, I just don't see the point in doing it now because AFAIK no browser supports this feature yet.

Obviously you're still missing out on mixins (can be replaced by utility classes, or PostCSS Mixins but I'm not a fan of them) and functions (Can be replaced by PostCSS Functions but I haven't tried it so I can't say much) but for me I can live without them.

The straw that broke the camel's back is not being able to get Sass set-up for a new project I started, which caused me to lose around an hour of work, so I found a nice setup with PostCSS and used it and I honestly couldn't be happier with it.

Now that I talked about why I made the switch, I wanted to detail the plugins I use and how I have my files structured.

Setup and details

I'm using this setup with SvelteKit so my folder structure is a bit different than the one I showed, but the one I showed should work with all frameworks.

Folder structure

project/
┣ src/
┃ ┣ styles/
┃ ┃ ┣ components/ 
┃ ┃ ┃ ┣ button.css
┃ ┃ ┃ ┗ other-component.css // Components that can be applied with a class
┃ ┃ ┣ app.css // Global CSS
┃ ┃ ┣ breakpoints.css // @custom-media declarations
┃ ┃ ┗ variables.css // --custom-property declarations
┃ ┗ Other files
┣ .gitignore
┣ package.json
┣ postcss.config.cjs
┗ Other config files
  • styles/ is for global CSS
  • styles/components/ is for components that you don't want to be framework-specific, they can be used via a class attribute. Stuff like buttons work well for this use-case

Config and plugins

Plugins

Config

This config is framework-agnostic, so you might have to do additional steps to make it work with your framework

const autoprefixer = require("autoprefixer");
const cssnano = require("cssnano");
const customMedia = require("postcss-custom-media");
const purgeCSS = require("@fullhuman/postcss-purgecss")
const path = require("path")

const mode = process.env.NODE_ENV;
const dev = mode === "development";

const config = {
    plugins: [
        autoprefixer(),
        customMedia({
            importFrom: [
                // Change to the path of breakpoints.css relative to the config file
                path.resolve(__dirname, "src/styles/breakpoints.css") 
            ]
        }),

        !dev && purgeCSS({
            content: ["./src/**/*.<extension>", "./src/**/*.html"] // change to your framework's file extension
            // Uncomment this line if you're using Svelte
            // safelist: [/^svelte-/]
        }),

        !dev && cssnano({
            preset: "default", // Change to your liking
        }),
    ],
};

module.exports = config;

As you can see, I kept it pretty simple and only used convenience plugins (autoprefixer and postcss-custom-media) and optimization plugins (cssnano and purgecss).

I've used this setup on a website that is sort of in production, and I say sort of because it's public but incomplete, I just made it public because the company wanted it to be public, and I've had no issues with it so far!

I struggle with how to end a blog post, so enjoy this cat picture :) Thanks for reading

cat.jpg

#css#sass#scss#postcss
 
Share this