Tutorial 16 of 36 · for anyone who styles with Tailwind · Markdown

Style an app with Tailwind

You'll style a status page with Tailwind utilities. Tailwind CSS v4 is built into the bundler and the build hands it the exact list of classes your pages render. It also tells you about a class it can't see before a visitor finds the missing style.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start an app with Tailwind in it#

$ fsr new status --with tailwind
$ ls status/app
generated
importmap.json
routes
src
styles
tsconfig.build.json
tsconfig.json
types

The compiler is snapfire_tailwind, Tailwind CSS v4 ported to Rust, inside the bundler you already installed. app/styles/app.css opts in on its first line:

@import "tailwindcss";

The rest of that file is the scaffold's own CSS: a --line color with a dark value and a few classes the layout uses. Tailwind and plain CSS sit in one stylesheet.

A page with a mistake in it#

Some services to report on, in app/src/services.ts:

app/src/services.ts
export type State = "operational" | "degraded" | "down";
export type Service = { name: string; state: State; uptime: string };

export const SERVICES: Service[] = [
  { name: "API", state: "operational", uptime: "99.98%" },
  { name: "Checkout", state: "degraded", uptime: "99.41%" },
  { name: "Search", state: "operational", uptime: "99.95%" },
  { name: "Email", state: "down", uptime: "97.12%" },
];
app/routes/page.loader.ts
import type { Ctx } from "@snapfire/fsr";
import { SERVICES } from "@src/services";

export async function load(_ctx: Ctx<"/">) {
  return { services: SERVICES };
}

Now the page, with two mistakes in it:

app/routes/page.tsx
import type { RootProps } from "@generated/client";

export default function Index({ services }: RootProps) {
  return (
    <section>
      <h1 className="mb-6 text-3xl font-bold">System status</h1>
      <ul className="grid gap-3">
        {services.map((s) => (
          <li key={s.name} className="flex items-center justify-between rounded-xl border border-line p-4">
            <span className="font-medium">{s.name}</span>
            <span className={`rounded-full px-3 py-1 text-sm status-${s.state}`}>{s.state}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}

An app made with --with tailwind alone writes its pages in fsr's own template dialect, which the server renders and the browser never runs.

$ fsr dev status/app --verbose
tailwind  22 classes in generated/tailwind.classes
warning   routes/page.tsx#default            tailwind/unlisted-class: a class value the build cannot list, `rounded-full px-3 py-1 text-sm status-${s.state}`
⚠️  `border-line` in routes/page.tsx#default is not a Tailwind utility and no stylesheet defines it

Those are the lines about Tailwind from the build's output.

Where the 22 classes came from#

fsr already lowered every page to find out what it renders, so it reads the classes off that: every className, both branches of a conditional and the fixed parts of a template string. They're written to generated/tailwind.classes, one per line with the module that uses it:

$ head -5 status/app/generated/tailwind.classes
bar	routes/layout.tsx#default
bg-amber-100	routes/page.tsx#default
bg-emerald-100	routes/page.tsx#default
bg-rose-100	routes/page.tsx#default
border	routes/page.tsx#default

The stylesheet is built for exactly that list. That's also why the two warnings can exist at all: the build knows what it couldn't list.

Fix the two warnings#

status-${s.state} is a class made from data. The build can't know which states exist, so neither can the stylesheet. Those pills would have no color. Write the values where the build can read them. A lookup object is read whole, so all three classes make the list. Replace app/routes/page.tsx:

app/routes/page.tsx
import type { RootProps } from "@generated/client";

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",
};

export default function Index({ services }: RootProps) {
  return (
    <section>
      <h1 className="mb-6 text-3xl font-bold">System status</h1>
      <ul className="grid gap-3">
        {services.map((s) => (
          <li key={s.name} className="flex items-center justify-between rounded-xl border border-line p-4">
            <span className="font-medium">{s.name}</span>
            <span className={`rounded-full px-3 py-1 text-sm ${tone[s.state]}`}>{s.state}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}

border-line is a typo for a color Tailwind doesn't have. The scaffold's --line variable is plain CSS, so Tailwind has never heard of it. Make it a theme color by adding this to app/styles/app.css, right below @import "tailwindcss";:

app/styles/app.css
@theme inline {
  --color-line: var(--line);
}

inline makes border-line use var(--line) where it's applied, so the dark value the scaffold already sets still switches with the system theme. Save both files and fsr dev rebuilds the stylesheet:

tailwind  28 classes in generated/tailwind.classes

No warnings. Open http://127.0.0.1:3000: four rounded rows, each with a green, amber or rose pill on the right.

Add a class while it runs#

Add hover:shadow-md to the end of the li's className and save:

tailwind  29 classes in generated/tailwind.classes

The page reloads and the rows lift under the pointer. The stylesheet only ever holds what the pages use. app/dist/styles/app.css is about 10 KB here, with only the theme values those 29 classes need.

Try it#

Change flex on the li to flx and save. The build names it as you go:

⚠️  `flx` in routes/page.tsx#default is not a Tailwind utility and no stylesheet defines it

Then put status-${s.state} back in place of ${tone[s.state]}. The tailwind/unlisted-class warning comes back and the pills lose their color, since no stylesheet has a rule for a class nobody could list. Put the lookup back.