Writing CSS With JavaScript

Don’t get me wrong. I’m a big fan of CSS. It does what it does really well, and you can’t beat the backward compatibility of web standards. Sometimes, though, I get a little annoyed with the way some of the more modern features are implemented, and with some of the missing features.

For most of the time I’ve written CSS I’ve used some sort of preprocessor, like Sass or PostCSS to handle these shortcomings. These usually have their own sets of downsides, like some weird language rules in Sass’s case or leaning on the standard in ways that mean annoyances aren’t really fixed.

🤐
There’s a weird thing where Sass won’t let you @extend within @media. It’s annoying and has been a problem for a while.

This past week I decided to play with the idea of just using JavaScript to generate some CSS, and I’m pretty happy with the outcome.

This isn’t a new idea. CSS in JS is obviously a thing, but that usually implies having component code with CSS right alongside it in some sort of JS based syntax. There’s also JSS which is a library for writing CSS with JS.

I had the idea to keep my CSS (written in JS) all in its own files, like I would with Sass or another preprocessor, but instead generate CSS output. Then, since it’s just JavaScript, it’s trivial to process it with an Esbuild onLoad plugin. And since it’s JavaScript it’s very easy to do almost anything with it, since it’s JavaScript.

The Syntax

The general syntax I landed on was that a stylesheet was a JavaScript module that exports an array of CSS rules.

1
2
3
4
5
6
7
export default [
  {
    a: {
      color: "red",
    },
  },
];

It’s pretty trivial to iterate that array and convert it into some CSS. I pretty quickly realized it was nice to not have to quote properties, so I wrote in support for converting property names in camelCase to kebab-case.

1
2
3
4
5
6
7
8
export default [
  {
    a: {
      color: "red",
      textDecorationStyle: "dotted",
    },
  },
];

I also decided that numbers should get a px added after them, since I tend to type px a lot when writing CSS, so that this is valid:

1
2
3
4
5
6
7
8
9
export default [
  {
    a: {
      color: "red",
      textDecorationStyle: "dotted",
      fontSize: 15,
    },
  },
];

I knew I wanted nesting, and since I was using Esbuild to post-process the generated CSS anyway, I was able to lean on it to handle browser unnesting (since browser support isn’t quite there on nesting). Building nesting support was pretty trivial. As I iterated style properties, if the value of a property was an object I assumed it was a new nested scope:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export default [
  {
    a: {
      color: "red",
      textDecorationStyle: "dotted",

      "&:hover": {
        textDecorationStyle: "solid",
      },
    },
  },
];

Some Cool Tricks

The nice thing about this model is that it gives you some nice tricks for code reuse. Want to factor someting out into a variable? Feel free:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const red500 = "#f00";

export default [
  {
    a: {
      color: red500,
      textDecorationStyle: "dotted",
    },
  },
];

In this case the red500 variable is doubly nice compared to a css variable because it’s a good bit shorter than typing out the variable (it would have been var(--red-500), and most editors give way better tools for refactoring JS variables than CSS variables.

You can still declare CSS variables within this syntax, and even shorten them for yourself while writing them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const red500 = "var(--red-500)";

export default [
  {
    ":root": {
      --red-500: "#f00"
    },

    a: {
      color: red500,
      textDecorationStyle: "dotted",
    },
  },
];

You can also factor out and reuse whole sets of properties:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const flexRowCentered = {
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
};

export default [
  {
    div: {
      ...flexRowCentered,
    },
  },
];

It’s even easy to define reusable media queries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const mediaSmall = "@media (max-width: 375px)";

export default [
  {
    p: {
      fontSize: 15,

      [mediaSmall]: {
        fontSize: 16,
      },
    },
  },
];

The default export being an array is handy for expanding imported rulesets without having to worry about collisions:

1
2
3
4
import typography from "./typography.css.js";
import color from "./color.css.js";

export default [...typography, ...color];

All in all, I thought this was a fun little experiment. What do you think?

Love it? Hate it? Have something to say? Let me know at comments@nalanj.dev.