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 downpermalink
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 togetherpermalink
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 Utilitiespermalink
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 uppermalink
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.