Tailwind CSS v4 is wired into every Leed site. There is nothing to install, no tailwind.config.js anywhere in the product, and no <link> tag for you to write. Configuration in v4 is CSS, and your entry point is one file in your repository.
Detection: one file turns CSS on
The build looks for exactly one path:
<your-repo>/tailwind/site.config.cssIf it exists, leed.env.css.usingTailwind is set and the Tailwind plugin is registered. If it does not, the build logs
Tailwind is not enabled, skipping compilationat debug level — so on a normal build you will not see it at all — and the site ships with no stylesheet. That same flag is what makes leed/head emit the stylesheet tags, so a missing entry file produces pages with no CSS and no error.
The one line your entry file must contain
Everything Leed adds to your CSS is inserted immediately after the first line matching @import "tailwindcss". The scaffold a new site starts from puts it on line one:
@import "tailwindcss" source("../src");
/**
* Layouts
*/
@import "./layouts/blog.css" layer(components);
@import "./site/base.css" layer(base);A file with no such line still compiles — Tailwind will process it — but it compiles with zero Leed CSS, no scanning of Leed’s own shared templates, and no exclusion of your archive and trash directories. Nothing warns you.
What gets injected
Four lines, inserted verbatim in this order:
@source not "../src/_archive";
@source not "../src/_trash";
@source "<leed-app-root>/static/site/**/*.hbs";
@import "<relative-path-to>/leed.css";| Line | Kind | What it does | What breaks without it |
|---|---|---|---|
@source not "../src/_archive" | scan exclusion | Keeps archived pages out of class scanning | Archived content resurrects classes into the build |
@source not "../src/_trash" | scan exclusion | Same, for trashed pages | Deleted content keeps its CSS alive |
@source "<appRoot>/static/site/**/*.hbs" | scan inclusion | Scans Leed’s own shared partials, so the classes they use survive tree-shaking | Leed’s header, sidebar, pager, search and alert markup renders unstyled |
@import "<…>/leed.css" | stylesheet | Pulls in Leed’s entire stylesheet — layer order, defaults, components, docs CSS, themes, fonts, Font Awesome | You get Tailwind and nothing else |
The @import position matters more than it looks. Leed’s stylesheet is imported at the top of your file, before your own imports — which is precisely what lets a layer(components) file of yours win over a Leed component style on ordinary cascade rules, with no !important. That mechanism is the subject of Cascade Layers and Overriding Leed.
source("../src")
The scaffold’s own directive. It points Tailwind’s class scanner at your src/ tree — your templates, your partials and your content. The three injected @source lines add to it rather than replacing it.
What “scanned source” includes, and why it matters more than it looks
Tailwind v4 emits a utility only when it finds the class name somewhere in scanned source, as text. The scan root is your content repository’s src/, and JSON data files under it are scanned like any other file.
That is not a curiosity. Measured on a shipped Pithy build, the emitted stylesheet contains fa-swift, fa-android, fa-unity, fa-brackets-curly, fa-i-cursor and fa-laptop-code — and those class names appear nowhere in the site except src/docs/docs.11tydata.json, where they are tab-group icons.
Two consequences follow, and they point in opposite directions:
- A Font Awesome icon named only in a tab-group configuration does get its CSS, because the configuration file is scanned.
- A tab group configured only in the CMS company record, and therefore never published into a file under
src/, renders an icon class with no rule behind it. Same mechanism, opposite outcome.
The same reasoning covers theme names. documentationConfiguration.colorTheme stores the full class string, and it lands in src/docs/docs.11tydata.json on publish — which is what makes a custom @utility color-theme-<brand> emit at all. If your documentation ever renders in Leed’s default blue after a page-type publish, check that the class literal still survives that round trip.
What a tailwind/ directory looks like
The scaffold above is the whole starting point. As a site grows, the shape that scales is one entry file plus directories by role — and every import carrying an explicit layer(...) annotation (or a comment explaining why it deliberately has none).
The full tailwind/ tree from leed.ai's own site
tailwind/
├── site.config.css
├── docs/
│ ├── chrome.css
│ ├── code-theme-leed.css
│ ├── color-theme-leed.css
│ ├── components.css
│ └── mermaid.css
├── layouts/
│ ├── apply.css
│ ├── blog.css
│ ├── centered.css
│ ├── content-with-form.css
│ ├── home.css
│ ├── legal.css
│ ├── pricing.css
│ ├── products.css
│ └── solution-page.css
├── partials/
│ ├── alerts.css
│ ├── cards.css
│ ├── comparisons.css
│ ├── footer.css
│ ├── forms.css
│ ├── illustrations.css
│ ├── menu.css
│ ├── pagination.css
│ ├── platform.css
│ ├── recommendations.css
│ └── sections.css
└── site/
├── base.css
├── buttons.css
├── components.css
├── images.css
├── svg-diagrams.css
├── theme.css
└── utilities.csssite/theme.css is imported first and holds the token palette; docs/ holds everything that only applies inside the documentation shell; layouts/ and partials/ mirror the template tree. The entry file’s comments record, per import, why it is in a layer or out of one — worth reading in full before you write your own.
The PostCSS chain
Your file, with Leed’s four lines inserted, goes through three PostCSS plugins in this fixed order:
| # | Plugin | Effect | Active when |
|---|---|---|---|
| 1 | @tailwindcss/postcss | Resolves @import, @theme, @utility, @apply and @source; emits only the utilities found in scanned source | Always |
| 2 | postcss-url | Rewrites asset URLs (below) | Always |
| 3 | fileLogger | Writes the compiled CSS out for inspection | Debug builds |
Any PostCSS warning is logged at warn level rather than swallowed, and an empty result throws No CSS output from preprocessing step!.
The URL rewrite is a simple, useful rule with one sharp edge: it looks at the pathname, not at where the file is.
- A
url()whose pathname containswebfonts/becomes/static/webfonts/<everything after webfonts/>, query string preserved. - A
url()whose pathname containsimages/becomes/static/images/<everything after images/>, query string preserved. - Everything else passes through untouched.
So a relative url("../webfonts/MyFace.woff2") written from anywhere under tailwind/ resolves correctly in the output. Where the font files themselves go is covered in Fonts and Webfonts.
Output, hashing and caching
The compile runs once per build, before templates render. The result is written on Eleventy’s after event.
| Item | Value | Set by | Consumed by |
|---|---|---|---|
| Output path | <output>/static/css/tailwind.css | eleventy.after hook, fs.writeFileSync | The browser |
| Content hash | a hash of the compiled CSS | compileCSS() | The ?v= query string |
leed.env.css.tailwindCSSPath | /static/css/tailwind.css?v=<hash> | The Tailwind plugin | leed/head |
leed.env.css.usingTailwind | true when the entry file exists | addTailwindPlugin | leed/head, as the condition on both tags |
<link> tags | one rel="preload" as="style", one rel="stylesheet" | leed/head.hbs | The browser |
| Cache header | Cache-Control: public, max-age=31536000, immutable on /static/* | leed-headers.hbs | The CDN and the browser |
Two details are worth calling out because they look like mistakes and are not.
Two link tags, not the combined form. Leed emits rel="preload" as="style" and rel="stylesheet" as separate tags rather than the shorter rel="preload stylesheet". Loading priority is identical; the reason is that the dev server’s CSS hot reload iterates link[rel="stylesheet"] as an exact attribute match, and the combined form matched nothing — so a CSS edit never reached the browser.
The hash is the whole point of the cache header. /static/* is served immutable for a year, so a stylesheet whose URL never changed would never be re-fetched. The ?v=<hash> changes on every content change and nothing else. That header is suppressed on preview deployments. The full caching story is in Static Files and Caching.
Is it minified?
No. The Tailwind stylesheet is tree-shaken but not minified — in every build mode.
This surprises people, so here is the mechanism. The stylesheet is written with fs.writeFileSync from an eleventy.after hook, straight from the PostCSS output. Eleventy transforms — including Leed’s minify transform — only ever see template output, so the stylesheet bypasses the transform chain entirely. There is no cssnano step inside the Tailwind PostCSS chain either.
Verified against a real build: 614 KB, 19,409 lines, comments intact. HTML, XML, JSON and JavaScript emitted through Eleventy are minified in non-debug builds; CSS is not.
Tree-shaking still does the heavy lifting. Only utilities found in scanned source are emitted at all — one of fourteen syntax themes, one of seventeen documentation color themes on a typical site.
The watch loop
The plugin registers addWatchTarget("<repo>/tailwind/**/*", { resetConfig: true }). Under leed site build --serve, any file under tailwind/ triggers a full configuration reset, rebuild and CSS recompile. Editing a page or a template does not recompile CSS — the stylesheet from the previous compile is simply rewritten unchanged.
Running the compile continuously is the normal working loop, and it is described alongside the rest of the dev server in Local Development.
When the compile fails
A PostCSS or Tailwind error is logged as
Tailwind Processing Error! <message>and then the process behavior splits on mode:
- A normal build calls
process.exit(1). The build fails, and nothing is deployed with a broken stylesheet. - Under
--servethe server stays up. The error is logged, the previous CSS remains in place, and you fix the file and save. That asymmetry is deliberate: a dev server that died on a mistyped selector would be unusable.
The same split applies to a failure writing the output file.
tailwind/ is fully editable, but a few sibling paths are not — src/static/images/logo-* and favicon-* among them. The complete list is in Editable and Read-Only Files.