zfb
GitHub repository

Type to search...

to open search from anywhere

Styling

Global CSS, Tailwind v4, and where component-scoped styling fits in zfb.

Styling in zfb has two layers — global CSS and Tailwind v4 — and one well-supported pattern for everything else: utility classes on the markup itself.

Global CSS

The default template ships with styles/global.css. This is plain CSS, processed by zfb's CSS pipeline, and made available to every page. Use it for design tokens, resets, base typography, and anything else that should apply site-wide.

zfb resolves the global stylesheet from either styles/global.css or src/styles/global.css — the top-level styles/global.css wins if both exist. The src/styles/ fallback exists for projects that organise sources under src/ (a common Vite/Astro/Next-style layout).

:root {
  --color-text: #1a1a1a;
  --color-bg: #ffffff;
  --font-body: system-ui, sans-serif;
}

body {
  color: var(--color-text);
  background: var(--color-bg);
  font-family: var(--font-body);
}

Imports inside CSS work as you would expect — split your styles across files and pull them together from global.css.

Tailwind v4

Tailwind v4 is on by default. A project whose zfb.config.{ts,json} carries no tailwind key builds with Tailwind enabled — there is nothing to add and no flag to flip. The key exists to opt out:

{
  "tailwind": {
    "enabled": false
  }
}

With Tailwind on, the zfb-css crate runs the bundled tailwindcss-v4 binary as part of the build. There is no per-project Tailwind install — you do not add tailwindcss to package.json and you do not maintain a tailwind.config.js. The compiler is built into zfb itself; Tailwind is embedded, not installed below states the full contract.

Utility classes work in .tsx files under the scanned content roots (see Where Tailwind looks below):

export default function Hero() {
  return (
    <section className="mx-auto max-w-2xl px-6 py-12">
      <h1 className="text-3xl font-bold">Hello</h1>
    </section>
  );
}

Tailwind v4's CSS-first configuration is supported through @theme directives in global.css — you customise tokens by editing CSS, not a JS config file.

Tailwind is embedded, not installed

zfb owns Tailwind end to end: the compiler, the version it runs, and the resolution of the tailwindcss CSS imports. The four points below are the whole contract.

The compiler ships inside the zfb executable. The Tailwind v4 standalone CLI is embedded in zfb's vendor snapshot, extracted at runtime, and invoked as a subprocess by the zfb-css crate — no Node.js, no node_modules, no download at build time (see Install without Node). The exact Tailwind version is pinned in zfb's own source tree (TAILWIND_VERSION in crates/zfb/build.rs) and changes only when zfb itself releases.

Your project must not install Tailwind. Do not add tailwindcss or @tailwindcss/vite to package.json. If either is already listed — in a project migrated from a Vite or Astro setup, or one produced by a scaffolder written before this contract — remove it and reinstall. Projects scaffolded by zfb new carry neither.

@import "tailwindcss" is resolved by the embedded engine, never from node_modules. All three specifiers you are likely to write are virtual:

styles/global.css
@import "tailwindcss";
/* or, taken apart: */
@import "tailwindcss/preflight";
@import "tailwindcss/utilities";

zfb's CSS import walker recognises exactly the specifier tailwindcss plus any tailwindcss/-prefixed subpath, and skips resolution for those entirely (is_virtual_specifier in crates/zfb-css/src/css_imports.rs); the embedded binary supplies the stylesheets itself. The match is that narrow on purpose — a separate package whose name merely begins with the same letters (tailwindcss-something) is an ordinary import and still resolves from node_modules. A node_modules/tailwindcss directory is therefore never read. Keeping one around is unsupported: it does not change the emitted CSS, does not override the embedded version, and only adds weight to your lockfile.

There is no project-level version override — but the binary path is overridable. No config field selects a Tailwind version, and an npm-installed tailwindcss is never consulted, whatever its version. What you can redirect is which binary runs: ZFB_TAILWIND_BIN points the CSS engine at an absolute path to a Tailwind v4 binary of your choosing, in place of the embedded one. Treat it as an advanced/CI escape hatch — for air-gapped builds, or environments required to run binaries from their own vetted supply chain — rather than as a supported way to select a Tailwind version. See the environment variable reference for its exact semantics.

A build that survives removing the dependency is the contract, not luck

If you drop tailwindcss from package.json and the emitted stylesheet comes out byte-identical, nothing is silently broken and nothing is about to break — the dependency was never being read in the first place.

Where Tailwind looks

Tailwind's @source content scan covers five default roots, resolved against the project root: pages/, components/, layouts/, content/, and src/. A utility class has to actually appear in a file under one of these directories to be picked up — a class used only in a file outside all five is not scanned, so it silently never reaches the emitted stylesheet (a green build, but the class has no styles).

Setting tailwind: { enabled: false } switches to an AuthoredCssEngine that passes your authored styles/global.css through verbatim — no Tailwind @import, no utility scan, no preflight reset, no subprocess. CSS Modules compilation, class-name hashing, and asset emission all keep working unchanged; only the Tailwind-specific steps are skipped.

While Tailwind is enabled, the build pipeline synthesises a temp entry file named zfb-tailwind-entry-*.css next to your CSS entry (inside the tracked source tree) and removes it once the build finishes. New scaffolds ignore it automatically. If your project was scaffolded before this glob shipped, add **/zfb-tailwind-entry-*.css to your .gitignore.

Component-scoped styling

There are two well-supported patterns for component-level styling: Tailwind utility classes, and CSS Modules.

Tailwind utility classes

The simplest pattern is global CSS for site-wide concerns plus Tailwind utility classes for component-level styling. This keeps the build fast and the runtime trivial, and it maps cleanly onto Tailwind v4's design-token model.

CSS Modules

For genuinely component-scoped CSS — class names that must not collide across components — zfb supports CSS Modules. Any file named *.module.css is a CSS Module: its class names are rewritten to scoped, file-stable identifiers at build time, so two components can both define a .button class without clashing.

Author the styles in a .module.css file:

components/card.module.css
.card {
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 1rem;
}

.title {
  font-weight: 700;
}

Import the module with a default import and read class names off the imported object:

components/Card.tsx
import styles from "./card.module.css";

export default function Card() {
  return (
    <div className={styles.card}>
      <h3 className={styles.title}>Hello</h3>
    </div>
  );
}

At build time zfb resolves styles.card to the scoped class name (e.g. KdPA9G_card) — the rendered HTML carries that scoped class, and the scoped CSS is folded into the same hashed dist/assets/styles-<hash>.css stylesheet as the rest of your CSS. There is no separate .css file per module and no runtime cost: the lookup is resolved during the build.

How it works:

  • import styles from "./x.module.css" must be a default import. styles is a plain object mapping your original class names to the scoped ones.

  • Access a class with member access — styles.card or styles["card"]. Both work; computed access with a dynamic key does not, because the rewrite happens at build time.

  • Plain .css imports (a file not ending in .module.css) are still treated as global CSS — only the .module.css suffix opts a file into scoping.

  • The .module.css file is discovered when it is imported from a .tsx/.ts/.jsx/.js file under pages/, components/, layouts/, or content/.

Limitations:

  • CSS Modules imported by bare specifier from node_modules (e.g. import s from "@org/pkg/x.module.css") are not scoped — only project-relative ./ / ../ imports are.

  • The :export block and composes directive are not supported; use plain class selectors.

Monorepo / workspace-sibling packages

In a pnpm-workspace-style monorepo, a sibling package reached only through a tsconfig path alias (not a plain relative import) is still picked up by the CSS pipeline:

  • The sibling's own *.module.css files join the same CSS Modules scan as project-local files, so their scoped class names resolve the same way as any other module.

  • The sibling's source files also feed Tailwind's @source content scan, so utility classes used only inside the sibling package are generated too — not silently dropped.

The aliased target becomes a "mirror root": the CSS pipeline treats a claimed workspace-sibling directory the same way it treats pages/, components/, and the other scanned roots described in Where Tailwind looks above.

What lands in dist/

The build pipeline runs Tailwind v4 and lightningcss, writes a hashed stylesheet to dist/assets/, and injects a <link rel="stylesheet"> into each rendered HTML page. The stylesheet reference is stable so CDN caches can hold it across deployments until the content changes.

Revision History

CreatedUpdated