Azbuka: Treating CSS Like a Real Programming Language (Finally)

Azbuka: Treating CSS Like a Real Programming Language (Finally) Azbuka is a compiler for utility CSS classes.

I've been working on CSS tooling (on and off) since August 2013, when I wrote AbsurdJS. Later, in January 2016, I "invented" CSSX - writing CSS directly in JavaScript. None of those became a thing, but they were incredibly interesting experiments. I had some time during the holidays and decided to materialize an idea that I'm shaping the last couple of months. And so I "accidentally" wrote a CSS compiler. It's called Azbuka (it means "alphabet" in Bulgarian).

Utility classes are not the villain - they’re the win

Let’s start with something obvious: utility-first CSS works. Even if you dislike the aesthetics, it delivers real, measurable, boring, practical value:

  • Fewer custom class names to invent.
  • Less CSS written by hand
  • Easier refactoring
  • Zero "what did .box--main--content-wrapper do again?" moments
  • Fewer trips to the stylesheet

Changing spacing becomes:

mt-2

not

.main-content-wrapper {
  margin-top: 16px;
}

And building UI often looks like building with LEGO. Drop classes on elements. See changes. Ship things. Go home earlier. Touch grass. Grab a beer. I genuinely like this part of Tailwind and similar tools.

A little twist: I believe you should write your own utilities

The problem with utility-first CSS frameworks is that they come with a pre-defined set of utilities. You get what you get. If you need something else, you have to extend the framework or write custom CSS.

Here comes my controversial bit. I don’t think the magic is in Tailwind itself. I think the magic is in the idea of utilities. And once we agree on that, another conclusion follows: maybe, we should write our own utilities. Why? Because then:

  • We ship only what we actually need. (yes, I know about tree-shaking)
  • We can enforce our team’s language.
  • We avoid encyclopedias of half-unused classes.
  • We don’t adapt our product to someone else’s naming taste.

For example, instead of:

mt-4

we could have:

space-top-md

Then reality hits: variations and responsive design

This is where things stop being cute. Utility-first CSS frameworks solve this by providing a massive set of variations for each utility. For example, Tailwind has 7 responsive breakpoints, 9 color shades, and 6 states (hover, focus, etc.). This means that each utility can have up to 378 variations (7 x 9 x 6). This is great for flexibility but leads to bloated stylesheets and cognitive overload. Also, if we write our own utilities, we have to (generate) write all those variations ourselves. Suddenly our tidy utility system now has mutations like:

mt-2
mt-2-sm
mt-2-md
mt-2-lg
mt-2-dark
mt-2:hover
md:mt-2
lg:hover:mt-4

...and we realize we’ve built Tailwind, but worse. Responsive design is especially brutal. Every small variation multiplies across breakpoints:

p-3
md:p-3
lg:p-3

And now our utilities no longer feel "atomic." They feel combinatorial. We need a better way. And since those are deterministic variations, we can generate them at build time. So, I've created a small utility in one of my projects that generates utility classes based on a configuration file. This way, I can define my own utilities and their variations without manually writing each one. That little script inspired me to go further and create Azbuka - a full-fledged CSS compiler that treats CSS like a real programming language.

If I have to be really honest, it's not amazingly complex but it has a parser, that reads class names and convert them to AST, a transformer, and a code generator. Big part of it is written by hand and for therest I used PostCSS where it made sense. The end result is that I can write CSS in a more structured way, define my own utilities, and generate all the necessary variations automatically.

How Azbuka works

Think about your code as the source of truth. We write our utilities the same way as we do in Tailwind. For example, we want on mobile to have margin-top of 2em and on desktop 4em. We write:

mt2 desktop:mt4

Thne we jump into your styles.css file and define what mt2 and mt4 means:

.mt2 {
  margin-top: 2em;
}
.mt4 {
  margin-top: 4em;
}

So far, nothing special. But we used desktop:mt4 no just mt4. Here is where Azbuka comes into play. We define what desktop: means in a configuration file azbuka.config.js:

export default {
  breakpoints: {
    desktop: "@media (min-width: 1024px)"
  }
}

When we run the Azbuka CLI

> azbuka

it will:

  • Parse our CSS files to figure out what utilities we have.
  • Parse our HTML/JSX files to find where we use those utilities.
  • Generate the necessary CSS rules based on our configuration breakpoints.

At the end of the process, a final CSS file is generated with only the utilities we use, along with their responsive variations.

.mt2 {margin-top: 2em; }
.mt4 { margin-top: 4em; }
@media (min-width: 1024px) {
  .desktop_mt4 { margin-top: 4em; }
}

One very important note: the desktop:mt4 in the HTML/JSX is transformed to desktop_mt4 in the final CSS. This way we avoid issues with colons in class names. For that reason Azbuka provides a small helper function to transform class names in your HTML/JSX files. I decided that this is easier than trying to support plugins for the various build systems that modify class names. So, if we have to write this into the React component we'll have:

import az from 'azbuka/az';

function MyComponent() {
  return (
    <div className={az("mt2 desktop:mt4")}>
      Hello, Azbuka!
    </div>
  );
}

The library lives not only in the React world but also in vanilla JS projects. That same helper function is available as a standalone file here.

More syntax

Since I have parser to AST and compiler after that I figured that I can support some interesting syntax as well. For example:

hover:red [.dark &]:blue

is translated into the DOM as hover_red dark-I_blue and in CSS as:

.hover_red:hover { color: red; }
.dark .dark-I_blue { color: blue; }

This way we can support pseudo-classes and complex selectors.

Addressing the utilities soup 🍜 - the need of an abstraction

One problem with utility-first CSS frameworks is the "utilities soup" - the overwhelming number of utility classes that can make it hard to maintain and understand the codebase. Here is a typical styling for a button in Tailwind:

bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4
rounded focus:outline-none focus:shadow-outline

And we have different variations of buttons all over the codebase. The first thing that comes to mind is to create component abstractions. For example, in React, we can create a Button component that encapsulates all the utility classes:

function Button({ children, onClick }) {
  return (
    <button
      className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
      onClick={onClick}
    >
      {children}
    </button>
  );
}

This way, we can use the Button component throughout our application without repeating the utility classes. If we need to change the button's styling, we only have to do it in one place.

However, this approach has its downsides. It can lead to a distribution problems, making the codebase harder to navigate. Additionally, it can reduce the flexibility of using utility classes directly in the markup.

Another problem that I have is that bugging need of an abstaraction layer which makes me feel that I'm back to square one. If I have to create components for everything, why not just write regular CSS or use CSS-in-JS? The main value proposition of utility-first CSS is to avoid writing custom CSS. If we end up writing abstractions for everything, we might lose that advantage.

To address these issues, I believe that a balanced approach is necessary. We can use utility classes directly in the markup for simple cases and create custom CSS functions for more complex or frequently used patterns. This way, we can maintain the benefits of utility-first CSS while avoiding the pitfalls of utilities soup and excessive abstraction. I called those functions "macros" in Azbuka.

Meet Azbuka Macros

Let's build on top of the button example above. Instead of creating a full React component, we can define a macro in Azbuka that encapsulates the button styles. In our azbuka.config.js, we can define a macro like this:

export default {
  macros: {
    button: (args) => {
      return "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline";
    }
  }
}

Then in our HTML/JSX, we can use the macro like this:

import az from 'azbuka/az';
function MyComponent() {
  return (
    <button className={az("button()")}>
      Click Me
    </button>
  );
}

When we run Azbuka, it will create a class .button and will plug in to it all the styles that are defined into the utilities returned by the macro. This way, we can avoid repeating the utility classes while still keeping the flexibility of using them as needed.

The macro can also accept arguments to customize the styles. For example, we can modify the macro to accept an argument for styling a secondary button:

export default {
  macros: {
    button: (args) => {
      const commonStyles = "font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline";
      if (args[0] === "secondary") {
        return `bg-gray-500 hover:bg-gray-700 text-white ${commonStyles}`;
      }
      return `bg-blue-500 hover:bg-blue-700 text-white ${commonStyles}`;
    }
  }
}

And we use it like this:

<button className={az("button(secondary)")}>
  Click Me
</button>

This pattern is quite useful when we are dealing with layout too:

<div className={az("flex flex-col md:flex-row gap-4")}>
  ...
</div>

can be replaced with

<div className={az("layout(column 4)")}>
  ...
</div>

You see, now when you are reading it you immediately understand that this is a layout specific styling and not some collection of utilities. Intent over implementation. And if you need to change the layout later you just go to the macro definition and change it there.

Getting started wtih Azbuka

Getting started with Azbuka is straightforward. You can install it via npm:

npm install azbuka --save-dev

Then, create an azbuka.config.js file in the root of your project to define your breakpoints and macros.

export default {
  dir: "./src",
  output: "./public/styles.css",
  bundleAll: true,
  breakpoints: {
    desktop: "@media (min-width: 1024px)",
    tablet: "@media (min-width: 768px) and (max-width: 1023px)"
  },
  macros: {
    button: (args) => {
      return '...'; // Define your button styles here
    }
  }
}

Finally, run the Azbuka CLI to generate your CSS:

> azbuka

Azbuka is still in its early days, and I'm actively working on improving it. If you're interested in trying it out or contributing, check out the GitHub repository. The official website with documentation is here.

If you enjoy this post, share it on Twitter, Facebook or LinkedIn.

With over two decades of deep front-end expertise, I offer comprehensive web consultancy and stack audits, alongside specialized workshops, training, and engaging public speaking to elevate your team's skills and optimize your digital presence. Contact me.