DEV Community

Cover image for Atomic CSS Deep Dive
Valik Ulyanov
Valik Ulyanov

Posted on

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:

Markup in Atomic CSS

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.

Myths about Atomic CSS

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
Enter fullscreen mode Exit fullscreen mode

Turns this into the following CSS:

@media(any-hover:hover) {
  .\[\@media\(any-hover\:hover\)\{\&\:hover\}\]\:opacity-100:hover {
    opacity: 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

supports-[margin:1svw]:ml-[1svw] 
Enter fullscreen mode Exit fullscreen mode
@supports (margin:1svw) {
  .supports-\[margin\:1svw\]\:ml-\[1svw\] {
    margin-left: 1svw;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 */
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Windi Lang Draft

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') }
      )
    })
  ]
}
Enter fullscreen mode Exit fullscreen mode

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`,
    }
  },
],
Enter fullscreen mode Exit fullscreen mode

Actual solution

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

mlut is an abbreviation for My Little UI Toolkit

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

No, it's not Tailwind in Sass

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!

Sass release stats

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.

Schematic diagram of the utility 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

Concise code, easier to write...

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.

JSON with all CSS properties

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

Some specs, that I saw

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.

Chrome platform status

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):

Table of CSS properties and their 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:

  1. Find properties that start with the same letter
  2. Rank them by popularity (mostly, but not only)
  3. Select groups with the same first word
  4. 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

  1. color => C
  2. cursor => Cs

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

  1. color => C
  2. cursor => Cs
  3. 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

  1. content => Π‘t
  2. contain => Cn

III. Next vowel (without skipping over a consonant)

  1. content => Π‘t
  2. 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>]
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

How to draw an owl

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!

CSS research process

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:

The process of designing the mlut syntax

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:

  1. CSS at-rule: breakpoints, @supports, etc
  2. pre-states - part of the selector before the utility class name
  3. Name
  4. Value
  5. 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

Utility with post states

Utility with pre states

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:

@supports syntax from the specs

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

@media syntax from the specs

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Utility with complex @media

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

Utility with several at-rules

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

Priests meme

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...

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.

JSON with CSS syntaxes

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 */
),
Enter fullscreen mode Exit fullscreen mode

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 */
),
Enter fullscreen mode Exit fullscreen mode

General scheme of conversion

  1. The full utility value is broken down (by space or delimiter) into simple values
  2. Each simple value goes through a chain of converters until 1 of them not to trigger
  3. A transformer is applied to the CSS value
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode
  • $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'),
),
Enter fullscreen mode Exit fullscreen mode

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'
  ),
),
Enter fullscreen mode Exit fullscreen mode

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:

Utility for CSS gradients

The first reaction might be something like this:

WAT?

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',
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
        },
      })
    })
  ]
}
Enter fullscreen mode Exit fullscreen mode

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') }
      )
    })
  ]
}
Enter fullscreen mode Exit fullscreen mode

UnoCSS

Adding values here is also quite simple:

theme: {
  // ...
  colors: {
    'veryCool': '#0000ff', // class="text-very-cool"
  },
}
Enter fullscreen mode Exit fullscreen mode

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` })],
]
Enter fullscreen mode Exit fullscreen mode

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,
      ),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

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 = %d\n", calc.operation(5, 3)); // #17

  calc.operation = subtract;
  printf("5 - 3 = %d\n", calc.operation(5, 3));

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)} {
    /* ... */
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Ancient CSS frameworks

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.

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;
Enter fullscreen mode Exit fullscreen mode

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' ],
  }
));
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)