Skip to content

Commit cb36a3a

Browse files
committed
docs: add comprehensive CSS/styling guide and css-modules type declaration
Add a dedicated Styling concepts page covering global stylesheets, Tailwind CSS, CSS Modules, route-scoped CSS, preprocessors, and how island CSS works. Ship an ambient `fresh/css-modules` type declaration so `*.module.css` imports work without @ts-ignore. Update `@fresh/init` to include the type in new projects.
1 parent 4d62e6f commit cb36a3a

9 files changed

Lines changed: 299 additions & 3 deletions

File tree

docs/latest/advanced/vite.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ Behind the scenes, the Fresh Vite plugin:
8383
During development (`deno task dev`), the Fresh Vite plugin enables HMR so that
8484
changes to components, islands, and CSS are reflected in the browser instantly
8585
without a full page reload. This is powered by Prefresh, Preact's fast refresh
86-
implementation.
86+
implementation. See [Styling](/docs/concepts/styling) for details on how CSS is
87+
handled.
8788

8889
## Debugging
8990

docs/latest/concepts/file-routing.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ array. This is a top-level export, separate from `config`:
101101
export const css = ["./assets/dashboard.css"];
102102
```
103103

104+
See [Styling](/docs/concepts/styling) for all CSS approaches including CSS
105+
Modules, preprocessors, and global stylesheets.
106+
104107
## Route Groups
105108

106109
When working with [layouts](/docs/advanced/layouts) or

docs/latest/concepts/islands.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ import OtherIsland from "../islands/other-island.tsx";
118118
</div>;
119119
```
120120

121+
## Styling islands
122+
123+
Islands can import CSS files just like any other module. CSS Modules
124+
(`*.module.css`) are the recommended approach for scoped styles. Fresh
125+
automatically collects CSS from each island's module graph and injects it as
126+
`<link>` tags during server rendering, so styles are available before hydration.
127+
128+
See [Styling](/docs/concepts/styling) for details on CSS Modules, global
129+
stylesheets, and other approaches.
130+
121131
## Client-only islands
122132

123133
Some libraries (e.g. Monaco Editor, certain charting libraries) reference

docs/latest/concepts/static-files.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import "./assets/styles.css";
3737
- Files **referenced by URL path** (favicon.ico, fonts, robots.txt, PDFs, etc.):
3838
place in `static/`
3939

40+
See [Styling](/docs/concepts/styling) for a complete guide to CSS handling in
41+
Fresh.
42+
4043
> [tip]: Always use root-relative URLs (starting with `/`) when referencing
4144
> static files in HTML. For example, use `src="/image/photo.png"` instead of
4245
> `src="image/photo.png"`. Relative paths resolve against the browser's current

docs/latest/concepts/styling.md

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
---
2+
description: |
3+
How to style your Fresh app: global stylesheets, Tailwind CSS, CSS Modules, route-scoped CSS, and preprocessors.
4+
---
5+
6+
Fresh supports several approaches to styling, all powered by
7+
[Vite's CSS handling](https://vite.dev/guide/features#css). Choose the approach
8+
that fits your use case:
9+
10+
| Goal | Approach |
11+
| ---- | -------- |
12+
| Global styles | Import CSS in `client.ts` |
13+
| Utility-first CSS | Tailwind via `@tailwindcss/vite` |
14+
| Scoped component styles | CSS Modules (`*.module.css`) |
15+
| Route-specific styles | `export const css` or side-effect import |
16+
| Preprocessors (SCSS, Less) | Install the npm package and import directly |
17+
| Static stylesheets | Place in `static/`, reference by URL path |
18+
| Inline styles in `<head>` | Use the `<Head>` component |
19+
20+
## Global stylesheets
21+
22+
The most common pattern is importing a CSS file from your `client.ts` entry
23+
point. This makes the styles available on every page.
24+
25+
```css assets/styles.css
26+
body {
27+
font-family: system-ui, sans-serif;
28+
line-height: 1.6;
29+
}
30+
```
31+
32+
```ts client.ts
33+
import "./assets/styles.css";
34+
```
35+
36+
Vite processes this import, applies any configured PostCSS transforms, and:
37+
38+
- In **development**: injects the CSS as inline `<style>` tags with hot module
39+
replacement.
40+
- In **production**: extracts the CSS to a hashed `.css` file served with
41+
long-lived cache headers.
42+
43+
> [info]: Place imported CSS files **outside** the `static/` directory (e.g. in
44+
> `assets/`). Files in `static/` are served as-is and would be duplicated in the
45+
> build output. See [Static files](/docs/concepts/static-files) for details.
46+
47+
## Tailwind CSS
48+
49+
Fresh works with [Tailwind CSS](https://tailwindcss.com/) via the official
50+
`@tailwindcss/vite` plugin:
51+
52+
```ts vite.config.ts
53+
import { defineConfig } from "vite";
54+
import { fresh } from "@fresh/plugin-vite";
55+
import tailwindcss from "@tailwindcss/vite";
56+
57+
export default defineConfig({
58+
plugins: [fresh(), tailwindcss()],
59+
});
60+
```
61+
62+
```css assets/styles.css
63+
@import "tailwindcss";
64+
```
65+
66+
```ts client.ts
67+
import "./assets/styles.css";
68+
```
69+
70+
Then use Tailwind classes in your components:
71+
72+
```tsx routes/index.tsx
73+
export default function Home() {
74+
return <h1 class="text-4xl font-bold text-blue-600">Hello, Fresh!</h1>;
75+
}
76+
```
77+
78+
## CSS Modules
79+
80+
[CSS Modules](https://vite.dev/guide/features#css-modules) scope class names to
81+
the component that imports them, preventing naming collisions. Any file ending in
82+
`.module.css` is treated as a CSS Module by Vite.
83+
84+
```css islands/Counter.module.css
85+
.counter {
86+
display: flex;
87+
gap: 0.5rem;
88+
align-items: center;
89+
}
90+
91+
.count {
92+
font-variant-numeric: tabular-nums;
93+
min-width: 3ch;
94+
text-align: center;
95+
}
96+
```
97+
98+
```tsx islands/Counter.tsx
99+
import { useSignal } from "@preact/signals";
100+
import styles from "./Counter.module.css";
101+
102+
export default function Counter() {
103+
const count = useSignal(0);
104+
return (
105+
<div class={styles.counter}>
106+
<button onClick={() => count.value--}>-</button>
107+
<span class={styles.count}>{count}</span>
108+
<button onClick={() => count.value++}>+</button>
109+
</div>
110+
);
111+
}
112+
```
113+
114+
CSS Modules work in islands, server-only components, and route files. Fresh
115+
automatically collects the CSS for any island and injects it as a `<link>` tag
116+
during server rendering.
117+
118+
### TypeScript support
119+
120+
Deno's type checker does not natively understand `*.module.css` imports. Fresh
121+
ships an ambient type declaration to fix this. Add it to your `deno.json`:
122+
123+
```jsonc deno.json
124+
{
125+
"compilerOptions": {
126+
"types": ["fresh/css-modules"]
127+
}
128+
}
129+
```
130+
131+
This declares `*.module.css` imports as `Record<string, string>`, which gives
132+
you autocompletion and type safety for class name lookups.
133+
134+
## Route-scoped CSS
135+
136+
You can load CSS for a specific route in two ways.
137+
138+
### Side-effect import
139+
140+
Import a CSS file directly in the route module:
141+
142+
```tsx routes/dashboard.tsx
143+
import "./dashboard.css";
144+
145+
export default function Dashboard() {
146+
return <main class="dashboard">...</main>;
147+
}
148+
```
149+
150+
### The `css` export
151+
152+
Export a `css` array with paths to CSS files:
153+
154+
```tsx routes/dashboard.tsx
155+
export const css = ["./assets/dashboard.css"];
156+
157+
export default function Dashboard() {
158+
return <main class="dashboard">...</main>;
159+
}
160+
```
161+
162+
Both approaches scope the CSS to the route — the styles are only loaded when
163+
that route is rendered. See [File routing](/docs/concepts/file-routing) for more
164+
on route exports.
165+
166+
## Preprocessors
167+
168+
Since Fresh uses Vite, you can use CSS preprocessors by installing the
169+
corresponding npm package. No additional Vite plugin is needed.
170+
171+
### SCSS / Sass
172+
173+
```sh
174+
deno install npm:sass
175+
```
176+
177+
```scss assets/theme.scss
178+
$primary: #3b82f6;
179+
180+
.btn-primary {
181+
background-color: $primary;
182+
&:hover {
183+
background-color: darken($primary, 10%);
184+
}
185+
}
186+
```
187+
188+
```ts client.ts
189+
import "./assets/theme.scss";
190+
```
191+
192+
### Less
193+
194+
```sh
195+
deno install npm:less
196+
```
197+
198+
```less assets/theme.less
199+
@primary: #3b82f6;
200+
201+
.btn-primary {
202+
background-color: @primary;
203+
}
204+
```
205+
206+
```ts client.ts
207+
import "./assets/theme.less";
208+
```
209+
210+
Preprocessor files also work with CSS Modules (e.g. `Button.module.scss`) and
211+
route-scoped imports.
212+
213+
## Static stylesheets
214+
215+
For CSS files that should be served without processing, place them in the
216+
`static/` directory and reference them by URL path:
217+
218+
```tsx routes/index.tsx
219+
import { Head } from "fresh/runtime";
220+
221+
export default function Home() {
222+
return (
223+
<>
224+
<Head>
225+
<link rel="stylesheet" href="/legacy.css" />
226+
</Head>
227+
<h1>Hello</h1>
228+
</>
229+
);
230+
}
231+
```
232+
233+
Static files are served with
234+
[`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag)
235+
headers for caching. Use `asset()` for cache-busted URLs with a one-year cache
236+
lifetime.
237+
238+
## How island CSS works
239+
240+
When an island imports CSS (via CSS Modules, side-effect imports, or
241+
preprocessors), Fresh handles it automatically:
242+
243+
1. **During the build**, Vite extracts CSS from each island's module graph into
244+
separate hashed `.css` files.
245+
2. **At runtime**, when the server renders an island, Fresh looks up its
246+
associated CSS and adds it to the page.
247+
3. The CSS is injected as `<link>` tags in `<head>` so styles are available
248+
before the island hydrates.
249+
250+
In development mode, CSS is injected as inline `<style>` tags with hot module
251+
replacement — changes are reflected instantly without a page reload.

docs/toc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const toc: RawTableOfContents = {
4242
["context", "Context", "link:latest"],
4343
["signals", "Signals", "link:latest"],
4444
["layouts", "Layouts", "link:latest"],
45+
["styling", "Styling", "link:latest"],
4546
["static-files", "Static files", "link:latest"],
4647
["file-routing", "File routing", "link:latest"],
4748
],

packages/fresh/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"./dev": "./src/dev/mod.ts",
1010
"./compat": "./src/compat.ts",
1111
"./internal": "./src/internals.ts",
12-
"./internal-dev": "./src/internals_dev.ts"
12+
"./internal-dev": "./src/internals_dev.ts",
13+
"./css-modules": "./src/css-modules.d.ts"
1314
},
1415
"imports": {
1516
"@deno/esbuild-plugin": "jsr:@deno/esbuild-plugin@^1.2.0",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Ambient type declarations for CSS Modules.
3+
*
4+
* Add this to your project's `deno.json` compiler options:
5+
*
6+
* ```json
7+
* {
8+
* "compilerOptions": {
9+
* "types": ["fresh/css-modules"]
10+
* }
11+
* }
12+
* ```
13+
*
14+
* Or reference it directly in a source file:
15+
*
16+
* ```ts
17+
* /// <reference types="fresh/css-modules" />
18+
* ```
19+
*
20+
* @module
21+
*/
22+
23+
declare module "*.module.css" {
24+
const classes: Record<string, string>;
25+
export default classes;
26+
}

packages/init/src/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ if (Deno.args.includes("build")) {
604604
};
605605

606606
if (useVite) {
607-
denoJson.compilerOptions.types = ["vite/client"];
607+
denoJson.compilerOptions.types = ["vite/client", "fresh/css-modules"];
608608
denoJson.tasks.dev = "vite";
609609
denoJson.tasks.build = "vite build";
610610

0 commit comments

Comments
 (0)