Fine, I’ll Use a Super Basic CSS Processing Setup.

If you’d like, you can jump straight to the setup, but I’d like to lay down my thinking first. Here goes.


These days I try to use just CSS. You don’t need a CSS processor these days like you used to, thanks to native CSS getting so much better over the years.

I used to reach for Sass like seconds on Thanksgiving. Of course I’m going for it. There was so much value there! Nesting, loops, mixins, includes, variables, math, just a cornucopia of value. Most of that stuff is in CSS now, and better.

Except includes. Dammit.

Good ones, anyway. Native CSS has @import which is technically an include. I suppose it predates ESM imports by gotta be somewhere near 2 decades. But all it does is trigger another HTTP Request for another stylesheet. It doesn’t concatenate them together which is generally what you want with CSS. Bundle, as it were.

So Sass made @import do bundling. That was a weird call, actually, since it trampled over a native CSS feature in a way that Sass was usually very good about not doing.

And I’d usually lump on PostCSS too, mostly because Autoprefixer had a real hay day of usefulness. These days, there is only precious few things left that even need it anymore (looking at you, mask).

So, between Sass and PostCSS (and probably a specialized minifier), that was getting to be a rather complex CSS processing pipeline. Plus, you’d always have to figure out how to wire it up with whatever other site-building dance you were doing. Maybe you had your own favorites. I know there are Less and Stylus people out there, not to mention a soup of potential PostCSS plugins, specialty tools like CSS modules, and the rest of the iceberg of CSS-in-JS machines.

That fatigued me, and so again: these days I try to use just CSS.

But there are still growing pains as a single CSS file gets bigger. The length of the file can get annoying. The odd CSS property or value that still needs prefixing. The desire to use some new CSS feature that would be easier with a sprinkle of processing.

SO FINE:

Let’s wire up a modern CSS processing setup.

We’re going to use Lightning CSS. It has one job: processing CSS. But offers a compelling set of features:

  1. Bundling
  2. Syntax Lowering
  3. Vendor prefixing
  4. Minification

I’ll get to the details of those as we go.

First, let’s get it installed. Here’s both the core and the CLI because that’s where we want to use it:

npm install lightningcss lightningcss-cli --save-devCode language: Bash (bash)

Lightning CSS is from the Parcel project, but here we’re not using it via Parcel, we’re using it directly. It’s now ready to be called at the command line.

Say we have a style.css file and we’re going to process it into a style-min.css file:

lightningcss --bundle --targets '>= 0.25%' --minifystyle.css -o style-min.cssCode language: Bash (bash)

Sweet, done.

Notice we’re specifically asking for the features we want via flags.

We’re going to want a file watcher.

We could make this script easier to run by putting it in our package.json like:

  "scripts": {
    "process": "lightningcss --bundle --targets '>= 0.25%' --minifystyle.css -o style-min.css"
  },Code language: JSON / JSON with Comments (json)

But that’s no fun.

Now every time we write some CSS we have to come back to the command line, run npm run process and then head over to the browser and refresh it to see the changes? We’ve made the choice opt-in to some extra technology here, so let’s get the DX right.

Let’s use Turbowatch to:

  1. Watch our CSS file(s)
  2. Run the command to process the CSS when the files change

I’m no Turbowatch expert but I found it straightforward to set up.

npm install turbowatch --save-devCode language: Bash (bash)

Let’s make our npm script run Turbowatch instead:

  "scripts": {
    "dev": "turbowatch ./turbowatch.ts"
  },Code language: JSON / JSON with Comments (json)

So we’ll need that turbowatch.ts file. Fortunately, it’s straightforward:

import { defineConfig } from "turbowatch";

export default defineConfig({
  project: __dirname,
  triggers: [
    {
      expression: ["match", "*.css", "basename"],
      name: "build",
      onChange: async ({ spawn }) => {
        await spawn`lightningcss --minify --bundle style.css -o style-min.css`;
      },
    },
  ],
});Code language: TypeScript (typescript)

Match files that end in *.css, when they change, run the command.

Cool.

Let’s add a live reloader.

Fair warning, this is extremely old school and definitely not the only option, but I like how easy it is: Live.js.

It’s a script you link up and it monitors the resources on the page and refreshes when they change. So now…

  1. We update our CSS file
  2. Turbowatcher sees the changes
  3. Lightning CSS runs and makes a new CSS file
  4. Live.js sees the change and refreshes the page

That’s the complete DX we’re after here.

Case closed. Lightweight CSS build process complete.

About those Lighting CSS features

Bundling

Like Sass, it’s slightly weird that the concatenation/bundling feature uses @import, which is the native CSS syntax. I sort of don’t blame them as otherwise Lightning CSS doesn’t invent any CSS syntax, which is a pretty special line to cross, and would probably mean they’d have to stop using .css and use .lightning instead or something.

I say that, but the conditional syntax for imports actually is an invented syntax. Strange. Probably would have went with @bundle or something then, so that the native @import behavior would be preserved. As it is, if you try to use the bundling feature and, say try to use Open Props via @import "https://unpkg.com/open-props"; or the like, it will error.

I still think bundling is a crucial feature here and probably wouldn’t use Lightning CSS at all if it didn’t have it.

I can do:

/project/
  /css/
    footer.css
    header.css
    layout.css
    reset.css
  style.css

And style.css can bundle those all together during processing as long as they are referenced:

@import "footer.css";
@import "header.css";
@import "layout.css";
@import "reset.css";Code language: CSS (css)

The order is preserved.

Syntax Lowering

Lightning CSS makes the choices on what CSS to process down to code that older browsers understand based on what browsers you actually want to support, ala Browserslist.

Say I want to take advantage of the OKLCH color model (and I do!). I could write…

html {
  --color-dark-purple: oklch(15.96% 0.079 316);
  --color-bright-purple: oklch(45.08% 0.24 308);
  --color-light-purple: #dabbe0;
}Code language: CSS (css)

And, as I write, with Browserslist set to defaults, the output will be:

html {
  --color-dark-purple: #190023;
  --color-bright-purple: #7900b5;
  --color-light-purple: #dabbe0;
}

@supports (color: lab(0% 0 0)) {
  html {
    --color-dark-purple: lab(2.88618% 14.7006 -16.436);
    --color-bright-purple: lab(31.5834% 65.0634 -67.2974);
  }
}Code language: CSS (css)

Notice the HEX color was left alone, and the OKLCH values were set into lab() instead. I’m not even 100% sure why, but it must be that I’ll get the best cross-browser support of that. That’s nice to not have to think intensely about.

It’s not just colors, it’s lots of features of “modern CSS”. Take, for instance, CSS nesting. I can now write:

div {
  div {
    border-block-start: 1px solid red;
  }
}Code language: CSS (css)

We’re close, but not all browsers are supporting native nesting yet. And some browsers right now will choke on that nested selector not “starting with a symbol”. So Lightning CSS will process that to:

div div {
  border-block-start: 1px solid red;
}Code language: CSS (css)

That’s supported everywhere, so fine with me! But notice it left the logical property alone. The browsers you get from the default setting all support that, so it’s left alone.

Vendor Prefixing

There isn’t terribly much to be said here, but it does what it says on the box.

.mask {
  mask: url(mask.png);
}Code language: CSS (css)

Turns into:

.mask {
  -webkit-mask: url("mask.png");
  mask: url("mask.png");
}
Code language: CSS (css)

Minification

This is a great feature of Lightning CSS, because it saves us from needing a separate tool for this. Plus, they say it’s the best tool on the market for squeezing the size of that CSS down, so bonus. Sass always had a “compressed” option for output, but nobody trusted it to truly minify CSS. Lightning CSS will do the job nicely.

CSS Modules

I figured I’d point out that Lightning CSS can do CSS Modules. I’m a fan of CSS Modules, the way it does scoped styles is very useful, but generally only in the context of React where I’m in a position to import the styles and apply the proper className. For a very simple CSS processing like this, which doesn’t involve the HTML or templates in any way, we don’t need CSS modules.

Example Project

Wanna see all this functional on a project so you can copy the bits you need like we all do every day? Heck yes, you do:

One response to “Fine, I’ll Use a Super Basic CSS Processing Setup.”

Leave a Reply

Your email address will not be published. Required fields are marked *