Checking Tailwind Class Names at Compile Time with Rust

At the end of my last post, “Frontend Rust Without Node”, I talked about my big issue with using Tailwind CSS. It has a huge number of classes, I can’t remember their names, so I often typed them incorrectly. This made it difficult to figure out why my styling wasn’t doing what I thought.

Here’s a recap of why this is the case …

Tailwind consists of class names and “modifiers”. A class name is something like text-lg or grid. Any class name can also have a variety of modifiers attached to it, separated by colons, so you can write something like this:

1
2
3
<div class="hidden lg:visible hover:background-indigo-200">
  Content that will only be visible above a certain screen size.
</div>

You can also combine modifiers to create classes like lg:hover:background-indigo-200. So while there are “only” a few hundred CSS class names, the number of names you can use is "base" names × modifiers! (that’s a factorial sign on modifiers!). It’s not really a factorial since you can’t combine every modifier with every other modifier (sm:md:lg:visible makes no sense), but it’s a lot more than a simple multiplication.

As such, it’s not practical to simply generate a CSS file with all possible classes. Well, I lied. It’s entirely practical because it’s trivially doable. Just add this to your tailwind.config.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module.exports = {
  content: {
    ...
    safelist: {
      pattern: /.*/,
      variants: [ "sm", "md", "lg", "xl", ... ],
    }
    ...
  }
};

… and then run the tailwindcss program to generate the CSS file1. When I tried this, without even including all variants, I ended up with a 7MB CSS file and I’d only be using a tiny fraction of what it contained.

So it’s not a good idea, and it’s not how the Tailwind CSS authors intend Tailwind to be used. Instead, the tailwindcss program will scan your code with some broad regexes to find strings that could be Tailwind CSS class names. Then it generates a CSS file containing just those strings which match actual Tailwind names.

But this scanning process, because it matches so broadly, errs on the side of false positives, and the tailwindcss program will not emit any warnings when it finds a string that could be a match, but which isn’t. If it did that, you’d end up with hundreds or thousands of warnings quite quickly, as it could match nearly every variable and function name in your codebase, depending on your naming conventions.

So in my first attempts to use Tailwind with Dioxus, my workflow ended up like this:

  • Add some class names in my React/Dioxus/Seed/Yew code.
  • Re-run tailwindcss against my code base to regenerate my “compiled” CSS file. 2
  • Look at my app, which since I’m using Trunk will have hot-reloaded in my browser.
  • Scream into the void when my CSS changes did not do what I intended, usually doing absolutely nothing. Then try to debug what happened by asking:
    • Did my CSS actually get regenerated or is my Trunk config not doing what I think it should do?
    • Does the regenerated CSS contain the new class names?
      • If yes …
        • Did the browser properly load the new CSS file3?
        • Does the CSS do what I think it does when attached to the element I think I attached it?
      • If no …
        • Did the tailwindcss work as I expected it to or did I screw up its config so it didn’t see the class names I just added?
        • Or did I typo a class name for the thousandth time today?

I spent a lot of time asking these questions. And most of the time, I had typoed a Tailwind class name but nothing in my toolchain was telling me I had done so.

This was annoying.

It was doubly annoying because I’m using Rust. If Rust does one thing well, that thing is telling me at compile time all the many things I did wrong.

Enlisting the Rust Compiler to Check my CSS

Fortunately, I knew I could make the Rust compiler check this for me. When I experimented with Seed before Dioxus, the quickstart template I used included a plugin for PostCSS written by Martin Kavik, postcss-typed-css-classes, that hooked into PostCSS and generated Rust code for all of its classes4.

But I didn’t want to use that plugin for a couple of reasons:

  1. I had so far managed to avoid needing to run node for my project, so I didn’t want to use PostCSS, which requires node.

  2. The code generated by that PostCSS puts all of the classes, tens of thousands of them, into a single struct in an 8MB file. The reason it’s so large is because it includes a huge number of class × modifiers, and it doesn’t even come class to including all possible modifier/class combinations.

    This killed my editor5 when it came to auto-completion. Even loading the generated file in my editor is slow, probably because of syntax highlighting. And jumping around the file or searching in it is also quite slow.

Obviously, #2 is fixable, but to fix #1 I needed a new tool, ideally written in Rust, since that’s what everything else I’m using is in.

So I Wrote That New Tool

It’s called tailwindcss-to-rust. It generates Rust code with all of the available Tailwind CSS class names and modifiers as static strings. It doesn’t generate strings for modifier/class combinations, which means that the full file is only 624kb. That’s still pretty big, but an order of magnitude smaller than the one generated by the PostCSS plugin. My editor takes a slight pause when it loads, but it’s only a second or two. And jumping around the file and searching it is quick enough to feel instantaneous.

And to further speed up code completion, I split up the classes into a set of structs, where each struct represents a “group” of classes based on function (layout, typography, animation, etc.). These groups are taken from the Tailwind documentation headings.

Unfortunately, there’s nothing in the Tailwind codebase to make this easier. There’s no list of all the available class names, and there’s no reference to the documentation groups in the codebase at all. So all the information I needed, the group and class names, only exists in the documentation or in a generated CSS file. And to make it even worse, the documentation itself is entirely generated by code.

Fortunately, as an old school Perl hacker, I know how to whip up some horrible hacks, sanity be damned! I wrote a Perl script6 that crawls the Tailwind documentation site and generates a Rust data structure mapping individual class names to groups.

If you’re running in terror, don’t worry, you don’t need to use Perl to use the tailwindcss-to-rust tool. I wrote the Perl to help me write the Rust to generate the Rust. And you just need to run the Rust that generates the Rust, not the Perl that generates (some of) the Rust to generate the Rust. I hope that clears things up.

The actual generator, tailwindcss-to-rust (written in Rust) takes as its input your tailwind.config.js file and an input CSS file for the tailwindcss program. We’ll call that input file tailwind.css for this explanation. This input file is usually just a few lines, see step 3 of the Tailwind installation docs for details. Then the generator does the following:

  • Creates a temp directory.

  • Copies your tailwind.config.js and input CSS file to the temp dir.

  • Adds safelist: [ { pattern: /.*/ } ] to the tailwind.config.js in the temp dir.

  • If the directory containing the given tailwind.config.js file contains a node_modules directory, that directory is symlinked from the temp directory. This is so it can access any tailwind plugins in that directory. I’m honestly not sure if this achieves anything, but I haven’t experimented with any plugins that don’t ship as part of the tailwindcss binary.

  • Runs tailwindcss in the temp directory, using the modified config file. Because it added that safelist item to the config, the generated file will include every possible CSS class. The exact classes vary based on what Tailwind plugins you are using.

  • “Parses” the generated CSS file to find all the class names it contains.7

  • Generates Rust code with structs for all of those classes. If there are class names the generator doesn’t recognize then they are put in a struct named “Unknown”.

    In the future, I may add an option to provide a group mapping for class names. If this tool sees broader adoption I’m sure people will want this, because one of the most powerful features of Tailwind is that you can quite easily create custom classes and modifiers.

The generated Rust code looks like this8:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#[derive(Clone, Copy)]
pub(crate) struct Modifiers {
    pub(crate) active: &'static str,
    pub(crate) after: &'static str,
    ...
    pub(crate) lg: &'static str,
    pub(crate) ltr: &'static str,
    ...
    pub(crate) visited: &'static str,
    pub(crate) xl: &'static str,
}

pub(crate) const M: Modifiers = Modifiers {
    active: "active",
    after: "after",
    ...
    lg: "lg",
    ltr: "ltr",
    ...
    visited: "visited",
    xl: "xl",
};


#[derive(Clone, Copy)]
pub(crate) struct Accessibility {
    pub(crate) not_sr_only: &'static str,
    pub(crate) sr_only: &'static str,
}

pub(crate) const ACCESSIBILITY: Accessibility = Accessibility {
    not_sr_only: "not-sr-only",
    sr_only: "sr-only",
};

...

#[derive(Clone, Copy)]
pub(crate) struct Sizing {
    ...
}

...

pub(crate) const C: C = C {
    acc: ACCESSIBILITY,
    ...
    siz: SIZING,
    ...
};

Then you can use the generated code like this:

1
2
use gen::{C, M};
let class = [[M.lg, C.siz.w_6].join(":").as_str(), C.typ.text_lg].join(" ");

If you remember, back at the beginning of this post, I mentioned that the tailwindcss program scans your code to figure out which class names you are using, and then generates a CSS file with only those classes. But to turn class names like “w-3/6”, “h-0.5”, or “text-lg” into valid Rust identifiers, I had to transform them a bit. This means that tailwindcss will no longer recognize what classes you’re using!

Fortunately, Tailwind allows you to provide a custom “extractor” to find class names, on a per-file extension basis. So you need to modify your tailwind.config.js file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
module.exports = {
  content: {
    files: ["index.html", "**/*.rs"],
    // You do need to copy this big blog of code in, unfortunately.
    extract: {
      rs: (content) => {
        const rs_to_tw = (rs) => {
          if (rs.startsWith("two_")) {
            rs = rs.replace("two_", "2");
          }
          return rs
            .replaceAll("_of_", "/")
            .replaceAll("_p_", ".")
            .replaceAll("_", "-");
        };

        let classes = [];
        let class_re = /C\.[^ ]+\.([^\. ]+)\b/g;
        let mod_re = /(?:M\.([^\. ]+)\s*,\s*)+C\.[^ ]+\.([^\. ]+)\b/g;
        let matches = [...content.matchAll(mod_re)];
        if (matches.length > 0) {
          classes.push(
            ...matches.map((m) => {
              let pieces = m.slice(1, m.length);
              return pieces.map((p) => rs_to_tw(p)).join(":");
            })
          );
        }
        classes.push(
          ...[...content.matchAll(class_re)].map((m) => {
            return rs_to_tw(m[1]);
          })
        );
        return classes;
      },
    },
  },
  ...
};

What the custom extractor does is find places in the Rust code that use modifiers or class names, then it transforms the names from Rust identifiers back to the Tailwind CSS names.

And with that in place, you now have compile-time checked Tailwind CSS class names, and a workflow that uses the tailwindcss tool without requiring node, npm, or yarn.

You might be tempted to add the tailwindcss-to-rust invocation to your Trunk.toml file (or other bundler tool). But in many cases, this won’t be necessary. For most projects, you will run the generator very rarely, possibly running it once only. The only things that require a re-run are:

  1. You add/remove plugins from your tailwind.config.js.
  2. You make changes to your tailwind.config.js that change the names of custom CSS classes you have configured.

So unless you have a config that generates custom names, you will rarely need to regenerate your CSS file. If you do have custom config, then it may make sense to have Trunk run tailwindcss-to-rust.

The Ergonomic Macros

The example I gave of using the generated structs earlier was this:

1
2
use gen::{C, M};
let class = [[M.lg, C.siz.w_6].join(":").as_str(), C.typ.text_lg].join(" ");

I said this was gross, and there are a couple reasons I think so. First, I hate having to manually join modifiers with a colon, and then the overall class list with the space. Second, because the first join with the modifier produces a String, you have to convert it to a &str to join it with the static &str in C.typ.text_lg. You could also write C.typ.text_lg.to_string() and drop the earlier .as_str(). But yuck either way.

You’ll be using these modifiers and classes a lot, so having to constantly repeat these join calls is horrible. To make using this generated code not horrible, I wrote a crate with helper macros called tailwindcss-to-rust-macros. Much of this crate’s content is a slightly tweaked version of code copied from the Seed framework codebase, adjusted to make it more generic.

Using the macros looks like this:

1
let class = C![M![M.lg, C.siz.w_6], C.typ.text_lg];

Yay, no join calls! The “arguments” to these macros can be any of these types:

  • &str
  • String
  • &String
  • Option<T> and &Option<T> where T is any of the above.
  • Vec<T>, &Vec<T>, and &[T] where T is any of the above.

There’s also a DC![...] macro for use with Dioxus inside its rsx! macro.

The big downside of using macros is that you won’t get any auto-completion help from your IDE inside the macros, at least for now9. This is a bit ironic since one of my main motivations for this tool was to make something that worked better with auto-completion. But there are some tricks. You can write this:

1
let class = [[M...., C.siz....], C.typ...

Where the ... is where your IDE will kick in and provide auto-completion. Then you can transform that into the equivalent macros. I bet you could even write an editor plugin to do this for you, but I haven’t done this yet.

If you hate macros, you could just write some helper functions:

1
2
3
4
5
6
7
8
9
fn m(names: &[&str]) -> String {
    names.join(":")
}

fn c(classes: &[&str]) -> String {
    classes.join(" ")
}

let class = c(&[&m(&[M.lg, C.siz.w_6]), C.typ.text_lg]);

This isn’t entirely terrible, but that sure is a lot of references to read. And they don’t handle all the Option and Vec/slice combinations that the macros handle.

A Future Feature?

One person who looked at this tool commented that they didn’t like the name transformations I used and would prefer to just use the original Tailwind names in code. I was thinking about how this might work and I think you could use these names with a procedural macro. So you could write this …

1
let class = C!["hidden", "lg:visible", "w-6", "text-lg"];

… and it would produce code something like this:

1
2
3
4
5
6
let _ = C.lay.hidden;
let _ = M.lg;
let _ = C.lay.visible;
let _ = C.siz.w_6;
let _ = C.typ.text_lg;
let class = ["hidden", "lg:visible", "w-6", "text-lg"];

But there are a some wrinkles. First, it’s not clear how to go from a class name to its group at compile time. How does the macro know that “hidden” belongs to C.lay? This might require producing a single struct with all the classes so the generated code could just reference C.hidden. Or maybe it could generate a bunch of structs split up by the first letter of the class name if one big struct causes editor issues.

Second, I suspect the compilation errors from typos will be kind of horrible, since they’ll end up referring to things like C.lay.hiddden that simply don’t exist in the code you wrote.

But if someone wants this, please make an issue in the repo and we can discuss it.

Putting It All Together

You’ll probably want a module in your code that wraps up the generated code and macros together into a convenient set of exports. The macros documentation shows you how to do that.

There are a lot of moving parts here, so here’s the summary:

  1. Follow the instructions for installing and running the tailwindcss-to-rust tool.

  2. Create the module as described in the docs for the macros.

  3. Import the module and use it:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    use css::*;
    
    fn some_func() {
        let class = C![
            C.spc.p_2,
            C.typ.text_white,
            M![M.hover, C.typ.text_blue],
        ];
        ...
    }
    

And that’s how you can have compile-time checking for your Tailwind class names. Of course, in doing all of this I’ve probably learned more about the Tailwind class names than I ever knew before, so I’ll never typo a class name again. Hah!


  1. See my “Frontend Rust Without Node” post for a lot more details on what the tailwindcss tool is and how it’s used. ↩︎

  2. Which you can automate with Trunk. See my “Frontend Rust Without Node” post for example code. ↩︎

  3. Trunk should make sure this happens by appending a content hash to the CSS file to ensure your browser doesn’t use an old cached version. ↩︎

  4. The template also uses PostCSS to generate the “compiled” Tailwind CSS file, which is a way to use Tailwind without needing to run tailwindcss↩︎

  5. I’m using Emacs (a great OS with excellent editing built-in) along with the fabulous LSP mode to give me the full IDE experience. As an aside, I only started using LSP mode a few years ago, and it’s been a huge game-changer when writing code in languages with a good LSP server, mostly Go and Rust in my case. ↩︎

  6. I could have written this in Rust, but for me this sort of thing is much, much quicker to whip up in Perl, especially using some great libraries off CPAN, notably LWP::Simple and Mojo::DOM↩︎

  7. I put “parses” in quotes because all it does is use a regex to match names like “.foo”. I tried using some CSS parsing crates but they were all enormously complex, and just getting a list of all the classes in a file was ridiculously hard. But then I remembered I’m an old-school Perl hacker and that regexes are always the best worst solution to any problem. ↩︎

  8. The default is pub(crate) but you can make it pub with the --visibility flag. ↩︎

  9. See the “IDEs and Macros” post on the rust-analyzer blog for why. ↩︎