Styling: global CSS, Tailwind, CSS Modules
The feature
Styling is convention, not configuration. Drop app/global.css and June links
it on every page — no import "./global.css", no <link> to wire:
/* app/global.css — auto-linked, recompiled on save, shipped as a static asset */
body { font-family: system-ui; }
Plain CSS works with zero dependencies. That's the floor; the rest is opt-in.
Tailwind v4 (the blessed default)
Opt into Tailwind by importing it from global.css — June compiles it through
the app's own Tailwind v4 (resolved from your node_modules, never bundled
into the framework), so you control the version and Tailwind auto-detects the
classes it needs:
/* app/global.css */
@import "tailwindcss";
The starter ships this line and Tailwind-styled pages. Plain CSS and Tailwind coexist in the same file.
CSS Modules
Name a file *.module.css and import it for locally-scoped class names:
/* app/Card.module.css */
.card { padding: 1rem; border: 1px solid #ddd }
.title { font-weight: 600 }
import styles from "./Card.module.css";
export default function Card() {
return <div className={styles.card}><h2 className={styles.title}>…</h2></div>;
}
styles.card resolves to a scoped name like card_9e43e788. The scoped name is
a deterministic hash of the file path + class — not a bundler-internal
counter — so the name is byte-identical in dev SSR, the production worker build,
the client islands bundle, and whether you run on Bun or Node. That identity is
what makes hydration safe: the server and client always agree on the class.
The full CSS-Modules surface works:
.btn { composes: base from "./shared.module.css"; color: white }
:global(.prose a) { text-decoration: underline }
composespulls in another local (even across files);styles.btnbecomes the merged"btn_… base_…".:global(...)opts a selector out of scoping for genuinely global rules.
For app-wide globals, prefer app/global.css; reach for :global only inside a
module.
What the build ships
Dev serves CSS readable and hot — edit a stylesheet and the page swaps it
without a reload. june build optimizes it:
- every stylesheet is content-hashed and emitted under the reserved
/_june/prefix (e.g./_june/global.a1b2c3d4.css), so it can be served immutable — the browser never revalidates it, and a change ships a new filename; - output is minified with Lightning CSS (the same engine Tailwind v4 optimizes with), so global CSS, Tailwind, and CSS-Modules sheets all come out consistently small.
The /_june/ prefix is June's alone — your routes and your own asset paths can
never collide with framework output.
Why it matters
CSS is the one surface that is purely for humans — it never touches the agent
projections (.md / .json / /mcp). So the rule is "stay out of the way":
the floor is plain CSS with no build to think about, the ceiling is Tailwind +
scoped modules with deterministic names an agent can reason about, and the
dev↔build difference is only the asset URL — never the rendered markup, which is
what keeps the dual-audience parity contract intact.