109. Tailwind and themes
For app developers. How a stylesheet that imports Tailwind gets its utilities from the classes your pages render, what the build says about a class it cannot see, how a theme is chosen for the whole document per request and how a card image draws the same classes.
Tailwind is an import
A stylesheet under styles/ that imports Tailwind is the opt-in:
/* app/styles/app.css */
@import "tailwindcss";
@theme {
--color-accent: #1f6feb;
--color-line: #dde3ea;
}
@utility pill {
@apply inline-flex items-center rounded-full border border-line px-2.5 py-0.5 text-[13px];
}fsr new shop --with tailwind starts an application that way. On one that already has stylesheets, fsr use shop/app tailwind adds styles/tailwind.css with Tailwind's theme and utilities and leaves its preflight reset out, so the existing pages keep their look while classes are added to them.
Everything Tailwind CSS v4 reads in a stylesheet works: @theme, @utility, @variant, @custom-variant, @apply, @source inline(...), theme() and --spacing(). There is no tailwind.config.js and no Node: the engine is snapfire_tailwind, a Rust port of Tailwind's own compiler. A JavaScript plugin or config is refused by name. So is a stylesheet that points Tailwind at files to scan, @source "../node_modules/some-ui" or source("../src") on the import, since the build reads classes from what it compiles and never scans; the error says to list such classes with @source inline(...).
Where the classes come from
Tailwind's CLI scans your files for anything that looks like a class. fsr already knows where every class is, so it lists them instead. fsr build reads the lowered plan: every class an element renders, both branches of a conditional, the fixed parts of a template, the values of a literal lookup object and the className a component is placed with. It writes them to generated/tailwind.classes, one per line with the modules that use it. The bundle builds the stylesheet for exactly that set:
tailwind 51 classes in generated/tailwind.classesA component that renders in the browser alone has no lowered tree, so the bundle reads its source instead: className and class attributes, the arguments of clsx, cn and Vue's normalizeClass and the constants they name, a lookup table among them. In fsr dev a save that changes the set rebuilds the stylesheet with it.
Classes from vendored packages
A component library under vendor/ names classes too. The bundle reads them from the packages your sources import. A package built with snapfirec --emit-tailwind-classes carries its class list as tailwind.classes beside its modules, read from its sources before anything was minified:
app/vendor/@acme/ui/
index.min.js
index.d.ts
tailwind.classesA package without one, the way a third-party bundle arrives, is read from its modules, its imports followed. A helper it imports is followed under whatever name the minifier gave it. One the bundler inlined and renamed is not, so a class composed through it is not seen; list it with @source inline(...). A site with no vendor tree of its own reads its shell's.
A package that carries index.d.ts beside its entry needs nothing from fsr types: the build takes its declarations from the vendor tree.
In the portal, packages/acme_ui and packages/acme_charts are built into app/vendor/@acme/. /themes shows their components styled by classes nothing else in the portal names.
A class the build cannot list
A class built from data at runtime is a class the build never sees:
<span className={`rounded-full px-2 status-${system.state}`}>{system.state}</span>The build says so and names the module. The fix is to write the values where the build can read them. A lookup object is read whole:
const tone: Record<string, string> = {
operational: "bg-emerald-100 text-emerald-800",
degraded: "bg-amber-100 text-amber-800",
down: "bg-rose-100 text-rose-800",
};
<span className={`rounded-full px-2 ${tone[system.state]}`}>{system.state}</span>A value that is not a class at all is better as an attribute and a variant:
<tr data-status={invoice.status} className="group">
<td className="group-data-[status=overdue]:text-rose-600">{invoice.status}</td>
</tr>When neither fits, list the values in the stylesheet:
@source inline("bg-{red,amber,emerald}-100");A class that is not a utility and that no stylesheet defines is a warning too, so a typo shows up in the build rather than as a missing style:
⚠️ `flx` in routes/page.tsx#default is not a Tailwind utility and no stylesheet defines itTailwind in a site
A site's stylesheet is linked after the shell's in the same document, so a site brings its utilities and leaves the base styles to the shell:
/* sites/blog/app/styles/blog.css */
@import "tailwindcss/theme" layer(theme.root);
@import "tailwindcss/utilities" layer(utilities);
@theme reference {
--color-card: #ffffff;
--color-accent: #1f6feb;
}@theme reference names the shell's tokens without writing them: bg-card compiles to background-color: var(--color-card, #fff), so the shell's value and the document's theme decide what the site's card looks like.
A theme is the whole document
A theme is a file of @theme blocks under styles/themes/, named by the file, that overrides tokens the root theme defines:
/* app/styles/themes/ocean.css */
@theme {
--color-accent: #0e7490;
--color-canvas: #f0f9fb;
}Tailwind compiles the root theme as it always does. fsr writes the named themes beside it as plain CSS, the way Tailwind's documentation switches a theme at runtime: ocean.css reaches the browser as [data-sf-theme="ocean"] { --color-accent: #0e7490; --color-canvas: #f0f9fb; }, so switching is one attribute on <html>. Each one sits in the theme.named cascade layer, so the root theme has to sit in theme.root, which comes before it. An application with themes imports Tailwind's theme on its own rather than through @import "tailwindcss", which puts it in theme:
/* app/styles/app.css */
@layer theme, base, components, utilities;
@import "tailwindcss/theme" layer(theme.root);
@import "tailwindcss/preflight" layer(base);
@import "tailwindcss/utilities" layer(utilities);Themes are not Tailwind's. An application without it declares the root's tokens in :root inside @layer theme.root { ... }, reads them with var() in its own rules and writes a theme file as a :root rule or bare custom properties; everything below works the same. A build with themes and the root theme outside theme.root fails and names the line to write. So does a theme that sets a token the root does not define (for a site, neither the site's root nor its shell's): add the token to the root's @theme with the value it has outside the theme.
The root theme is named default once there is a theme file. The build writes the root's value of every token a theme sets under [data-sf-theme="default"], so theme.ts can choose it over a route's own; a styles/themes/default.css fails the build. The generated types declare Theme as the union of the names, a site's own and its shell's.
A layout or a page picks one with a literal export, plain or forced:
export const theme = "editorial";
export const theme = { name: "ledger", force: true };Down a route's layouts the deepest one that sets a theme wins, unless a level above it forces its own; of two forced levels the outer one wins. In the portal the blog sets editorial and billing forces ledger. A name with no file behind it fails the build.
Choosing per request
app/theme.ts decides for each request, after middleware and before the page. It is lowered like a loader, so it reads the session, the query and the identity. ctx.theme is the route's own choice:
import type { Scheme, Theme, ThemeChoice, ThemeCtx } from "@snapfire/fsr";
export function theme({ theme, query, session }: ThemeCtx): ThemeChoice {
const scheme = session.scheme === "light" || session.scheme === "dark" ? (session.scheme as Scheme) : null;
if (theme.forced) return { name: theme.name, scheme };
const picked = query.theme ?? session.theme;
return { name: picked === "" ? theme.name : (picked as Theme), scheme };
}force is a flag the body respects rather than a rule the host enforces, so a site that must keep its brand says so and the body keeps it. A mounted site's own theme.ts runs after the shell's and sees the shell's answer as ctx.theme. The answer is written on the document as <html data-sf-theme="ocean" data-sf-scheme="dark">; a client navigation sets both from the payload's head row, so moving from the portal into billing switches the theme without a reload. A prerendered page is written without one; the host writes the request's theme into its <html> before sending it, the same step as for a page rendered live.
A route whose forced theme was kept also carries data-sf-theme-forced on <html>, so a shell's own theme picker can say so with a variant alone:
<span className="hidden [[data-sf-theme-forced]_&]:inline">This site keeps its own theme</span>Light and dark are the scheme, not a theme, so a brand theme and the visitor's scheme combine. A null scheme writes no attribute and leaves it to the browser's prefers-color-scheme. Key the variant on the attribute and on the preference when it is absent:
@custom-variant dark {
&:where([data-sf-scheme="dark"], [data-sf-scheme="dark"] *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(:root:not([data-sf-scheme]), :root:not([data-sf-scheme]) *) {
@slot;
}
}
}The dark values go in the stylesheet's own @layer theme. A rule in theme itself outranks everything in its sublayers, so the scheme wins over a theme's neutrals whichever stylesheet the document links last:
@layer theme {
[data-sf-scheme="dark"] {
--color-canvas: #0d1117;
--color-ink: #e6edf3;
}
@media (prefers-color-scheme: dark) {
:root:not([data-sf-scheme]) {
--color-canvas: #0d1117;
--color-ink: #e6edf3;
}
}
}Previewing a theme inside a page
The theme and scheme selectors match any element, so a part of a page can be drawn in another theme with real utilities:
<div data-sf-theme="default">
<figure data-sf-theme="ocean" data-sf-scheme="dark" className="bg-canvas p-4 font-sans text-ink">
<a href="/">A link</a> beside <code>code</code>
</figure>
</div>The default wrapper resets the root's values beneath whatever theme the document has. Two things do not carry into a preview by themselves. A value set on <html> or body, such as the font, is resolved there, so the preview sets font-sans and bg-canvas again. A token declared as another's var(), such as --color-accent: var(--color-brand), resolves where it is declared, so declare it again wherever a theme applies:
@layer theme {
[data-sf-theme] {
--color-accent: var(--color-brand);
}
}A dark: variant keys on an ancestor's attribute, so inside a light preview on a dark page it still applies; draw previews from tokens rather than dark: classes.
A theme never reaches a body, so it changes no cache key.
Card images draw the same classes
A card image is drawn by a renderer that matches no selectors, so the build turns each class into plain declarations in generated/tailwind.cards.json, with colors in sRGB and variables resolved. The host writes them into each element's style:
export default function Image({ post }: PostSlugProps) {
return (
<div className="flex h-full w-full flex-col justify-between bg-amber-50 p-[72px] text-gray-800">
<h1 className="m-0 text-[72px]/[1.1] font-bold">{post.title}</h1>
</div>
);
}An inline style still wins over a class, as on a page. A breakpoint variant applies when the card is at least that wide; hover:, dark: and the other variants mean nothing on a still image and are skipped.
Lab
In the portal example, open /themes: each cell is drawn in its own theme and scheme and "Use this" saves both. Open /?theme=forest: the whole document turns green and serif for that one request. Sign in, pick Ocean and Dark on /account and follow the header into the blog, billing and status. The blog takes your pick over its own editorial, billing keeps ledger and every site follows the dark scheme. Then change the status page's lookup to `status-${system.state}` and run fsr build sites/status/app: the report names the module and the value it cannot list.