brust 2.0.0-alpha.5
Guide / CSS

CSS

You import stylesheets; brust bundles and minifies them, names them by content, and links each page's stylesheets in its head. You never write those link tags.

Global stylesheets

A plain import './layout.css' in a component is enough. The build collects every .css import reachable from a page and the server links them.

Layout.tsx
// Layout.tsx: site-wide, because every page's chain starts with this layout
import './layout.css'

// Home.tsx: linked on this page only
import './home.css'

Which, and in what order

A page links, in this order:

  1. every file in [css] global of brust.toml, in the listed order;
  2. every CSS import reachable from the page's chain: the root layout first, then nested layouts, then the page, each depth-first in import order.

A file is linked once, at its first occurrence. So a layout's import is site-wide, and a page's import is linked on that page only.

brust.toml
[css]
global = ["styles/reset.css", "styles/theme.css"]   # for apps without a layout, or to go first

The walk follows static imports and dynamic import() of your own source files, including child components, so a lazily loaded component is styled on first paint. It also collects CSS imported from packages, for example import 'some-ui/dist/style.css'. It does not read the JavaScript inside node_modules.

Note

A CSS import in routes.tsx itself belongs to no route: the build warns and links it nowhere. Import it from a layout, or list it in [css] global.

The built files

Each stylesheet becomes dist/client/<name>-<hash>.css with a 10-character content hash. It is minified, its @imports are inlined and its nesting is flattened. A file shared by several pages is one file at one URL. The hash is in the name, so the server sends the file as immutable; there is no .min. in the name. brust build always minifies, and brust dev does not, so the CSS stays readable while you work.

A relative url() to a font or image is copied next to the stylesheet with a hash in its name and the URL is rewritten. An absolute /public/… or /_brust/… URL is left as written.

layout.css
.hero {
  background: url("/public/hero.jpg");      /* stays as written */
  font-family: Inter;
}
@font-face {
  font-family: Inter;
  src: url("./fonts/inter.woff2");           /* copied to dist/client/inter-<hash>.woff2, URL rewritten */
}

The types served from there are woff2, woff, ttf, otf, eot, png, jpg, jpeg, gif, webp, avif, svg and ico. Any other type, a cursor file or a video, fails the build with css-asset-type because the server would answer 404 for it. Put that file in public/ and reference it as /public/….

The server inserts one <link rel="stylesheet" data-brust-css> per stylesheet right after the opening <head>, or right after a <meta charset> that comes first. Everything you wrote in the head follows, so your own <link> and <style> tags come later and win the cascade.

the rendered <head>
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="/_brust/client/layout-3f9c1a07b2.css" data-brust-css>
  <link rel="stylesheet" href="/_brust/client/home-91c4d2e8aa.css" data-brust-css>
  <title>Home</title>
  <link rel="stylesheet" href="/public/mine.css">   <!-- yours: later, so it wins -->
</head>

This happens on every page, including all-static pages that load no JavaScript. A page with no <head> gets the links first, after its doctype; <header> is not a head. Cloudflare Workers and --target static use the same server code.

CSS Modules

A file named *.module.css is scoped. import styles from './Card.module.css' gives a frozen object that maps each class to a generated name, such as card_nBzD1A. composes: works.

Card.tsx
// Card.tsx
import styles from './Card.module.css'

export default function Card() {
  return <p className={styles.card}>hello</p>
}
Card.module.css
/* Card.module.css */
.card { color: oklch(0.5 0.15 260); }
.title { font-weight: 700; }

Class names are plain strings, so the same map works in static and native templates, in a React island's server render and its hydration, and in the Cloudflare worker. Types ship with @brust/core: the import is typed as an object of strings.

Class names fold at compile time

In a static or native template, styles.card and styles['card'] are replaced by the literal class string when the page is compiled. The component keeps needs_worker: false and the build plans no job for it, so the worker is not called for a string the build already knew.

dist/jinja/card.jinja
<p{% if "card_nBzD1A" | present %} class="{{ "card_nBzD1A" | attr_str | e }}"{% endif %}>hello</p>

It folds inside a conditional (on ? styles.on : styles.off) and inside a template literal too. A native component keeps its interactivity: the client chunk gets the literals as well.

A class the module does not define is a build error with the file, line and column of the use, rule css-module-class. The build never guesses.

Limit

Only a property read of a default import of a relative *.module.css folds. Object.keys(styles), or passing styles to a function, still reads the map at run time. Imported objects do not fold in general; this is the one exception, because the map is frozen.

Try it

Type an expression a template could use with the module above. The demo answers the way the build does.

Live demoClassFold · native
Compiled
class="card_nBzD1A"
Result
folds: a literal in the template, no job

Tailwind CSS v4

Tailwind is built in: brust compiles with the Tailwind packages installed in your app, so the build uses exactly the tailwindcss version you pinned. Install tailwindcss, @tailwindcss/node and @tailwindcss/oxide as your app's own dev dependencies, at one version. The blank starter already ships all three and an app.css to start from, see Project structure.

bun add -d --exact [email protected] @tailwindcss/[email protected] @tailwindcss/[email protected]
# the blank starter already has all three:
bun create @brust/app my-app
app.css
/* app.css, imported by the root layout */
@import "tailwindcss";

@theme {
  --color-brand: oklch(0.55 0.2 260);
}
Layout.tsx
// Layout.tsx
import './app.css'
// …
<h1 className="text-brand font-bold md:text-4xl">…</h1>

When it loads

brust loads Tailwind only when a stylesheet, or a file it imports, contains @import "tailwindcss", @apply, @reference, @theme or @plugin. If the packages are missing, the build fails with tailwind-missing and prints the bun add -d command.

Limit

brust looks for Tailwind in your stylesheets and in the files they import by a relative path. An @import of a bare package name is not followed, so Tailwind used only inside a package's stylesheet is not detected: put one of the markers above in one of your own files.

What it scans, and one import

Tailwind scans the app root for class names, so the utilities you use in .tsx files end up in the stylesheet, minified like the rest. Import tailwindcss once, in the stylesheet your root layout imports: the build warns when more than one file has @import "tailwindcss", because each one ships all of Tailwind.

Inside a CSS Module

A CSS Module that uses @apply must reference your Tailwind entry itself. This is standard in v4; brust does not add it.

Card.module.css
/* Card.module.css */
@reference "../app.css";
.card { @apply p-4 text-brand; }

Tailwind v4 puts its utilities in a cascade layer, and unlayered CSS beats layered CSS whatever the file order. Your CSS Modules and plain stylesheets are unlayered, so they beat a utility. To let a utility override a module class, put the module rule in a layer too.

Card.module.css
@layer components {
  .card { padding: 1rem; }   /* now a utility class can override it */
}

In brust dev

Editing a stylesheet rebuilds the CSS and the open page swaps its stylesheet links without reloading, so the page keeps its state. Editing a .tsx file rebuilds and reloads the page, and Tailwind rescans for classes. In dev the CSS is not minified.

What does not change

  • A <link> you write to a file in public/ is served as before, without a content hash.
  • No JavaScript bundle contains CSS. A .css import in JavaScript is an empty module, and a .module.css import is its class map.