Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

## Debugging

Expand Down
3 changes: 3 additions & 0 deletions docs/latest/concepts/file-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ array. This is a top-level export, separate from `config`:
export const css = ["./assets/dashboard.css"];
```

See [Styling](/docs/concepts/styling) for all CSS approaches including CSS
Modules, preprocessors, and global stylesheets.

## Route Groups

When working with [layouts](/docs/advanced/layouts) or
Expand Down
42 changes: 37 additions & 5 deletions docs/latest/concepts/islands.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,44 @@ import OtherIsland from "../islands/other-island.tsx";
</div>;
```

## Rendering islands on client only
## Styling islands

When using client-only APIs, like `EventSource` or `navigator.getUserMedia`, the
component would error during server-side rendering. Use the `IS_BROWSER`
constant from `fresh/runtime` to guard browser-only code. It is `false` on the
server and `true` in the browser:
Islands can import CSS files just like any other module. CSS Modules
(`*.module.css`) are the recommended approach for scoped styles. Fresh
automatically collects CSS from each island's module graph and injects it as
`<link>` tags during server rendering, so styles are available before hydration.

See [Styling](/docs/concepts/styling) for details on CSS Modules, global
stylesheets, and other approaches.

## Client-only islands

Some libraries (e.g. Monaco Editor, certain charting libraries) reference
browser globals like `document` at the module top level, which crashes during
server-side rendering. You can mark an island as **client-only** by adding
`export const clientOnly = true`. Fresh will skip executing the component on the
server and render an empty placeholder instead. On the client, the component
renders normally.

```tsx islands/my-editor.tsx
export const clientOnly = true;

export default function MyEditor() {
// Safe to use document, window, etc. — this code never runs on the server.
return <div>{/* ... */}</div>;
}
```

> [warn]: Client-only islands produce no meaningful HTML on the server. This
> means search engines will not see their content, and users will see an empty
> placeholder until JavaScript loads. Use this only when the component truly
> cannot run on the server.

### Using `IS_BROWSER` for a custom fallback

If the module itself can be loaded on the server but you only need to guard
certain API calls, use the `IS_BROWSER` constant from `fresh/runtime` instead.
This lets you return a meaningful SSR fallback:

```tsx islands/my-island.tsx
import { IS_BROWSER } from "fresh/runtime";
Expand Down
3 changes: 3 additions & 0 deletions docs/latest/concepts/static-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import "./assets/styles.css";
- Files **referenced by URL path** (favicon.ico, fonts, robots.txt, PDFs, etc.):
place in `static/`

See [Styling](/docs/concepts/styling) for a complete guide to CSS handling in
Fresh.

> [tip]: Always use root-relative URLs (starting with `/`) when referencing
> static files in HTML. For example, use `src="/image/photo.png"` instead of
> `src="image/photo.png"`. Relative paths resolve against the browser's current
Expand Down
253 changes: 253 additions & 0 deletions docs/latest/concepts/styling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
---
description: |
How to style your Fresh app: global stylesheets, Tailwind CSS, CSS Modules, route-scoped CSS, and preprocessors.
---

Fresh supports several approaches to styling, all powered by
[Vite's CSS handling](https://vite.dev/guide/features#css). Choose the approach
that fits your use case:

| Goal | Approach |
| ---- | -------- |
| Global styles | Import CSS in `client.ts` |
| Utility-first CSS | Tailwind via `@tailwindcss/vite` |
| Scoped component styles | CSS Modules (`*.module.css`) |
| Route-specific styles | `export const css` or side-effect import |
| Preprocessors (SCSS, Less) | Install the npm package and import directly |
| Static stylesheets | Place in `static/`, reference by URL path |
| Inline styles in `<head>` | Use the `<Head>` component |

## Global stylesheets

The most common pattern is importing a CSS file from your `client.ts` entry
point. This makes the styles available on every page.

```css assets/styles.css
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
}
```

```ts client.ts
import "./assets/styles.css";
```

Vite processes this import, applies any configured PostCSS transforms, and:

- In **development**: injects the CSS as inline `<style>` tags with hot module
replacement.
- In **production**: extracts the CSS to a hashed `.css` file served with
long-lived cache headers.

> [info]: Place imported CSS files **outside** the `static/` directory (e.g. in
> `assets/`). Files in `static/` are served as-is and would be duplicated in the
> build output. See [Static files](/docs/concepts/static-files) for details.

## Tailwind CSS

Fresh works with [Tailwind CSS](https://tailwindcss.com/) via the official
`@tailwindcss/vite` plugin:

```ts vite.config.ts
import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
plugins: [fresh(), tailwindcss()],
});
```

```css assets/styles.css
@import "tailwindcss";
```

```ts client.ts
import "./assets/styles.css";
```

Then use Tailwind classes in your components:

```tsx routes/index.tsx
export default function Home() {
return <h1 class="text-4xl font-bold text-blue-600">Hello, Fresh!</h1>;
}
```

## CSS Modules

[CSS Modules](https://vite.dev/guide/features#css-modules) scope class names to
the component that imports them, preventing naming collisions. Any file ending in
`.module.css` is treated as a CSS Module by Vite.

```css islands/Counter.module.css
.counter {
display: flex;
gap: 0.5rem;
align-items: center;
}

.count {
font-variant-numeric: tabular-nums;
min-width: 3ch;
text-align: center;
}
```

```tsx islands/Counter.tsx
import { useSignal } from "@preact/signals";
import styles from "./Counter.module.css";

export default function Counter() {
const count = useSignal(0);
return (
<div class={styles.counter}>
<button onClick={() => count.value--}>-</button>
<span class={styles.count}>{count}</span>
<button onClick={() => count.value++}>+</button>
</div>
);
}
```

CSS Modules work in islands, server-only components, and route files. Fresh
automatically collects the CSS for any island and injects it as a `<link>` tag
during server rendering.

### TypeScript support

Deno's type checker does not natively understand `*.module.css` imports. Add
`vite/client` to your `deno.json` to pick up Vite's ambient types (which cover
CSS Modules, `?url`/`?raw` imports, and `import.meta.env`):

```jsonc deno.json
{
"compilerOptions": {
"types": ["vite/client"]
}
}
```

This declares `*.module.css` imports as `Record<string, string>`, which gives
you autocompletion and type safety for class name lookups. Projects scaffolded
with `deno run -A jsr:@fresh/init` already include this.

## Route-scoped CSS

You can load CSS for a specific route in two ways.

### Side-effect import

Import a CSS file directly in the route module:

```tsx routes/dashboard.tsx
import "./dashboard.css";

export default function Dashboard() {
return <main class="dashboard">...</main>;
}
```

### The `css` export

Export a `css` array with paths to CSS files:

```tsx routes/dashboard.tsx
export const css = ["./assets/dashboard.css"];

export default function Dashboard() {
return <main class="dashboard">...</main>;
}
```

Both approaches scope the CSS to the route — the styles are only loaded when
that route is rendered. See [File routing](/docs/concepts/file-routing) for more
on route exports.

## Preprocessors

Since Fresh uses Vite, you can use CSS preprocessors by installing the
corresponding npm package. No additional Vite plugin is needed.

### SCSS / Sass

```sh
deno install npm:sass
```

```scss assets/theme.scss
$primary: #3b82f6;

.btn-primary {
background-color: $primary;
&:hover {
background-color: darken($primary, 10%);
}
}
```

```ts client.ts
import "./assets/theme.scss";
```

### Less

```sh
deno install npm:less
```

```less assets/theme.less
@primary: #3b82f6;

.btn-primary {
background-color: @primary;
}
```

```ts client.ts
import "./assets/theme.less";
```

Preprocessor files also work with CSS Modules (e.g. `Button.module.scss`) and
route-scoped imports.

## Static stylesheets

For CSS files that should be served without processing, place them in the
`static/` directory and reference them by URL path:

```tsx routes/index.tsx
import { Head } from "fresh/runtime";

export default function Home() {
return (
<>
<Head>
<link rel="stylesheet" href="/legacy.css" />
</Head>
<h1>Hello</h1>
</>
);
}
```

Static files are served with
[`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag)
headers for caching. Use `asset()` for cache-busted URLs with a one-year cache
lifetime.

## How island CSS works

When an island imports CSS (via CSS Modules, side-effect imports, or
preprocessors), Fresh handles it automatically:

1. **During the build**, Vite extracts CSS from each island's module graph into
separate hashed `.css` files.
2. **At runtime**, when the server renders an island, Fresh looks up its
associated CSS and adds it to the page.
3. The CSS is injected as `<link>` tags in `<head>` so styles are available
before the island hydrates.

In development mode, CSS is injected as inline `<style>` tags with hot module
replacement — changes are reflected instantly without a page reload.
1 change: 1 addition & 0 deletions docs/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const toc: RawTableOfContents = {
["context", "Context", "link:latest"],
["signals", "Signals", "link:latest"],
["layouts", "Layouts", "link:latest"],
["styling", "Styling", "link:latest"],
["static-files", "Static files", "link:latest"],
["file-routing", "File routing", "link:latest"],
],
Expand Down
3 changes: 3 additions & 0 deletions packages/fresh/src/build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export class IslandPreparer {
chunkName: string,
modName: string,
css: string[],
clientOnly?: boolean,
) {
const isClientOnly = clientOnly ?? mod.clientOnly === true;
for (const [name, value] of Object.entries(mod)) {
if (typeof value !== "function") continue;

Expand All @@ -117,6 +119,7 @@ export class IslandPreparer {
fn,
name: uniqueName,
css,
clientOnly: isClientOnly,
});
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/fresh/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface Island {
exportName: string;
fn: ComponentType;
css: string[];
clientOnly: boolean;
}

export type ServerIslandRegistry = Map<ComponentType, Island>;
Expand Down
Loading
Loading