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
6 changes: 6 additions & 0 deletions docs/src/content/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ Here's an example of how to use the `toggleMode` function to toggle the mode:
For additional information and configuration, please refer to the [API reference](/docs/api-reference/mode-watcher).

</Steps>

## CSR/SPA/SSG Users

If you're using SvelteKit with `ssr: false`, `adapter-static` with a fallback page, or any other client-side rendered setup, you may experience a flash of unstyled content (FOUC) before mode-watcher hydrates.

See the [CSR/SPA FOUC Prevention](/docs/guides/csr-spa-fouc) guide for a simple inline script solution.
105 changes: 105 additions & 0 deletions docs/src/content/guides/csr-spa-fouc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
title: CSR/SPA FOUC Prevention
description: Prevent flash of unstyled content in client-side rendered applications.
section: Guides
---

<script>
import { Callout } from '@svecodocs/kit'
</script>

If you're using mode-watcher in a client-side rendered application (SvelteKit with `ssr: false`, Vite SPA, or any static site), you may experience a flash of unstyled content (FOUC) before mode-watcher hydrates.

## Why This Happens

In CSR apps, JavaScript executes after the initial HTML renders. The browser shows unstyled content (usually light mode) until mode-watcher reads localStorage and applies the theme.

This differs from SSR apps where the server can inject the correct theme before the page reaches the browser.

## Solution

Add this script to your `<head>` **before** any stylesheets:

```html
<script>
const mode = localStorage.getItem("mode-watcher-mode") ?? "system";
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = mode === "dark" || (mode === "system" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
</script>
```

This script:

- Runs synchronously before first paint
- Reads the same localStorage key mode-watcher uses
- Handles `light`, `dark`, and `system` modes
- Sets both the `dark` class and `color-scheme` property

## Customizing the Default

Change the fallback value if your app defaults to a specific theme:

```javascript
// Default to dark mode for new users
const mode = localStorage.getItem("mode-watcher-mode") ?? "dark";
```

## Why Not CSS-Only?

A CSS-only approach using `@media (prefers-color-scheme: dark)` only respects the OS preference, not the user's stored choice.

**Failure case:**

1. User's OS is set to light mode
2. User explicitly chooses dark mode in your app (stored in localStorage)
3. On reload: CSS sees "light" OS preference, shows light background
4. mode-watcher hydrates, reads "dark" from localStorage, switches to dark
5. User sees a light-to-dark flash

The JavaScript approach reads localStorage directly, respecting the user's explicit choice.

## SvelteKit with adapter-static

For SvelteKit apps using `adapter-static` with `ssr: false`:

### 1. Add the script to `src/app.html`

```html title="src/app.html"
<!doctype html>
<html lang="en">
<head>
<script>
const mode = localStorage.getItem("mode-watcher-mode") ?? "system";
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = mode === "dark" || (mode === "system" && prefersDark);
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```

### 2. Common Pitfalls

<Callout type="warning">
**Do not** use `nonce="%sveltekit.nonce%"` with prerendered pages. The placeholder isn't replaced during prerendering and will cause CSP failures.
</Callout>

<Callout type="warning">
**Do not** use `hooks.server.ts` for script injection. Server hooks don't run for prerendered pages; they only execute during SSR.
</Callout>

## SSR Applications

If you're using SSR and need CSP compliance, see the [`createInitialModeExpression`](/docs/utilities/create-initial-mode-expression) utility instead. It's designed for server-rendered apps where `hooks.server.ts` runs on each request.

## References

- [SvelteKit CSP nonce limitations with prerendering](https://github.com/sveltejs/kit/issues/13307)
- [Real-world implementation in Epicenter](https://github.com/EpicenterHQ/epicenter/pull/1168)
6 changes: 6 additions & 0 deletions docs/src/content/utilities/create-initial-mode-expression.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ Use `createInitialModeExpression` when:

This approach is ideal for security-sensitive environments or platforms with strict CSP headers, where inline scripts must include a trusted nonce.

<Callout type="info">

**Using CSR/SPA/SSG?** This utility requires server hooks, which don't run for prerendered or client-side rendered pages. See the [CSR/SPA FOUC Prevention](/docs/guides/csr-spa-fouc) guide instead.

</Callout>

## Usage

To use `createInitialModeExpression`, you need two things:
Expand Down
55 changes: 33 additions & 22 deletions docs/src/lib/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
import { defineNavigation } from "@svecodocs/kit";
import ChalkboardTeacher from "phosphor-svelte/lib/ChalkboardTeacher";
import RocketLaunch from "phosphor-svelte/lib/RocketLaunch";
import NoteBlank from "phosphor-svelte/lib/NoteBlank";
import Tag from "phosphor-svelte/lib/Tag";
import { getAllDocs } from "./utils.js";
import { defineNavigation } from '@svecodocs/kit';
import ChalkboardTeacher from 'phosphor-svelte/lib/ChalkboardTeacher';
import RocketLaunch from 'phosphor-svelte/lib/RocketLaunch';
import NoteBlank from 'phosphor-svelte/lib/NoteBlank';
import Tag from 'phosphor-svelte/lib/Tag';
import { getAllDocs } from './utils.js';

const allDocs = getAllDocs();

const components = allDocs
.filter((doc) => doc.section === "Components")
.filter((doc) => doc.section === 'Components')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));
const states = allDocs
.filter((doc) => doc.section === "States")
.filter((doc) => doc.section === 'States')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));
const utilities = allDocs
.filter((doc) => doc.section === "Utilities")
.filter((doc) => doc.section === 'Utilities')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));

const testing = allDocs
.filter((doc) => doc.section === "Testing")
.filter((doc) => doc.section === 'Testing')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));

const guides = allDocs
.filter((doc) => doc.section === 'Guides')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
Expand All @@ -36,41 +43,45 @@ const testing = allDocs
export const navigation = defineNavigation({
anchors: [
{
title: "Introduction",
href: "/docs",
title: 'Introduction',
href: '/docs',
icon: ChalkboardTeacher,
},
{
title: "Getting Started",
href: "/docs/getting-started",
title: 'Getting Started',
href: '/docs/getting-started',
icon: RocketLaunch,
},
{
title: "Mode vs Theme",
href: "/docs/mode-vs-theme",
title: 'Mode vs Theme',
href: '/docs/mode-vs-theme',
icon: NoteBlank,
},
{
title: "Releases",
href: "https://github.com/svecosystem/mode-watcher/releases",
title: 'Releases',
href: 'https://github.com/svecosystem/mode-watcher/releases',
icon: Tag,
},
],
sections: [
{
title: "Components",
title: 'Guides',
items: guides,
},
{
title: 'Components',
items: components,
},
{
title: "States",
title: 'States',
items: states,
},
{
title: "Utilities",
title: 'Utilities',
items: utilities,
},
{
title: "Testing",
title: 'Testing',
items: testing,
},
],
Expand Down
Loading