A CSS project boilerplate

For the many folks who ask how I write CSS since removing Sass, this is how I and the Set Studio team do it in 2024.


When it comes to the core of CSS stuff, we do things very much the same each time at the studio. The output varies project-to-project, but the core principles stay the same regardless.

Because of that, I recently created a boilerplate which I’ve made open to everyone. I thought it would be a good idea to write about it too.

It’s CUBE CSS all the way down permalink

Absolutely no surprises it’s all CUBE CSS — a methodology I wrote and which we maintain in the studio.

Here’s the directory structure:

Code language
text
.
├── src
│   ├── css-utils
│   │   ├── clamp-generator.js
│   │   └── tokens-to-tailwind.js
│   ├── css
│   │   ├── blocks
│   │   │   └── prose.css
│   │   ├── compositions
│   │   │   ├── cluster.css
│   │   │   ├── flow.css
│   │   │   ├── grid.css
│   │   │   ├── repel.css
│   │   │   ├── sidebar.css
│   │   │   ├── switcher.css
│   │   │   └── wrapper.css
│   │   ├── global
│   │   │   ├── fonts.css
│   │   │   ├── global-styles.css
│   │   │   ├── reset.css
│   │   │   └── variables.css
│   │   ├── utilities
│   │   │   ├── region.css
│   │   │   └── visually-hidden.css
│   │   └── global.css
│   └── design-tokens
│       ├── colors.json
│       ├── fonts.json
│       ├── spacing.json
│       ├── text-leading.json
│       ├── text-sizes.json
│       ├── text-weights.json
│       └── viewports.json
├── tailwind.config.js
└── postcss.config.js

There’s a lot going on and I’m certainly not going to go through it all because parts of it are changeable, such as the source and structure of design tokens.

I’ll focus on the CSS and the stuff that makes the CSS work.

What the heck is Tailwind doing in here? permalink

You might be thinking “Andy?! Tailwind?!?!” but I dunno, I’ve already written about how useful Tailwind is as a utility class generator. It really is useful too in this context.

The main thing Tailwind is useful for is it generates utility classes on demand. Previous tools I used and wrote generated all the utility classes which bloated things up a bit. Tailwind also bloated the hell out of things out of the box and it’s taken a couple of years of tinkering to get it to a place where it’s not in the way of progress, but I got there.

Here’s the config in full:

Code language
js
const plugin = require('tailwindcss/plugin');
const postcss = require('postcss');
const postcssJs = require('postcss-js');

const clampGenerator = require('./src/css-utils/clamp-generator.js');
const tokensToTailwind = require('./src/css-utils/tokens-to-tailwind.js');

// Raw design tokens
const colorTokens = require('./src/design-tokens/colors.json');
const fontTokens = require('./src/design-tokens/fonts.json');
const spacingTokens = require('./src/design-tokens/spacing.json');
const textSizeTokens = require('./src/design-tokens/text-sizes.json');
const textLeadingTokens = require('./src/design-tokens/text-leading.json');
const textWeightTokens = require('./src/design-tokens/text-weights.json');
const viewportTokens = require('./src/design-tokens/viewports.json');

// Process design tokens
const colors = tokensToTailwind(colorTokens.items);
const fontFamily = tokensToTailwind(fontTokens.items);
const fontWeight = tokensToTailwind(textWeightTokens.items);
const fontSize = tokensToTailwind(clampGenerator(textSizeTokens.items));
const fontLeading = tokensToTailwind(textLeadingTokens.items);
const spacing = tokensToTailwind(clampGenerator(spacingTokens.items));

module.exports = {
  content: ['./src/**/*.{html,js,jsx,mdx,njk,twig,vue}'],
  // Add color classes to safe list so they are always generated
  safelist: [],
  presets: [],
  theme: {
    screens: {
      sm: `${viewportTokens.min}px`,
      md: `${viewportTokens.mid}px`,
      lg: `${viewportTokens.max}px`
    },
    colors,
    spacing,
    fontSize,
    fontLeading,
    fontFamily,
    fontWeight,
    backgroundColor: ({theme}) => theme('colors'),
    textColor: ({theme}) => theme('colors'),
    margin: ({theme}) => ({
      auto: 'auto',
      ...theme('spacing')
    }),
    padding: ({theme}) => theme('spacing')
  },
  variantOrder: [
    'first',
    'last',
    'odd',
    'even',
    'visited',
    'checked',
    'empty',
    'read-only',
    'group-hover',
    'group-focus',
    'focus-within',
    'hover',
    'focus',
    'focus-visible',
    'active',
    'disabled'
  ],

  // Disables Tailwind's reset and usage of rgb/opacity
  corePlugins: {
    preflight: false,
    textOpacity: false,
    backgroundOpacity: false,
    borderOpacity: false
  },

  // Prevents Tailwind's core components
  blocklist: ['container'],

  // Prevents Tailwind from generating that wall of empty custom properties 
  experimental: {
    optimizeUniversalDefaults: true
  },

  plugins: [
    // Generates custom property values from tailwind config
    plugin(function ({addComponents, config}) {
      let result = '';

      const currentConfig = config();

      const groups = [
        {key: 'colors', prefix: 'color'},
        {key: 'spacing', prefix: 'space'},
        {key: 'fontSize', prefix: 'size'},
        {key: 'fontLeading', prefix: 'leading'},
        {key: 'fontFamily', prefix: 'font'},
        {key: 'fontWeight', prefix: 'font'}
      ];

      groups.forEach(({key, prefix}) => {
        const group = currentConfig.theme[key];

        if (!group) {
          return;
        }

        Object.keys(group).forEach(key => {
          result += `--${prefix}-${key}: ${group[key]};`;
        });
      });

      addComponents({
        ':root': postcssJs.objectify(postcss.parse(result))
      });
    }),

    // Generates custom utility classes
    plugin(function ({addUtilities, config}) {
      const currentConfig = config();
      const customUtilities = [
        {key: 'spacing', prefix: 'flow-space', property: '--flow-space'},
        {key: 'spacing', prefix: 'region-space', property: '--region-space'},
        {key: 'spacing', prefix: 'gutter', property: '--gutter'}
      ];

      customUtilities.forEach(({key, prefix, property}) => {
        const group = currentConfig.theme[key];

        if (!group) {
          return;
        }

        Object.keys(group).forEach(key => {
          addUtilities({
            [`.${prefix}-${key}`]: postcssJs.objectify(
              postcss.parse(`${property}: ${group[key]}`)
            )
          });
        });
      });
    })
  ]
};

As is tradition, like the CSS reset post, I’ll break it down, piece-by-piece:

Code language
js
const plugin = require('tailwindcss/plugin');
const postcss = require('postcss');
const postcssJs = require('postcss-js');

const clampGenerator = require('./src/css-utils/clamp-generator.js');
const tokensToTailwind = require('./src/css-utils/tokens-to-tailwind.js');

This is all the dependencies. The first 3 I’ll cover as we get to them, but the clampGenerator and tokensToTailwind live in the repository. They do exactly what they say on the tin really.

The clampGenerator generates Utopia-like CSS clamp values for fluid type and space (thank you to Trys for the Utopia logic) and the tokensToTailwind function converts whatever format the project’s design tokens are in, into Tailwind friendly configuration objects.

Code language
js
content: ['./src/**/*.{html,js,jsx,mdx,njk,twig,vue}'],
// Add color classes to safe list so they are always generated
safelist: [],
presets: [],
theme: {
screens: {
  sm: `${viewportTokens.min}px`,
  md: `${viewportTokens.mid}px`,
  lg: `${viewportTokens.max}px`
},
colors,
spacing,
fontSize,
fontLeading,
fontFamily,
fontWeight,
backgroundColor: ({theme}) => theme('colors'),
textColor: ({theme}) => theme('colors'),
margin: ({theme}) => ({
  auto: 'auto',
  ...theme('spacing')
}),
padding: ({theme}) => theme('spacing')

I don’t let Tailwind create all the utilities that it can do at the core. Mainly because there’s way too many, so the above is a condensed set. Each of the single keyword properties like fontSize are defined at the top of the file by running through the tokensToTailwind function, so they get referenced straight in the config.

The Tailwind media query function — screen — is very rarely used, but we at least want that rigged up to design tokens.

Code language
js
variantOrder: [
  'first',
  'last',
  'odd',
  'even',
  'visited',
  'checked',
  'empty',
  'read-only',
  'group-hover',
  'group-focus',
  'focus-within',
  'hover',
  'focus',
  'focus-visible',
  'active',
  'disabled'
],

This was added on day 1 of this config and I’m not sure it’s changed much. The reason it’s surfaced in config though is to make it easier to adjust should we ever need to adjust it. Read about that stuff here.

Code language
js
// Disables Tailwind's reset and usage of rgb/opacity
corePlugins: {
  preflight: false,
  textOpacity: false,
  backgroundOpacity: false,
  borderOpacity: false
},

Right, this is where we get into the weeds a bit. The first part is disabling Tailwind’s built-in reset. It’s a bit aggressive and also isn’t needed because there’s one in the project already.

The last 3 properties contribute to getting rid of that massive block of empty Custom Properties that Tailwind generates. It’s an unbearable amount of useless guff for my CSS, but I think it’s useful if you go all-out atomic with Tailwind.

Code language
js
// Prevents Tailwind's core components
blocklist: ['container'],

There’s only one blocked core component in there: container. Again, this is not needed because there’s a wrapper in css/compositions. I prefer to have control of that too, rather than be prescribed a wrapper/container.

Code language
js
experimental: {
  optimizeUniversalDefaults: true
},

This config value also contributes to getting rid of the massive wall of Custom Properties. This tortured my soul for a long time and it’s only recently I decided enough was enough and dedicated actual time to working out how to get rid of it. It wasn’t easy to work out how to get rid of Tailwind’s Custom Properties at all — which is a surprise because the documentation is good — but I’m glad I got there in the end.

Code language
js
plugin(function ({addComponents, config}) {
  let result = '';

  const currentConfig = config();

  const groups = [
    {key: 'colors', prefix: 'color'},
    {key: 'spacing', prefix: 'space'},
    {key: 'fontSize', prefix: 'size'},
    {key: 'fontLeading', prefix: 'leading'},
    {key: 'fontFamily', prefix: 'font'},
    {key: 'fontWeight', prefix: 'font'}
  ];

  groups.forEach(({key, prefix}) => {
    const group = currentConfig.theme[key];

    if (!group) {
      return;
    }

    Object.keys(group).forEach(key => {
      result += `--${prefix}-${key}: ${group[key]};`;
    });
  });

  addComponents({
    ':root': postcssJs.objectify(postcss.parse(result))
  });
}),

Right, this is the sort of thing that sold me on Tailwind. They have a whole custom plugin system that you can tap into. One thing I want to always do is generate a nice block of Custom Properties, based on design tokens. That’s exactly what the above code does.

It goes through each defined group (groups) and then grabs the values > generates Custom Property values > sticks them to the result.

Finally, using postcssJs and postcss, an object that Tailwind can understand is created. The addComponents function sticks that custom properties block on the @components layer. It’s a bit of a hack, but it does the job.

Code language
js
plugin(function ({addUtilities, config}) {
  const currentConfig = config();
  const customUtilities = [
    {key: 'spacing', prefix: 'flow-space', property: '--flow-space'},
    {key: 'spacing', prefix: 'region-space', property: '--region-space'},
    {key: 'spacing', prefix: 'gutter', property: '--gutter'}
  ];

  customUtilities.forEach(({key, prefix, property}) => {
    const group = currentConfig.theme[key];

    if (!group) {
      return;
    }

    Object.keys(group).forEach(key => {
      addUtilities({
        [`.${prefix}-${key}`]: postcssJs.objectify(
          postcss.parse(`${property}: ${group[key]}`)
        )
      });
    });
  });
})

Lastly, this function creates custom utilities that I can use in markup, such as gutter-m or flow-space-s. It’s all rigged up to the design tokens Custom Property block. It’s damn useful, especially when tweaking layout compositions in context.

That’s it for the config. Pretty darn neat.

PostCSS and how it all comes together permalink

Everything is stitched together using PostCSS in src/css/global.css.

Code language
css
@import 'tailwindcss/base';

@import 'global/reset.css';
@import 'global/fonts.css';

@import 'tailwindcss/components';

@import 'global/variables.css';
@import 'global/global-styles.css';

@import-glob 'blocks/*.css';
@import-glob 'compositions/*.css';
@import-glob 'utilities/*.css';

@import 'tailwindcss/utilities';

I try to maintain a decent source order for specificity purposes as you can see. The @import 'tailwindcss/components' is where that block of Custom Properties generated in the Tailwind config gets put. Because everything else that layer does is disabled in config, it’s nice and clean.

The CUBE parts are all imported using the extremely useful import-glob PostCSS plugin. This allows new files to be added to directories and imported straight away. It’s very Sass-like, but I like that. It’s probably one of the only things I would miss from Sass to be honest.

Useful core Blocks, Compositions and Utilities permalink

In src/css the CUBE structure is directory-led. In each there are boilerplate CSS files that we almost always use. This is especially the case with src/css/utilties.

There’s a bunch of Every Layout layouts in src/css/compositions. Sometimes they all get used and sometimes they don’t. What I’d love to do in the future is pull in these on demand because that directory could be hugely expanded with more layouts if that was the case.

Either way, having this boilerplate stuff is very useful.

Wrapping up permalink

I get asked a lot about how I structure projects, so I hope this has been a useful post for those folks. Like I said at the start, every project is different, so the CSS setup rarely looks exactly like this. The boilerplate does speed things up considerably though.

You can grab it yourself on GitHub too. I just ask that you don’t try to contribute to it (unless you find a problem), because it’s what works for me and the team. Go ahead and fork it if you want to make changes to how it works and is structured.