DEV Community

James Garbutt
James Garbutt

Posted on • Updated on

Using tailwind at build-time with lit-element

Outdated solution!

There's a much easier way to do this now:

Read on if the new solution doesn't work, or you just prefer this one...


A few days ago, I wrote about using tailwind with web components at run-time:

At the time, I was actually trying to figure out how to do this at build-time but was struggling to find an existing solution. Good news: I found one!

Keep in mind, this example is specific to lit-element.

My Setup

As in my previous article, the same setup was used:

  • A single web component (lit-element in this case)
  • esbuild
  • TypeScript

Using a lit-element component:

class MyElement extends LitElement {
  static styles = css`
    /*
     * Somehow we want tailwind's CSS to ultimately
     * exist here
     */
  `;

  render() {
    // We want these tailwind CSS classes to exist
    return html`<div class="text-xl text-black">
      I am a test.
    </div>`;
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem

As discussed in my last post, tailwind doesn't seem to support shadow DOM or web components in general out of the box.

I previously solved this by using twind, a great little library which behaves as a 'tailwind runtime' and produces the correct stylesheets at run-time.

However, not everyone wants a run-time solution, some have static enough CSS they would rather build it once and forget.

So, as you saw in the example above, our aim is to inject tailwind's CSS into the component's stylesheet.

The investigation

Getting to the solution below took quite some time over the past day or so, involved finding a few bugs and discovering new tools.

First of all, I did some googling and found:

postcss-js

This is a postcss plugin for dealing with "CSS in JS". Sounds promising!

But no, this is a plugin for converting between CSS objects (actual JS representations of CSS) and CSS strings. We don't want this, we want to transform CSS strings in-place.

babel plugin

The babel plugin extracted CSS from template literals, passed them through postcss and replaced the original. Exactly what we need!

But... it is a babel plugin and we don't want to use babel. So this one was a no, too.

rollup plugin

A rollup plugin or two exist which do the same as "postcss-js": they transform to and from CSS objects.

Again, not what we want.

Custom rollup plugin

I then made my own rollup plugin, which extracted template literals the same as the babel plugin did and processed them with postcss.

This did work, but seemed like overkill and tied us into rollup. I didn't really want to have a solution which depends on another build tool being used.

Fun to make my own rollup plugin, though, so good experience.

postcss-jsx (aka postcss-css-in-js)

Andrey (postcss maintainer) at this point recommended I use "postcss-jsx". I had seen this while googling previously but couldn't quite figure out from the docs how to get it working with my sources.

It sounded like the right way to go, though, so I tried again!

First try, I managed to get it processing the CSS from my element! Success. It resulted in a huge stylesheet (all of tailwind) but looked like it worked.

Bug 1

Not so fast, though. I tried this in a browser and was met with a good ol' syntax error. The first bug: postcss-jsx doesn't escape backticks in the output CSS.

Tailwind's CSS contains comments with backticks, so we end up producing syntactically incorrect code like this:

const style = css`
  /** Tailwind broke `my code with these backticks` */
`;
Enter fullscreen mode Exit fullscreen mode

At this point, I noticed postcss-jsx is unmaintained and the folks at stylelint have forked it. So I filed the first bug in my investigation:

https://github.com/stylelint/postcss-css-in-js/issues/89

Bug 2

I fixed postcss-css-in-js locally to escape backticks, so I now got some output.

But this won't work for anyone else until the package is fixed, of course. So I figured we can get around it: use cssnano to strip comments entirely - making those backtick comments conveniently disappear.

Installed cssnano, added it to my postcss config, and used the "lite" preset as I only wanted empty rules and comments removing.

Turns out, cssnano-preset-lite doesn't work with postcss-cli. Another bug:

https://github.com/cssnano/cssnano/issues/976

Bug 3

I almost forgot, postcss-css-in-js also had a 3rd bug: it produces an AST like this:

Document {
  nodes: [
    Root { ... },
    Root { ... }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Turns out, postcss has trouble stringifying nested roots. Bug raised and even tried a PR this time:

https://github.com/postcss/postcss/issues/1494

UPDATE: fixed in PostCSS 8.2.2!

Solution

After this excellent amount of fun finding bugs and researching solutions, I finally got to one which works.

Source

To include tailwind's CSS, we do exactly as in their docs:

export class MyElement extends LitElement {
  public static styles = css`
    @tailwind base;
    @tailwind utilities;
    /* whatever other tailwind imports you want */
  `;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

These @tailwind directives will later be replaced with tailwind's actual CSS by postcss.

Dependencies

As mentioned above, we needed the following:

$ npm i -D postcss @stylelint/postcss-css-in-js tailwindcss postcss-syntax postcss-discard-comments postcss-discard-empty
Enter fullscreen mode Exit fullscreen mode

Build script (package.json)

{
  "scripts": {
    "build:js": "tsc && esbuild --bundle --format=esm --outfile=bundle.js src/index.ts",
    "build:css": "postcss -r bundle.js",
    "build": "npm run build:js && npm run build:css"
  }
}
Enter fullscreen mode Exit fullscreen mode

Running npm run build will:

  • Run typescript (with noEmit: true) just for type-checking
  • Run esbuild to create a JS bundle
  • Run postcss and replace the contents of the JS bundle in place

tailwind.config.js

module.exports = {
  purge: [
   './bundle.js'
  ]
};
Enter fullscreen mode Exit fullscreen mode

Here, bundle.js is what we produced with esbuild earlier on. We want to purge unused styles from our bundle.

postcss.config.js

module.exports = {
  syntax: require('@stylelint/postcss-css-in-js'),
  plugins: [
    require('tailwindcss')(),
    require('postcss-discard-comments')(),
    require('postcss-discard-empty')()
  ]
};
Enter fullscreen mode Exit fullscreen mode

Here:

  • syntax tells postcss how to read our JS file
  • tailwindcss injects tailwind's CSS and then purges unused styles
  • postcss-discard-comments discards comments (which prevents bug 1 above)
  • postcss-discard-empty discards the empty rules tailwind left behind after purging

Note: cssnano can be used instead of the last 2 plugins but we didn't in this case because of bug 2 above

Build it

Our build script from before should now work:

$ npm run build
Enter fullscreen mode Exit fullscreen mode

If we want to strip all those unused styles and make use of the purge option in our config, we need to specify NODE_ENV:

$ NODE_ENV=production npm run build
Enter fullscreen mode Exit fullscreen mode

Tailwind will pick this up and purge unused styles.

Enabling purging in both dev and prod

If you always want purging to happen, simply change your tailwind config to look like this:

module.exports = {
  purge: {
    enabled: true,
    content: [
      './bundle.js'
    ]
  }
};
Enter fullscreen mode Exit fullscreen mode

This is described more here.

Optimise it

We can do a bit better than this. Right now, we are producing a tailwind stylesheet for each component.

If we have multiple components, each one's stylesheet will have a copy of the tailwind CSS the whole app used (as we're operating against the bundle, not individual files).

So we'd probably be better off having a single tailwind template many components share:

// styles.ts
export const styles = css`
  @tailwind base;
  @tailwind utilities;
`;

// my-element.ts
import {styles} from './styles';
export class MyElement extends LitElement {
  static styles = [styles];
  public render() {
    return html`<p class="p-4">One</p>`;
  }
}

// another-element
import {styles} from './styles';
export class AnotherElement extends LitElement {
  static styles = [styles];
  public render() {
    return html`<p class="p-6">Two</p>`;
  }
}
Enter fullscreen mode Exit fullscreen mode

This means we will produce one monolithic tailwind stylesheet all of our components re-use.

In the example above, .p-6 and .p-4 (the classes used in the render methods) will both exist in the stylesheet with all other unused styles stripped.

Whether this is an optimisation or not does depend on your use case. Just remember the "purging" happens on the bundle, not the individual files.

Useful links (packages we used)

Wrap-up

As I said in my previous post, run-time vs build-time is a project-based preference I think. Some of you will be better off using the run-time twind solution, others will be better off using this build-time solution.

If your styles are very static (i.e. you don't really dynamically use any at run-time) or you already have a similar postcss build process, you should probably process Tailwind at the same time.

The cssnano inclusion is a hack in my case, to get around bug 2 mentioned above. Though you probably want to use it anyway to save some bytes in production.

Have fun!

Top comments (8)

Collapse
 
jivaneperich profile image
jivane-perich

Thanks a lot for the guide.
This was very helpful for setting up our litelement project. But, when trying to use some variants with tailwind (like hover: or focus:), we found that we couldn't use the class defined by tailwind due to a lit element issue : github.com/Polymer/lit-element/iss... .

In order to complete the guide, we fixed the problem by adding the following package :
github.com/gridonic/postcss-replace

npm install postcss-replace
Enter fullscreen mode Exit fullscreen mode

and the following configuration in postcss.config.js :

module.exports = {
  syntax: require('@stylelint/postcss-css-in-js'),
  plugins: [
    require('tailwindcss')(),
    require('postcss-discard-comments')(),
    require('postcss-discard-empty')(),
    require('postcss-replace')({pattern: ':', data: {replaceAll: '\\:'}}),
  ]
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
davierobertson profile image
Davie

Thanks for a very interesting article.

It would be interesting to see how this can be integrated with open-wc.org and its default rollup scaffolding.

Collapse
 
ivan_jrmc profile image
Ivan Jeremic

I try this to have you found any solution?

Collapse
 
lukasergio profile image
luka-TU

@43081j Thank you for a very interesting article!
Is it possible to make it work both for the css defined as css-in-js and the regular css at the same time? I am not not sure how to set up postcss config for it.

Collapse
 
runninggrass profile image
running-grass

thx you, i try use another way resolved it.

It is implemented by using the unsafe and adoptstyles of lit, based on the constructive stylesheet.

Look here
github.com/running-grass/starter-l...

Collapse
 
anandrikka profile image
Anand Reddy Rikka • Edited

Great post, Thanks..

Can we use this setup without bundle, for design system components?

Collapse
 
michaelwarren1106 profile image
Michael Warren

i have implemented something like this, and i'm thinking about putting together an article on it soon. my design system components arent bundled, and it works just fine

Collapse
 
scherler profile image
Thorsten Scherler

Awesome post, after reading this I created github.com/scherler/sirocco-wc and the base code of this article is generating the css.