DEV Community

Michael Warren
Michael Warren

Posted on • Updated on

Lit web components: Tailwindcss styles at build time

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() { ... }
}
Enter fullscreen mode Exit fullscreen mode

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:

Lets get down to business - mulan gif


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
Enter fullscreen mode Exit fullscreen mode

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'); 
Enter fullscreen mode Exit fullscreen mode

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 with require 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?');
}
Enter fullscreen mode Exit fullscreen mode

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);
   });

});
Enter fullscreen mode Exit fullscreen mode

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"
   }
}
Enter fullscreen mode Exit fullscreen mode

Now, every time you

yarn build-style
#or
npm run build-style
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() { ... }
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

then alert.css.ts will now have:

// alert.css.ts

import { css } from 'lit';
export default css`p { color: blue; }`;
Enter fullscreen mode Exit fullscreen mode

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() { ... }
}
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (2)

Collapse
onmax profile image
Max

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:

  • In the content of the script build-styles, you use require('tailwindcss')(tailwindConfig). with a dot at the end. I had to remove it so it could work
  • I added cssnano as a plugin, so the dev server doesn't clompain about weird characters introduced by Tailwind
Collapse
michaelwarren1106 profile image
Michael Warren Author • Edited on

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!