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:
Using tailwind at run-time with web components
James Garbutt ・ Dec 27 '20
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>`;
}
}
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` */
`;
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 { ... }
]
}
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 */
`;
// ...
}
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
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"
}
}
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'
]
};
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')()
]
};
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
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
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'
]
}
};
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>`;
}
}
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)
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:
orfocus:
), 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
and the following configuration in postcss.config.js :
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.
I try this to have you found any solution?
@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.
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...
Great post, Thanks..
Can we use this setup without bundle, for design system components?
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
Awesome post, after reading this I created github.com/scherler/sirocco-wc and the base code of this article is generating the css.