February 4, 2019

In Search of the Perfect Tailwind Config File Structure

Missed updating this journal last week, but that's because for the last three weeks I have been working on the same damn thing: driving myself insane trying to figure out how the Tailwind config file should be structured for 1.0.

It will be a significant breaking change (although I plan to make the upgrade path pretty painless), and I want to get it right now so I never have to change it again.

TL;DR, I think I finally have it figured out — check out the pull request to see what I'm planning.

Why the hell am I even doing this?

When we first created the Tailwind config file, Tailwind was missing a bunch of features we have now, such as:

  • Options for things like configuring a prefix, a custom separator, making utilities !important, etc.
  • Being able to configure what variants (hover, focus, etc.) were generated for each module
  • Being able to entirely disable a utility module
  • The plugin system, for extending Tailwind programmatically with JS

So at the time, it made sense that all of the values for each utility lived in top-level keys in the config (like textSizes, backgroundColors, etc.) because those were the only types of options that existed.

But eventually people wanted to be able to do things like configure a prefix, so the options key was born.

Later we added the ability to disable modules and configure which variants were generated for each module by bolting on a modules key that contained an entry for every single module in the system (including modules that had no other configuration, like display, position, etc.)

So right now the config file has this sort of structure:

module.exports = {
  colors: colors,
  screens: { ...},
  fonts: { ... },
  textSizes: { ... },
  // ...
  svgFill: { ... },
  svgStroke: { ... },

  modules: {
    appearance: ['responsive'],
    backgroundAttachment: ['responsive'],
    // ...
    zIndex: ['responsive'],
  },

  plugins: [
    require('./plugins/container')({
      // center: true,
      // padding: '1rem',
    }),
  ],

  options: {
    prefix: '',
    important: false,
    separator: ':',
  },
}

It's not terrible but had I known we were going to add more top-level keys that weren't really related to your design system (like modules, plugins, and options), this is not how I would have structured the config file. There's just something that seems dumb about backgroundColors and modules being siblings, instead of all of the style-related options being grouped together under one key.

Tailwind 1.0 is my chance to make a more informed decision about how to structure this config object, so I really want to come up with something I feel good about before locking myself in for another major release cycle.

Plugins everywhere!

A few weeks ago I did some work to convert all of Tailwind's built-in utility modules to use the plugin system instead of the original internal API.

After I had done this, I thought "wouldn't it be nice if Tailwind was really just an engine for turning these plugins into classes, and by default your scaffolded config file contains all of Tailwind's core plugins?"

It sounded really nice in theory! There wouldn't be any true "built-in classes" in Tailwind — everything would just be a plugin, and we would just include a big list of recommended plugins in your config file when we generate it for you.

Conceptually it was so simple. Users wouldn't have to learn how to configure Tailwind's built-in styles and learn the plugin system, there would just be one thing to learn and understand.

The config file sounded really elegant in my head, but when I actually wrote out the whole file to see what it would look like, it was truly offensive.

I spent the next week or so trying to figure out how to make this same approach feel cleaner and more manageable, with no shortage of help from Jonathan Reinink and Sam Selikoff, two guys in particular who let me get away with wasting way too much of their time.

Sam maintains ember-cli-tailwind, an Ember add-on for quickly integrating Tailwind into your project. One of the interesting decisions he made with that project was to split all of the contents of the config file into multiple files.

This got me thinking, what if instead of scaffolding a single file for the user, we scaffold them a directory?

We could make the config file a lot simpler this way, turning it into something like this:

const plugins = require('./plugins')

module.exports = {
  prefix: '',
  important: false,
  separator: ':',
  screens: {
    sm: '576px',
    md: '768px',
    lg: '992px',
    xl: '1200px',
  },
  plugins: plugins,
}

The plugins folder could contain an index.js file that just listed all of the default plugins, and we could even extract the configuration for each plugin into separate files and throw those into another folder like ./styles so you could easily import that information into your JS components if you wanted to as well.

Each style file could look something like this:

// ./styles/borderRadius.js

module.exports = {
  variants: ['responsive'],
  values: {
    none: '0',
    sm: '.125rem',
    default: '.25rem',
    lg: '.5rem',
    full: '9999px',
  }
}

The only way to know if I would actually like the end result was to manually create all of these files, so I got to work.

Why it sucked

It sucked, for a few reasons:

  1. Pointless files.

    So many of these files were exactly the same. Because many of the core plugins (like appearance, backgroundAttachment, cursor, display, etc) only let you customize the generated variants and not the generated values, they all had exactly the same file contents:

    // ./styles/{lotsOfPlugins}.js
    
    module.exports = {
      variants: ['responsive'],
    }

    I considered trying to simplify things by writing some DSL in JS that would be published in the user's config, but the simple index of plugins would start to feel like real code instead of just configuration really quickly, and that seemed like a dark road to go down:

    // ./plugins/index.js
    function variants(variants) {
      return { variants: variants }
    }
    
    module.exports = {
      require('tailwindcss/plugins/appearance')(variants(['responsive'])),
      // ...
      require('tailwindcss/plugins/borderRadius')({
        variants: ['responsive'],
        values: {
          none: '0',
          sm: '.125rem',
          default: '.25rem',
          lg: '.5rem',
          full: '9999px',
        }
      }), 
      // ...
    }
  2. Overwhelming scaffolding.

    Running tailwind init and suddenly having multiple folders, some with over 50 files depending on what iteration of this idea I was working on, seemed like a great way to make users say "holy crap okay nope get your dirty tentacles out of my project" and immediately git reset --hard.

    Even though all of those folders and files contained no more information than our current 500 line config file, it felt a lot heavier and like it was trying to take over your project. I feel like the barrier to entry with Tailwind is already sort of high with setting up your bundler of choice to handle PostCSS stuff properly, and overwhelming new users with a massive configuration folder would only make it higher.

  3. No distinction between defaults and customizations.

    There was no strong indicator (other than the npm package name) which plugins were there when you scaffolded the config file, and which ones you added yourself. I could have added some comments to organize the plugins list into different sections or something, but that didn't feel like a real solution to me.

    On top of that, if you disabled a core plugin by deleting it from the list, there was nothing left behind to tell you you made that customization, or to clue you in that that plugin exists if you wanted to re-enable it later.

    These same problems applied to the configuration within each plugin as well.

Embracing the defaults

I was starting to feel pretty defeated at this point, having tried everything I could possibly think of to organize this stuff in a sane way while still pursuing the "everything is a plugin, Tailwind is just a dumb plugin engine" approach.

My friend Jason McCreary offered to hop on a call and work through some of it with me in case he could offer any insights, and despite Jason not really being part of my usual inner circle of Tailwind collaborators, it was probably the most helpful conversation I had.

Essentially Jason convinced me that exposing everything as a plugin was a bad idea, and that he believed one of Tailwind's core value propositions was providing a great starting point for most projects. Yes the ability to customize was crucial, but the core utilities in Tailwind really should feel like part of the framework itself, and not just an example of the sorts of classes you could generate by including a bunch of plugins.

I ran a carefully phrased survey on Twitter to see if other people agreed and was pretty surprised by the results.

I asked:

What do you value more about Tailwind, the flexibility and ability to customize, or the well-chosen default styles?

I was really surprised when 54% of respondents chose the default styles. I knew the defaults were important, and we worked our asses off to make them as good as we could, but I really expected that to be everyone's second-favorite feature, with customization coming first. I thought it would be maybe 85%/15%, favoring customization over the default styles.

So with that information reinforcing the conversation I had with Jason, I got to work on a new approach to the config file that optimized for extending the defaults rather than encouraging complete replacement from the beginning.

Instead of tailwind init scaffolding out a giant 500 line config file with every default value right there in front of you (or dozens of files in complex folder structures), I decided that the config file should not only be extremely short, it should be entirely optional.

I took the opinion that your config file should be where you look to see what you've changed, not what the entire design system looks like, defaults and all.

A simple config file might look something like this:

const defaultTheme = require('tailwindcss/defaultTheme')()

module.exports = {
  important: true,
  theme: {
    colors: {
      ...defaultTheme.colors,
      primary: defaultTheme.colors['blue']
    },
    margin: {
      ...defaultTheme.margin,
      '48': '12rem',
      '64': '16rem',
    }
  },
  variants: {
    opacity: ['responsive', 'hover']
  }
}

...where the only changes the user has made is making utilities !important, adding a new color to the color palette, adding two extra margin sizes, and adding hover variants for the opacity classes.

I've already written about the new config file format in detail in the pull request, so rather than explain it all again here I encourage you to check out the PR for the whole story.

It was a brutal process with lots of paralyzing decisions along the way (and more to come still I'm sure), but I feel very good about the direction I landed on and think it's going to make the framework even more approachable than it is in 0.x.

Other updates

  • I published a new Full Stack Radio episode last week, part two of my conversation with Sam Selikoff about building single-page apps. It's a really good listen — my favorite episodes are the ones where I have a ton of questions about something I'm really trying to understand and I get to try and get the guest to clear it all up, and this episode is a perfect example of that.
  • I'm trying to get the ball rolling on the Full Stack Radio heavy metal t-shirts I had designed last year. Have the info I need and really just need to set something up to start taking pre-orders. Wanted to do it last week but ended up working on Tailwind stuff all week instead. Hopefully this week.
  • I live-streamed some work on Tailwind 1.0 which was a lot of fun. It's nice to be out of decision-making mode a bit and into executing mode, because that stuff is a lot easier to share.
  • I never finished that Firebase-driven comment system for the journal updates, might still do it, might not. Either way was fun to get a better understanding of what Firebase is all about.
  • I think doing these journal updates once a week is actually harder than it would be to do it every day or couple of days — just a lot of work to remember and summarize everything that happened. I bought devjournal.app a while ago, might use my 20% time to build a little community where others can start doing this same sort of thing since people seem to like the idea. Would be good for live streams, could open-source the whole thing, and maybe even charge a small membership fee to get me to take it more seriously.