Today I saw this article from James Garbutt penned around a year ago about how to use Tailwind CSS for styles authoring in a lit-element (now Lit) web component and I thought I'd expand on it a little more with a few ideas drawing from experience with an implementation approach I've used in two design system implementations.
Environment
This approach I'm going to outline probably won't be worth it for all use cases, so I'll focus on a solution for component libraries and design system monorepos that have many components that all share the same source code structure and therefore need the same core styles to use at dev/build time.
Therefore, picture a dev environment with the following:
- Monorepo
- Typescript
- Lit web components
- Distributed as es6 components
- No bundlers
Your particular environment might slightly differ, but the main approach here will still work just fine. You just might need to adjust some of the code snippets here so that your desired source files or output files are generated the way you want/need them to be.
A note about bundlers
These days, the prevailing best practice for component authors, particularly those of us that make design systems and libraries of components, is NOT to bundle the distribution version. Bundling dependencies into component distros short-circuits tree-shaking and code-splitting that bundlers used in web app build systems have been well optimized to do. So we don't have any bundlers in our code because we're not distributing bundled components, so adding a bundler for the sake of a build step when we don't actually need it is probably going to be massive overhead, especially if you can write a pretty straight-forward node script. (HINT: we're going to write a node script)
Requirements of our build environment
I also want to outline what this solution aims to provide in terms of satisfying a few requirements contributing to the overall developer experience of the whole project.
Style authoring takes place in separate files with style extensions
.css
& .scss
are the ones I'll focus on, but of course others will work. Being able to work in separate style files keeps our component.ts
files clean and separates concerns better than the documented default for Lit.
The documented default for Lit (playground example) shows a static styles
variable that contains a css tagged template string with the actual styles for that component;
export class Alert extends LitElement {
static styles = css`p { color: blue }`;
render() { ... }
}
This method would only be tenable for the simplest of tiny components. As soon as you have more than 3 selectors in your style string, your component is going to start becoming hard to maintain. Breaking out styles into separate files that live alongside your component class file is a much more common and familiar approach.
Plus, the default implementation approach for Lit is css ONLY. Lit components cannot accept — nor should they — syntaxes like scss that make our lives easier. So if we want to use scss, we're going to have to do it ourselves, but find a way to feed Lit the css it needs the way it needs it.
All components use the same shared tailwind config
Besides the consistency aspect of all components sharing the same config — most likely a config generated from your design system tokens — dealing with more than one Tailwind config is overhead we don't need.
Bonus points if your monorepo has a dedicated style package whose main job is to distribute a pre-built Tailwind config as an option for consumption of your design system tokens via the Tailwind styles. Mine does, and it's super helpful to simply use the latest version of the style package's provided config for each component's style build scripts.
Styles get imported into Lit components as Typescript imports
Since we want to pull out our style declarations from the static styles variable directly in class files, we are going to need a way to get them back in again. If you're writing ES6 components, then ES6 imports would do nicely. If you're writing JS for older browser support, or for different module systems, you can always adjust your output to write a different module syntax. For me, ES6/TS imports are way simpler, and my source code is in Typescript anyway, so it makes sense to generate Typescript files.
Styles are purged using our class and type files
The one drawback to Tailwind is the file size of the kitchen-sink pre-generated css file it can produce. There are ways to get it smaller, but any way you slice it, the only styles that belong in our components are styles that are actually being used in those components. Tailwind now provides the Just-In-Time mode and will only generate styles that are actually being used. For us design system devs, and this approach, JIT mode is going to be a big help. But we also need to programmatically change the paths that we set in Tailwind's config because we have multiple component files to purge against, and we wouldn't want to purge the styles for x-alert
while we're building the styles for x-button
.
Now that we've got our plans for what we're going to do:
1. Make a script file in your project root
This is the file we're going to reference when we run this script as a part of our build.
# your folder and file names can of course vary
mkdir ./tasks
touch ./tasks/build-styles.js
Then go ahead and add some requires we know we'll need later:
const path = require('path');
const fs = require('fs');
const glob = require('glob');
const postcss = require('postcss');
const autoprefixer = require('autoprefixer');
// use sass, not node-sass to avoid a ruby install
const sass = require('sass');
Feel free to switch these packages out with ones you're familiar with that serve similar purposes.
CommonJS syntax?
I'm going to write this in CommonJS (.js) syntax withrequire
instead of ES6 syntax (.mjs) syntax just because its a pure node script that doesn't need to be portable or run in a browser. Feel free to write yours as an.mjs
with ES6 style code if that's what your project needs
2. Accept a package identifier as a command argument
If you're going to run this script in a bunch of components, having a little help for your glob to know what package/folder you're running in will help a lot, so just set up a simple args parser — I like yargs so that you can pull a simple package name from the command we'll run as an npm
script at the end
// build-style.js
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers')
const options = yargs(hideBin(process.argv)).argv;
// given an npm script run like:
// $ node ./tasks/build-style.js --package alert
console.log(options.package) //alert
Note: hideBin
is a yargs
shorthand for process.argv.slice(2)
that takes into account slight variations in environments.
3. Glob up all the style files for the package
If you are delivering a few related web components in the same package, there might be a few style files that need converting in one package, so we want to get a glob of them to loop through.
Assuming a directory structure of something like:
packages
|-- alert
|-- src
|-- components
|-- alert
|-- index.ts
|-- alert.ts
|-- alert.css
|-- index.ts
then your glob would be something like:
const styleFiles = glob.sync(path.join(__dirname, '../', `packages/${options.package}/src/components/**/*.{scss, css}`));
// maybe you want to throw an error if no style files were found for that package
if(!styleFiles.length) {
throw new Error('why you no provide files?');
}
This glob will pick up BOTH .css
and .scss
files, but we're going to process the .scss
files a little more when present.
Aside: Why both scss AND css? Why not just pick one and be consistent?
I have found that for components that have styles that are directly based on tokens, it can be useful to use scss looping mechanisms to loop through token names and values if you have a component attribute that is the token name and need the value in your scss. As we'll see later on, adding scss
support is just one more line in this script, but offers a ton more flexibility for when you need that little bit of scss logic that css/postcss cant provide.
4. Loop through all your file paths
That glob we made provides us with an array of file paths that we can use to do processing on
styleFiles.forEach((filePath) => {
// parse the filePath for use later
// https://nodejs.org/api/path.html#pathparsepath
const parseFilePath = path.parse(filePath);
// figure out ahead of time what the output path should be
// based on the original file path
// ALL output files will end with `.css.ts
// since all outputs will be css as exported TS strings
const styleTSFilePath = path.format(Object.assign({}, parsedFilePath, { base: `${parsedFilePath.name}.css.ts`}));
// set a variable to hold the final style output
let styleOutput;
// grab the file type of the current file
const fileType = parseFilePath.ext === '.scss' ? 'scss' : 'css';
// read the file contents
// passing the encoding returns the file contents as a string
// otherwise a Buffer would be returned
// https://nodejs.org/api/fs.html#fsreadfilesyncpath-options
const originalFileContents = fs.readFileSync(filePath, { encoding: 'utf-8'});
// one-liner to process scss if the fileType is 'scss'
// if not using scss just do:
// styleOutput = originalFileContents;
styleOutput = fileType === 'css' ? originalFileContents : sass.renderSync({ file: filePath}).css.toString();
// wrap original file with tailwind at-rules
// the css contents will become a "tailwind css" starter file
//
// https://tailwindcss.com/docs/installation#include-tailwind-in-your-css
styleOutput = `@tailwind base;
@tailwind components;
@tailwind utilities;
${styleOutput}`;
// prep for processing with tailwind
// grab your master config
const tailwindConfig = require('../path/to/your/config');
tailwindConfig.purge = {
enabled: true,
content: [
/* the files you want tailwind to purge from nearby to the original css/scss file */
`${parsedFilePath.dir}/**/*.{ts,css}`
],
options: { /* yourOptions */}
};
// now run postcss using tailwind and autoprefixer
// and any other plugins you find necessary
postcss([
autoprefixer,
require('tailwindcss')(tailwindConfig),
// ...other plugins
])
// the 'from' property in the options makes sure that any
// css imports are properly resolved as if processing from
// the original file path
.process(styleOutput, { from: filePath})
.then((result) => {
// write your "css module" syntax
// here its TS
const cssToTSContents = `
import { css } from 'lit';
export default css\`${result.css}\`;
`;
// write the final file back to its location next to the
// original .css/.scss file
fs.writeFileSync(styleTSFilePath, cssToTSContents);
});
});
So there's the nuts and bolts of our .css/.scss => .css.ts
file processing script. Now all we have to do is run it.
5. Create an npm script in your packages to run the task
In each of your component packages, create a new npm script that will just run the script you've just written but provide the correct package name. If you're using lerna and/or yarn workspaces (npm@7 has workspaces too now!) then the package name you want is probably the folder name directly under your /packages/
folder
// /packages/alert/package.json
{
scripts: {
"build-style": "node ./path/to/build-style.js alert"
}
}
Now, every time you
yarn build-style
#or
npm run build-style
you'll have a freshly generated batch of .css.ts
files and your component folder will have:
packages
|-- alert
|-- src
|-- components
|-- alert
|-- index.ts
|-- alert.ts
|-- alert.css.ts
|-- alert.css
|-- index.ts
6. Import the .css.ts files in your component class file
So remember our component before with the static styles
export class Alert extends LitElement {
static styles = css`p { color: blue }`;
render() { ... }
}
Well now you can import your styles, rename them to something that makes sense, because we used the default export alias in our .css.ts file and then set your static styles
property using the imported styles
So if alert.css
has something like:
/* alert.css */
p { color: blue; }
then alert.css.ts
will now have:
// alert.css.ts
import { css } from 'lit';
export default css`p { color: blue; }`;
which your Lit component will accept when assigning your static styles
property.
// alert.ts
import AlertStyles from './alert.css';
export class Alert extends LitElement {
static styles = [ AlertStyles ];
render() { ... }
}
And thats all there is to it!
Usage
Now that you have all the plumbing hooked up, you can use Tailwind classes in a few ways. Provided that you've set up your purge globs in the Tailwind config correctly, you can add Tailwind classes directly to HTML tags in your render function
// alert.ts
render() {
return html`<div class="block bg-red-500"></div>`;
}
or you can use the @apply
directive to assign Tailwind classes to another — perhaps more semantic — class if you want to
/* alert.css */
.button {
@apply bg-red-500 block rounded;
}
Optimizations and extras
The script I've shown here is very basic for tutorial purposes, so I won't outline all of the possible optimizations you could make to the code itself (I'm sure there are a lot). But here are some extras that you can do in your own project setups
Run the build-style
script as a part of file watcher script like nodemon
or tsc-watch
.
If your main TS build process is just tsc
I'd consider using tsc-watch and set build-style
as the script to run with the --onCompilationStarted
flag so that your style rebuilds every time your TS file rebuilds.
Caching
If you set this build script up to run on every file change, you may end up running a build for style files that haven't changed. If you want to save those cycles and milliseconds then implementing a caching mechanism would be a good idea. With caching enabled, you'd first want to hash your file contents and compare those against the hashes in the cache and then only re-compile files whose current hashes are different than the cached ones, indicating that the file has changed. After you're done, hash the changed files again and save them in the cache for the next run.
Make helper functions for wrapping content
I showed them inline for readability and better understanding, but the wrapping of the css content with tailwind utils, and the wrapping of the final css output into a TS module export would be better as helper functions for a cleaner file
Async execution
I tend to write build scripts as synchronous code because its generally fast enough not to have to worry about doing things in parallel, but asynchronous execution is definitely an optimization that makes a lot more sense the more components you're building in a single package.
I also used the .then()
notation for the postcss
execution because forEach()
and async
functions don't behave as we would think. If you want to use async/await
syntax, just change the forEach()
loop to a for...in
loop and it'll work just fine with async/await
Other style pre-processor syntaxes
I am not as familiar with less
and stylus
and other languages that produce css output. But if your project requires those instead of scss
and there is a node package that you can use programmatically to generate your own css output, then the scss processing sections can be easily switched out with those other pre-processors
Cheers and thanks for reading! Let me know in the comments if there's anything I could improve on!
Top comments (2)
Wow, great article!!! I have been struggling with this quite a lot of times, and you gave me the solutions! Thank you.
Just I would like to mention few things:
good catch! i'll fix that.
cssnano and autoprefixer are both good postcss plugins. If you want to write plain css but want to write it with nested syntax, postcss-nested is another good one, etc. I didn't include much in the way of talking about specific plugins because postcss's own docs talks about plugins and their usage in more detail.
Glad you liked the article!