Atomic CSS Deep Dive

Atomic CSS Deep Dive

Hello, comrades! My name is Valik and today we are going to talk about Atomic CSS approach, tool development and related topics.

Let’s briefly recall the basics – why Atomic CSS. Let’s consider popular solutions to work in this approach and compare them with my invention – mlut. We’ll analyze the problems of well-known tools and see how I solved them in mine. There will be interesting architectural solutions, technical details and some hardcore.

Those who do frontend development will be able to look at Atomic CSS in a different way and perhaps adopt a new tool. And those who write system code and tooling will get inspiration and learn from unconventional experience.

This is a transcript of my talk from HolyJS Spring 2024. You can watch the video (RU), or you can read this article with some additions and better wording.

A few words about myself

I’m a developer, in IT for over 8 years. For the last 2, I’ve been mostly doing backend on Node.js and tooling, and before that, I worked more with frontend. Doing my own open source project. I speak at IT events and lead a local IT community of 500+ people in St. Petersburg.

Why exactly am I going to talk about Atomic CSS?

In topic since 2018, when Tailwind was still a noname library
Watched all relevant tools that have more than 20 stars on github
3 years of career with a lot of frontend
I’ve invested well over 1000 hours in the development of my tool

Basics about Atomic CSS

Let me remind you that Atomic CSS is a layout methodology in which we use small atomic CSS rules, each of which does one action. These classes are also called utilities. They often apply a single CSS property (like changing the color of text), but not necessarily one. In code, it looks something like this:

The main advantages of the approach are

Compared to handwritten CSS:

Waste less mind fuel. No need to think about unique entity names, whether it’s a BEM block or BEM element, what kind of catalog structure to use, etc

Less CSS on the client. At a certain point in development, styles stop being added. We reuse the same utilities all the time.

Faster to write styles. Especially if we use short utility names. Plus, we have a lot less need to switch between files

Some of you have probably remembered the typical myths about Atomic CSS, some of which can be seen in the illustration below. Of course, I won’t deal with them in this article, because here we are more about system code and tools. But we will definitely come back to them in my next talk on this topic.

State of Atomic CSS

Let’s analyze the current situation on the market. Let’s take 3 current and quite popular tools for working in Atomic CSS:

Tailwindcss – the best known and most popular one

UnoCSS – not just a framework, but an engine for creating your own framework.

Atomizer – a good old Yahoo tool that has a lot to boast about

Despite the fact that we have at least 3 tools, the following problems remain relevant:

Non-consistent naming
Uncomfortable writing complex utilities
Working with handwritten CSS
Uncomfortable to extend

Below we look at these problems in more detail

Non-consistent naming

A few examples of utilities from popular libraries

flex => display: flex, but flex-auto => flex: 1 1 auto

tracking-wide => letter-spacing: 0.025em

normal: line-height, font-weight or letter-spacing?

Complex utilities

This is roughly how we are encouraged to write non-standard @media expressions:

[@media(any-hover:hover){&:hover}]:opacity-100

Turns this into the following CSS:

@media(any-hover:hover) {
.[@media(any-hover:hover){&:hover}]:opacity-100:hover {
opacity: 1;
}
}

It’s not all smooth sailing with complex selectors either:

[&:not(:first-child)]:rounded-full
.[&:not(:first-child)]:rounded-full:not(:first-child) {
border-radius: 9999px;
}

The various at-rules also leave a lot to be desired:

supports-[margin:1svw]:ml-[1svw]
@supports (margin:1svw) {
.supports-[margin:1svw]:ml-[1svw] {
margin-left: 1svw;
}
}

Working with handwritten CSS

It’s worth revealing a terrible secret about Atomic CSS here:

In most projects, some part of the CSS you’ll have to write by hand!

And it is normal, because such code will be in the limit of 10%, as practice shows.

Now for the problem itself. Let’s look at the following code sample on Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components { /* #5 */
.card {
background-color: theme(colors.white); /* #7 */
border-radius: 5px / theme(borderRadius.lg);
padding: 1rem theme(spacing[2.5]); /* #9 */
}
}

What we’re seeing here:

Conflicts with CSS. Not so long ago CSS introduced cascading layers, which are declared via at-rule @layer. And Tailwind has its own @layer (line #5), which works somehow in its own way

Files structure. By default, you can only work in a single CSS file in Tailwind. To work on more than one, you will need to use the PostCSS plugin

Use utility values. If you want to get utility values to use in some property, you will need a special theme function (line #7). That said, you’ll need to know how to get to the right value in the theme dictionary, and that’s not always obvious (line #9)

No preprocessor features. This is rather a minus with an asterisk, because not everyone needs these features, and some of them can be obtained with PostCSS

Another interesting point to this topic. At one time there was such a clone of Tailwind – Windi CSS. The guys there started to make their own language or preprocessor to solve the issue with handwritten CSS. Here here you can check it out, looks funny.

Uncomfortable to extend

This is how we are prompted to add a relatively simple utility:

module.exports = {
theme: {
tabSize: {
// map with values
}
},
plugins: [
plugin(function({ matchUtilities, theme }) {
matchUtilities(
{
tab: (value) => ({
tabSize: value
}),
},
{ values: theme(tabSize) }
)
})
]
}

And to add your own variant (a modifier to make the utility work by hover, for example), you have to write something like this:

variants: [
// hover:
(matcher) => {
if (!matcher.startsWith(hover:))
return matcher
return {
matcher: matcher.slice(6),
selector: s => `${s}:hover`,
}
},
],

Actual solution

As a solution to the above problems, I bring to your attention my tool: mlut

Atomic CSS toolkit with Sass and ergonomics for creating styles of any complexity

Every word matters in this message, but now I will explain why Sass is highlighted in particular

Some might think: “Sass is a legacy technology, vanilla CSS is already wow: custom properties, cascading layers etc”. But I wouldn’t be in a hurry to bury it.

Yes, as CSS has evolved, some of its features have become less relevant, but despite that it is steadily evolving, and it has more downloads per week on npm than Tailwind. And Sass is not just being maintaned, it’s having features added to it: there have been at least 4 minor releases in the last six months!

Next let’s get to the technical part, get ready!

How utilities are structured

We are going to talk quite a lot about the structure of utilities, so I will start with a general scheme of their structure.

It is not very clear to you now, but we will analyze it in more detail later. For now, it will accompany us as a kind of mini-map, which will help us to orientate ourselves: at what stage of learning utilities we are at. And first of all, we will talk about naming.

Naming

Let’s take a look at how things are going for popular tools

Tailwind

There is no consistent naming here. Utilities have opinionated names that are consonant with CSS properties or values. Let’s consider a couple of examples:

justify-*: content, items, self?

bg-none – remove all background? Nope, only background-image

flex => display: flex, but flex-auto => flex: 1 1 auto

UnoCSS

Let me remind you that UnoCSS is an engine, not just a framework. It allows you to build your own framework from so-called presets. There are already many ready-made presets, or you can write your own. In particular, by plugging in one of these presets you can use the syntax of some popular libraries. Most often Tailwind is used

But in this example we will take a preset with Tachyons, a once popular library. But here the naming is even more deplorable:

br-0 => border-right-width: 0, but br1 => border-radius:.125rem
b: bottom, border, display: block? Nope, this is font-weight:bold!
normal: line-height, font-weight, letter-spacing?

Atomizer

Here the situation with naming is better. Emmet abbreviations are used as the basis and new ones are added, in their likeness. It looks quite consistent:

Js(c) => justify-self: center
Bg(n) => background: none
Bgbm(c) => background-blend-mode: color

mlut

mlut uses a single algorithm for all abbreviations. Some examples:

Js-c => justify-self: center
Bdr => border-right: 1px solid
Bdrd1 => border-radius: 1px

I am well aware that abbreviations is a controversial topic. They have their pros and cons

Pros
Cons

Concise code
There’s a threshold

Easier to write
Not for everyone

Slightly less code size

Some people won’t like them at all, purely aesthetically, and that’s okay too. In their defense, abbreviations is all around us:

In languages: const, int, char

In command line: cd, pwd, ls

Low-level: Ldar, Star, SubSmi

extra credit question

Write in the comments who recognises the latest abbreviations

Why the abbreviation algorithm?

Avoid collisions with new properties and values in CSS, as it has been evolving rapidly in recent years
The ability to output properties “in your mind” rather than having to memorize them. Once you’ve do this way and used the utility a couple of times, you’ll quickly bring it to automaticity, and you won’t need to spend any more mind fuel on remembering it again

The reader can say that there are already ready-made abbreviations Emmet, which someone even managed to learn) Yes, they are not bad, but their problem is that there is no clear algorithm there. This was confirmed by Sergey Chikuyonok – the creator of Emmet (I asked him about it). So they do not solve the first problem.

How it was

Few people know, but there is such a NPM package: mdn-data. It contains several large json, which contain data about almost all CSS. About the properties, their syntaxes, media features and much more. I constantly turned to him during research.

Of course, I studied specs CSS. A lot of specs: both stable and drafts.

The data from Chrome Platform Status also helped me a lot. These are stats on the frequency of use of CSS properties on the Internet. Yes, this is also.

As a result of the research, I have this table for all the properties, where they are categorized into groups and most are given a popularity rating (this is the finished table with abbreviations):

So of course it’s been weeks of reflection, trial and error, and that’s about it….

Thus was born the abbreviation algorithm that we will now study

General algorithm for abbreviations

The whole thing is described in documentation, and here we’ll go over the top of it:

Find properties that start with the same letter
Rank them by popularity (mostly, but not only)
Select groups with the same first word
Make abbreviations within the groups

The last screenshot with the table shows the result of this algorithm. It should be clarified that the general algorithm is used primarily for composing new abbreviations. That is, the abbreviations that are already there will not change anymore. This means that in practice, you will use the algorithm to abbreviate one entity, which we will consider next. And the general one is needed more for general understanding, so to speak.

Algorithm for reducing a single entity

I. Shorten the name to the first letter of the property/value: color => C

II. If the name is of several words, the first letter of each word is taken: color-adjust => Ca

III. If two names have the same initial letter, a letter is added to the next name when sorting them by rating

color => C

cursor => Cs

IV. If the title is of several words, the letter is added in the corresponding word in order

color => C

cursor => Cs

color-scheme => Csc

Order of adding a letter

In the previous algorithm, there was a point about adding a letter if the resulting abbreviation already exists. Now we will specify its order, because it is important.

I. The consonant of the next syllable: cursor => Cs

If the next syllable starts on a vowel, the nearest previous consonant from it is taken

II. Next consonant

content => Сt

contain => Cn

III. Next vowel (without skipping over a consonant)

content => Сt

counter-increment => Coi

Now let’s practice! We haven’t learnt so much about the abbreviation algorithm for nothing. Below you’ll find a few spoilers: in the title – the abbreviation, and inside – the property that corresponds to it. Try to expand them in mind according to the entity abbreviation algorithm, taking into account the order of adding letters

Ps

position

Fnw

font-weight

Tf

transform

Flg

flex-grow

Weak parts

The popularity of properties changes. It is quite possible that some new CSS property will appear and become popular. Then when composing its abbreviation in your head you may make mistakes, because the derived abbreviation will already be occupied by some old property. Although this disadvantage will be more relevant for new mlut users
Rare long properties may be difficult to recall
Controversial situations are possible as CSS evolves. The algorithm is formal enough to write a program that could turn properties from JSON into abbreviations. But the problem is that the primary source here is not JSON, but specs, and things are not so unambiguous there. You have to follow their development, see where certain properties/features are moving. And then use the algorithm based on these inputs. But so far there have been almost no disputable situations

Syntax

Let’s see what popular tools offer us

Tailwind

There is not even any semblance of a specification here. For the most part, the syntax is a set of ad-hoc solutions with kludges in the form of arbitrary parts. Let’s briefly go through it.

Utility and value:

util-value – simple value

-util-2 – negative value

util-[42px] – arbitrary value

[css-prop:value] – arbitrary CSS propertie and value

Variants:

variant:util-value – selectors and some at-rules

group/name:util-value – named groups

@md:util-value – container queries

Arbitrary variants

variant-[.class]:util – arbitrary variant value

[&:nth-child(3)]:util – arbitrary variant

@[17.5rem]:util – container queries

UnoCSS

We skip this part because Uno most often uses the Tailwind syntax. At least, it is the most advanced one available there.

Atomizer

Suddenly, there is a specification! But in fact, the syntax covers quite few CSS features.

[<context>[:<pseudo-class>]<combinator>]<Style>[(<value>,<value>?,…)][<!>][:<pseudo-class>][::<pseudo-element>][–<breakpoint_identifier>]

We’re not going to study it now, of course. I just inserted it to show that it exists in principle. For comparison: in Tailwind I had to run all over the docs looking for all syntax. Here I went to one page, parsed the spec and already have an idea of what utilities can be and what they can do.

mlut

mlut implements the so-called Components Syntax, thanks to which we can expand compact utilities into complex CSS rules

@:ah_O1_h =>

@media (any-hover) {
.@:ah_O1_h:hover {
opacity: 1
}
}

I realize now it was like a “How to draw an owl” meme, but don’t worry: we’ll break down this same example next

Why design the syntax?

Main goal is conceptual closeness with CSS to grow organically with it

Less opinions, more standards! (c) Me

A well-designed syntax allows us to:

Teach less about “fantasy” entities
Avoid (minimize) conflicts with CSS
Maintain usability
Gain high expressiveness to implement the largest number of CSS features

Well, we are already masters and we know how to research CSS. So let’s take the previously mentioned tools and dive into the specs!

But this time, I didn’t just study spec drafts, but “drafts of drafts”. That’s what you can call thematic issues in the very repository CSSWG where the specs discussion is going on. It’s also there in the screenshot above

Fun fact

Did you know that most of the CSS specs was written by 2 people? Tab Atkins and Elika Etemad

I designed the first version of the syntax for about 2 weeks and was often around this situation:

And this is what I got…..

Utility components syntax

A syntax that divides a utility into components, each of which corresponds to a part of a CSS rule. By parts here we mean at-rules, selector, properties and their values. Now, let’s go back to one of the previous examples and look at it in a different way:

Now it’s time to deal with the utility device diagram that has accompanied us throughout this article:

CSS at-rule: breakpoints, @supports, etc

pre-states – part of the selector before the utility class name
Name
Value

post-states – part of selector after the utility class name

It’s worth stepping back a bit here and introducing a concept like conversion, since it’s going to be mentioned a lot further down the line.

Conversion – turning the abbreviation from a class name into a real CSS entity. It is found in almost all parts of utilities: values, states, at-rules, etc.

States

Before we get back to utility syntax, we need to remember the complexity of selectors in CSS:

Simple selector – with one condition: .class, #id, element

(Pseudo-)Compound selector – several simple selectors without combinators: .class[attr], element.class

Complex selector – several simple/compound with combinators: .class:hover + .item

Selector list – comma-separated list from simple, compound or complex: .class, .item + .item, a.active

So, states in mlut are a simplified selector list. That means we can use almost all CSS selector features with similar DX in them. Even multiple selector (via ,). The main syntax differences are as follows:

: – merge states

, – split into a list

<empty>: – space in selector

Let’s look at a couple of examples

At-rules

Before exploring the structure of at-rules in our syntax, let’s recall some of their features in CSS. At-rules in CSS also vary in complexity. There are simple ones, like @import and @charset. There are nested ones, like @layer. And the most complex ones are called conditional at-rules – we’ll talk about them further.

What at-rules consist of:

Conditions – the conditions themselves. They can consist of operators, parentheses, and features / queries, depending on the specs: (hover) and (min-width: 20rem)

Operators – logical: and, or, not

Features – expressions, functions, etc: (pointer: fine), style(color: green)

In the example below, we can see that we have <supports-condition> which contains everything else:

What else is worth understanding about at-rules:

The composition is very different
You can build complex expressions using operators
You can embed them in each other

Now about the at-rules in mlut. This includes breakpoints and at-rules themselves.

Breakpoints have a separate sub-syntax because they are used much more frequently than other at-rules variants. In addition to the standard behavior, where the utility is enabled only from a certain screen size, we may also want the utility to work only in a range of widths or up to a certain size. That’s why we need a subsyntax here.

/* sm:md,xl_P2r */

@media (min-width: 520px) and (max-width: 767px), (min-width: 1200px) {
.sm:md,xl_P2r {
padding: 2rem;
}
}

And the rules themselves: @media, @supports and others.

To compose complex expressions in both syntaxes, the following operators are used:

: => and

, => , (or)

Next, let’s look at how rules work in at-rules. Each rule has a:

Abbreviation: m, s, c – made by a known algorithm
Converter – turns abbreviations into a chain of CSS-expressions
Custom values – aliases for frequently used chains. They can be added by the user through the config

Thus, the at-rules syntax in mlut allows to close the whole class of these features in CSS. This means that if a new at-rule is added to CSS, it is very likely that it can be implemented in mlut without changing the syntax or modifications in the core. Let’s look at some examples below.

And yes, at-rules in mlut can be combined!

Yes, the first reaction might be something like the following:

But in my opinion, the syntax is powerful) And despite this, it still has weaknesses:

You can’t (yet) write an arbitrary pseudoselector. Now it will be converted like this: D-f_:pseudo => .D-f pseudo {…}

Possible conflicts of custom aliases in at-rules (-myQuery) with CSS custom media or cutsom selector. But it’s not certain, as it’s still quite draft there

Value conversion

We have already touched a bit on the concept of conversion. Let me remind you that this is the name of converting a class name abbreviation into a real CSS value.

ml-1 => margin-left: 0.5rem
D-f => display: flex

How are other competitors instruments doing

Tailwind

The conversion here is quite modest. Here’s what he can do:

Substituting a value from the dictionary in the config (theme)
Color transparency: bg-sky-500/75

Imperative conversion, which we write by hand when adding a utility via plugin
Parts of custom values, such as: more convenient writing of custom properties

UnoCSS

It’s about the same as Tailwind here

Atomizer

There are a couple interesting places here, but nothing special either:

Substitution of meaning from dictionary + RTL by design
Color transparency: C(#fff.5)

Convenient syntax for custom properties
Multiple values(!): Bgp(20px,50px).
Substituting custom values from config

mlut

In mlut I developed a conversion system for almost arbitrary values. A few examples to get you started:

Ml-1/7 => margin-left: -14.3%

Bdrd1r;2/5p => border-radius: 1rem 2px / 5%

Why a conversion system?

CSS property values are tricky. A little further on you’ll see for yourself
We want to stay close to the platform – remember the previous principles of tool design
We want all this to be easy to write

What are the difficulties of working with CSS values? We should start with the fact that there is a special Value Definition Syntax to describe them (and not only for that)! And in the values themselves we can have: different data types, units of measurement, functions and many other things….

But we are not afraid of difficulties, so let’s go to specs – study Value Definition Syntax…

And now, when we look at the description of CSS properties in mdn-data, we’ll understand what values it can take. And by looking at a few of these properties, we’ll start to see patterns and commonalities, which will help design our conversion system closer to reality.



An interesting point is that mdn-data also has JSON, where syntaxes that are reused in different properties (and not only properties) are placed. This has helped a lot in identifying patterns in values.

Basic concepts of conversion

Converter – a function that converts a value from an abbreviated class to a real CSS value.

Transformer – a function that can still somehow change the converted CSS value. It is specified in the utility options.

Conversion type – list of converters that are applied to the utility value. Each utility has it, and if it is not explicitly specified in the utility options, the default one is applied.

For further understanding, it is worth to understand a bit how mlut utilities are represented in the code. All utilities are stored in a single registry. Let’s simplify it a bit, but it’s basically a big dictionary, where keys are utility names and values are options. The options can be a property name, conversion type, and many other things.

Apcr: (
‘properties’: aspect-ratio,
‘conversion’: ‘num-length’, /* conversion type */
),

In addition to the registry, there is a common config for utilities. It stores some settings related to all or large groups of utilities. In particular, conversion types are stored here. This is a dictionary where keys are the name of the type and values are a chain of converters.

conversion-types: (
/* */
‘num-length’: (‘num-length’, ‘global-kw’, ‘cust-prop’)
/* ^a chain of converters */
),

General scheme of conversion

The full utility value is broken down (by space or delimiter) into simple values
Each simple value goes through a chain of converters until 1 of them not to trigger
A transformer is applied to the CSS value
The final value is substituted into the CSS rule

Now let’s look at converters in a little more detail. These are ordinary functions with the following signature:

@function convert-uv-number($value, $data: ()) {
/* … */
@return $new-value;
}

$value – initial value

$data – dictionary with additional data

The main features of converters:

The part of the name after convert-uv- is used in conversion types
Applied one after another
Internally can use other converters
Can write your own

And a couple more words should be said about the peculiarities of transformers. By signature, they are the same as converters. The main difference: they are applied once to the whole CSS value and return the new final value in its entirety. A typical case: converting a value into a CSS function, for example, into a filter.

Case: Gradient Utility

We’ve explored a lot of individual concepts, and now it’s worth seeing how it all works together. Let’s take a case study: a CSS gradient utility, probably the most complex in mlut. It has the following options:

-Gdl: (
‘properties’: background-image,
‘transformer’: ‘gradient’,
‘css-function’: ‘linear-gradient’,
‘conversion’: ‘gradient’,
‘multi-list-separator’: ‘,’,
‘keywords’: (‘position’, ‘gradient’),
),

As we can see, there is both a special conversion type and a transformer. And here is how this conversion type is described in the general config:

conversion-types: (
/* */
‘gradient’: (
‘keyword’, ‘color’, ‘cust-prop’, ‘Pl’,
‘number’, ‘angle’, ‘global-kw’
),
),

There’s a rather long chain of converters (and pipeline is an advanced feature), but the following is notable. I didn’t have to write a ton of imperative code to get a fairly complex conversion logic. I just made such a chain from the available converters and got the desired behavior. Here is how this utility works:

The first reaction might be something like this:

But in my opinion, the utility turned out to be a masterpiece

Weak parts

No first-class support for CSS functions (yet): calc(), clamp().
In the future, CSS values may take over the special characters used: ;, $, ?

Configuration

Configuration refers to the customization of the tool. Specifically:

Adding values: colors, fonts, keywords
Creating utilities
Changing settings: breakpoints, new states

What do the known tools have to offer us?

Tailwind

Adding a value for utility is kind of easy.

module.exports = {
theme: {
extend: {
fontFamily: {
display: Oswald, ui-serif,
}
}
}
}

But to add a utility, you have to write a plugin! At the same time, there are static and dynamic utilities.

Static utilities

Manually write CSS(-in-JS)-rules
Variants will be available

module.exports = {
plugins: [
plugin(function({ addUtilities }) {
addUtilities({
.content-auto: {
content-visibility: auto,
},
.content-hidden: {
content-visibility: hidden,
},
})
})
]
}

Dynamic utilities

You can add a dictionary with values
Arbitrary syntax will be available
Variants will be available

module.exports = {
theme: {
tabSize: {
// map with values
}
},
plugins: [
plugin(function({ matchUtilities, theme }) {
matchUtilities(
{
tab: (value) => ({
tabSize: value
}),
},
{ values: theme(tabSize) }
)
})
]
}

UnoCSS

Adding values here is also quite simple:

theme: {
// …
colors: {
veryCool: #0000ff, // class=”text-very-cool”
},
}

But the situation with utilities is worse. There is a nice and concise api for this purpose here. The simplest utilities can be added in one line! But for something more complex you will have to write regexps and imperative conversion:

rules: [
[m-1, { margin: 0.25rem }],
[/^p-(d+)$/, ([, d]) => ({ padding: `${d / 4}rem` })],
]

mlut

In mlut all extensions are done in one config and as a rule: with a couple lines of code.

How to add a new utility?

@use ‘mlut’ with (
$utils-data: (
‘utils’: (
‘registry’: (
‘Mm’: margin-magick,
),
),
),
);

Boilerplate is a bit bigger, but the simple utility is just as added in one line. That said, here’s what it can do out of the box, in terms of conversion:

Number value: Mm1r => margin-magick: 1rem

Global keywords: Mm-ih => inherit

Custom properties: Mm-$myCard?200 => var(–ml-myCard, 200px)

Several values: Mm10p;1/3 => 10% 33.3333%

Dispatching

Now we’ll digress a bit and recall the concept of “dispatching” from programming. Dispatching is finding and choosing which function will be called for a certain type of data. It can be:

Static – at the compile time
Dynamic – at runtime

As an example of static dispatching, here is an example in C++. Yes, suddenly we’ve moved from CSS to C++

struct Calculator {
int (*operation)(int, int);
}

int add(int a, int b) {
return a + b;
}

int subtract(int a, int b) {
return a b;
}

int main() {
Calculator calc;

calc.operation = add;
printf(“5 + 3 = %dn, calc.operation(5, 3)); // #17

calc.operation = subtract;
printf(“5 – 3 = %dn, calc.operation(5, 3));

return 0;
}

Here we have a structure with a pointer to a function. Already at the compilation stage it will be clear which function implementation to call on line #17.

If you write in dynamic languages, you encounter dynamic dispatching (hereinafter referred to as DD) almost every day. Here you can think of the usual method search through a chain of prototypes in JavaScript. But there are different variants of DD and one more of them is often encountered: DD based on a virtual table. The idea is that we have some table in memory, in which it is prescribed that for such data type this and that function implementation should be called. For a general understanding of the question, I will give you the following code:

class Toad {
sleep() {
// …
}
}

class Lizard {
sleep() {
// …
}
}

function lull(animal) {
animal.sleep(); // #14
}

const toad = new Toad();
lull(toad);

Imagine that this is not JavaScript, but some other language with classes. On line #14, how to understand: which implementation of the sleep method to call?

Now the question is: How can we use these concepts when designing our program? Let’s look at an example from mlut, which uses an approach similar to DD with a virtual table.

/* _at-rules.scss */
$at-rules-db: (
‘media’: (
‘alias’: ‘m’,
‘default’: true,
),
‘supports’: (
‘alias’: ‘s’,
),
‘container’: (
‘alias’: ‘c’,
),
);

/* _mk-ar.scss */
@mixin -generate-ar($at-rules, $this-util, $ar-list, $cur-index, $last-index) {
/* … */

$converter: map.get(ml.$at-rules-db, $ar-name, ‘converter’);

@#{$ar-name} #{meta.call($converter, $ar-str, $this-util)} {
/* … */
}
}

Here we have an excerpt from the code in which at-rules conversion takes place. We have $at-rules-db – a config with data about all at-rules: their abbreviations, converters etc. We can use this config as a kind of virtual table. Then, in the -generate-ar mixin, we see which at-rule we are working with ($ar-name), and based on that, we get the necessary converter from the config. Next, we apply it to the $ar-str abbreviation chain.

What are the advantages of this approach? It gives us good extensibility. When adding a new at-rule, we don’t need to go to the kernel code and fix something. That is, a new at-rule can be added simply from the config, when connecting the library. We will consider such an example further on.

Some time ago I received an issue with a question about container queries support. I answered it with a ~20 lines snippet of code that could be used to add basic support for this feature via config!

In comparison, to add container queries to Tailwind, the guys had to come up with a new syntax and write a 70 line plugin.

JIT engine

Lastly, let’s talk about JIT engines in Atomic CSS tools. But first, let’s remember a bit of history.

How the old generation tools (Tailwind v1, Tachyons, etc) worked:

Generate over9000 utilities for all occasions
Use some of them in our markup
Add a program to the build that looks at our markup and removes unused CSS

What are the problems with this approach:

It is tedious or impossible to use arbitrary utility values
Regularly have to edit config to add new utility values
Large CSS bundle in development mode

To solve these problems there is a new approach called JIT mode. There is nothing in common with JIT compilers here, it’s more of a marketing name. With JIT mode everything becomes easier:

Writing (almost) arbitrary utilities in markup
The JIT engine looks at our code and generates only the utilities we used

Historical note

Some believe that the JIT engine first appeared in Windi CSS to solve Tailwind v2 problems. Then, the Tailwind team stole adopted the solution. I think they were the ones who coined the term JIT-engine back then.

But few people know that the JIT engine was still in the first versions of the Atomizer, back in 2015! Knowing this, it was amusing to see the pathos statements of its aforementioned “reinventors”, and the musings of Anthony Fu on the topic

General scheme of JIT engine operation:

Find the content files
Scan them and get the utilities
Generate CSS for found utilities

And already our regular column: review of current solutions

Tailwind

At the heart of the engine here is a big PostCSS plugin. This means we get both the pros and cons of PostCSS. It’s easy to do integrations with bundlers and other plugins from the ecosystem. But you have to work with AST PostCSS and settle for medium performance.

Although a new engine, Oxide, is planned for Tailwind v4, which will solve some of the weaknesses of the current one

UnoCSS

It uses its own utility generator. Judging by benchmark and authors’ statements: it is the fastest and there are a lot of optimizations. There is integration with the main popular bundlers. Also, there are many additional features, such as attributify and shortcuts

Atomizer

This is also good: it uses its own utility generator. It is relatively simple, but with legacy dependencies like lodash. There are also integrations: for most bundlers there are plugins via unplugin, and for some of them there are separate packages

mlut

mlut has distinguished itself here too, but not for the better. Now let’s understand why. Here we have almost like in compilers: there is a frontend and a backend

Front: TypeScript
Back: Sass

CLI / plugin
Utilities generator and settings

JIT engine
CSS library

Sass compiler

The main question here is: how to link Sass and JS? We need to somehow get data from Sass config, pass the collected utilities to the generator etc. To solve these problems we use an approach that I call: Sass in JS.

The gist of it is this:

Load the code of the required Sass module
Add the code to it
Compile the final script in CSS
(if necessary) Get data from the output

What it looks like in code

We take the contents of the user’s input Sass file (Sass entry point), or the default config from the example below, if there is no input file:

/* default userConfig */
@use “sass:map”;
@use “../sass/tools/settings” as ml;

Next, we add some Sass code to the end of the input file, where we execute the logic. For example, get something from the settings. Compile the resulting code:

const { css } = (await sass.compileStringAsync(
userConfig + n a{ all: map.keys(map.get(ml.$utils-db, “utils”, “registry”)); },
{
style: compressed,
loadPaths: [ __dirname, node_modules ],
}
));

The output is this funny CSS rule, where the all property contains a list of names of all utilities from the registry. Then, we just extract the target data from it

a {
all: “Ps”, “T”, “R”, “B”, “-X”, “-Y”, “-I”, /* etc */
}

There are a few more interesting features of Sass as a language

No rantime. The code is simply compiled and some CSS is output. So this is like a classic PHP: for every style rebuild, “the world is re-created” – all the settings are loaded, including the utility registry of 2k+ lines. Yes, this is not particularly optimal, but thanks to the native Dart-compiler Sass – works quite fast. On a small project, even faster than Tailwind v3, especially cold starts. Because every start here is like a cold start, although at some point, the JIT in the JS engine may turn on.

Too few features in the language. No classes and objects, not even regexp. But there are some things from FP: higher-order functions, immutable data structures, etc. It may seem strange, but writing in such a language is an interesting and even useful experience. Perhaps something similar is experienced by those who work with Clojure, but it’s not certain, I haven’t tried it yet. The idea is that when a language has few features, it forces you to combine a small number of basic elements to get complex logic. And that’s one of the basic skills of a good engineer.

Maximum integration with CSS. At one time I thought: “why do I keep writing a complex program in Sass, instead of rewriting everything in Rust JS”. One of the answers was precisely “maximum integration”. Where authors of other generators had to write logic to work with CSS selectors, I took functions from the Sass standard library. Where in JS you have to take into account the units of measurement of numbers (1px, 1rem), in Sass such values are first-class citizens. The same can be said about translating Sass lists into CSS lists and many other things.

Conclusion

Here are some of the insights I gained while working on the project:

Don’t be afraid of crazy ideas. For example, such as: “Write a complex program on the CSS preprocessor”. It is quite possible that in the process you will create something innovative and outstanding. Or just get a very useful experience.

Try to go all the way, to get to the root of the problem. And having understood the root of the problem, come up with a solution that will eliminate the true cause so that the problem would not arise in principle. After all, often when we solve some problem, we stop at treating some consequence of a more fundamental problem or a semi-kludge solution. Usually business gives us limited resources and this is understandable. But as soon as the opportunity arises – try the above approach.

Failure is also a result. As astrophysicist Konstantin Batygin used to say:

99% of a scientist’s work is fails.

Even if you didn’t manage to make the fastest framework at the first attempt, you shouldn’t be upset. You got useful experience that somehow changed and improve you. For example, you can make a good talk out of it and become a speaker at a tier 1 conference

And lastly, I want to explain again why I did all this.

I was trying to solve the problems of the existing tools we looked at in the beginning. I wanted to try to maximize the potential of the Atomic CSS approach. I was excited about it because I had dreamed of working with such a tool, but at that moment, there was no such tool on the market.

I want to show the community all these original ideas and interesting technical details that mlut has. I think it will help the industry at least a little bit. So right now I’m promoting the tool and ideally want it to make it into the State of CSS survey. So would appreciate any feedback and help on this.

I have a dream: I want to become a full-time open source developer. I see the project as the beginning of my career in this field and a “warm-up before the big game”. I should add that I have no goal to “take over the world” with mlut or “kill” Tailwind. I am well aware that the tool is rather niche and doesn’t have such potential by design. But I would like to confidently enter the market, fight with the top analogues, find my audience and benefit them!

That’s it! Subscribe to my telegram channel (RU), put stars on github mlut, well I’ll be glad to see your comments!

Please follow and like us:
Pin Share