---
title: "Add light and dark themes"
audience: "anyone adding a dark mode"
description: "You'll add light and dark that is correct in the first byte the server sends. A visitor who never chooses gets their system's setting. One who chooses keeps…"
snippet_caption: ""
snippet: ""
---

import { Install } from "@src/ui/Install";

You'll add light and dark that is correct in the first byte the server sends. A visitor who never chooses gets their system's setting. One who chooses keeps it, because the choice lives in the session and the server reads it before it renders.

<Install captured="0.x" />

## Where the flash comes from

The usual dark mode keeps the choice in `localStorage`. The server can't read it, so it renders the default and the page paints. A script then reads storage and switches, so the visitor sees one theme and then the other on every load.

The usual patch is a blocking inline script in the head. Under a Content-Security-Policy with a hash, which is what [450](https://snapfirers.com/fsr/learn/450-set-a-content-security-policy) ends up with, that script is one more hash to keep in step with its own contents.

fsr has the other half built in. An `app/theme.ts` decides the scheme for each request and the host writes the answer on `<html>` as `data-sf-scheme`, before the first byte leaves.

## Put the choice in the session

```shell-session
$ fsr new tones --with react
```

```ts title="app/schemas/session.ts"
export interface Session {
  /** `light`, `dark` or empty to follow the browser. */
  scheme: string;
}

export const defaults: Session = {
  scheme: "",
};

export interface SchemeChoice {
  scheme: string;
}
```

Every visitor has a session whether or not they've signed in. Its cookie arrives with every request. An empty `scheme` means the visitor hasn't chosen.

## Answer it in theme.ts

```ts title="app/theme.ts"
import type { Scheme, ThemeChoice, ThemeCtx } from "@snapfire/fsr";

export function theme({ theme, session }: ThemeCtx): ThemeChoice {
  const scheme = session.scheme === "light" || session.scheme === "dark" ? (session.scheme as Scheme) : null;
  return { name: theme.name, scheme };
}
```

`app/theme.ts` runs for every request after middleware and before the page. It's lowered like a loader. A `scheme` of `light` or `dark` is written on `<html>` as `data-sf-scheme`. `null` writes nothing, which leaves it to the browser's `prefers-color-scheme`. `name` is the named theme, a palette on top of the scheme that a route can set; here it passes the route's own through. [The section below](#add-a-named-theme) adds one.

## Write the CSS against the attribute

Replace the two blocks at the top of `app/styles/app.css`, the `:root` one and the `@media` one, with these three:

```css title="app/styles/app.css"
:root {
  color-scheme: light;
  --ink: #16181d;
  --paper: #fbfbfd;
  --line: #e3e4e8;
}

[data-sf-scheme="dark"] {
  color-scheme: dark;
  --ink: #e8e9ed;
  --paper: #14161a;
  --line: #2a2d34;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-sf-scheme]) {
    color-scheme: dark;
    --ink: #e8e9ed;
    --paper: #14161a;
    --line: #2a2d34;
  }
}
```

A chosen scheme matches the attribute. With no attribute the media query decides, so a first visit follows the system. Every rule below reads `var(--ink)` and never a hex literal, so it follows whichever block applies.

## Add the toggle

The action saves the choice:

```ts title="app/routes/actions.ts"
import { action, fail } from "@snapfire/fsr";
import type { ActionCtx } from "@snapfire/fsr";
import type { SchemeChoice } from "@schemas/session";

export const setScheme = action(async ({ input, session }: ActionCtx<SchemeChoice>) => {
  if (!["", "light", "dark"].includes(input.scheme)) fail("invalid", "not a scheme");
  session.scheme = input.scheme;
  return { scheme: input.scheme };
});
```

The button flips the attribute at once, so the page switches before the action is called. Then it saves the choice:

```tsx title="app/src/ui/SchemeToggle.tsx"
import { actions } from "@generated/client";

export function SchemeToggle() {
  return (
    <button
      className="scheme-toggle"
      onClick={async () => {
        const root = document.documentElement;
        const now = root.getAttribute("data-sf-scheme") ?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
        const next = now === "dark" ? "light" : "dark";
        root.setAttribute("data-sf-scheme", next);
        await actions.$root.setScheme({ scheme: next });
      }}
    >
      <span className="to-dark">Dark</span>
      <span className="to-light">Light</span>
    </button>
  );
}
```

With no attribute only the browser knows which scheme is showing, so the button asks `matchMedia` for the starting point. Its label is CSS for the same reason. Add these at the end of `app/styles/app.css`:

```css title="app/styles/app.css"
.bar {
  display: flex;
  justify-content: space-between;
}

.to-light,
[data-sf-scheme="dark"] .to-dark {
  display: none;
}

[data-sf-scheme="dark"] .to-light {
  display: inline;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-sf-scheme]) .to-dark {
    display: none;
  }

  :root:not([data-sf-scheme]) .to-light {
    display: inline;
  }
}
```

Put it in the header of `app/routes/layout.tsx`:

```tsx title="app/routes/layout.tsx" {2,11}
import { Link, type Children } from "@snapfire/fsr-authoring/template";
import { SchemeToggle } from "@src/ui/SchemeToggle";

export default function Layout({ children }: { children: Children }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          tones
        </Link>
        <SchemeToggle />
      </header>
      <main className="content">{children}</main>
    </div>
  );
}
```

The button's markup is rendered by the server like the rest of the header. Only its click handler runs in the browser.

## Check it

```shell-session
$ fsr dev tones/app
```

Open `http://127.0.0.1:3000` and view the page source. Before you choose, the first line has no scheme:

```text
<html lang="en" data-sf-locale="en">
```

The page follows your system. Click the button, then view the source again:

```text
<html lang="en" data-sf-locale="en" data-sf-scheme="light">
```

That attribute was written by the server, in the first byte of the document. Reload as often as you like and every load paints in the scheme you chose.

## Add a named theme

Light and dark are the scheme. A named theme is a palette a route chooses. The scheme still applies on top of it. A theme overrides tokens the root defines, so the root's tokens go in the `theme.root` cascade layer and the scheme's in `theme`, which outranks every theme. Replace the three blocks at the top of `app/styles/app.css` with:

```css title="app/styles/app.css" {1,8,10,26}
@layer theme.root {
  :root {
    color-scheme: light;
    --ink: #16181d;
    --paper: #fbfbfd;
    --line: #e3e4e8;
  }
}

@layer theme {
  [data-sf-scheme="dark"] {
    color-scheme: dark;
    --ink: #e8e9ed;
    --paper: #14161a;
    --line: #2a2d34;
  }

  @media (prefers-color-scheme: dark) {
    :root:not([data-sf-scheme]) {
      color-scheme: dark;
      --ink: #e8e9ed;
      --paper: #14161a;
      --line: #2a2d34;
    }
  }
}
```

A theme is a file under `app/styles/themes/`, named by the file, that sets some of those tokens:

```css title="app/styles/themes/sunset.css"
:root {
  --paper: #fff7ed;
  --line: #fed7aa;
}
```

A route picks it with an export. Add this to the top of `app/routes/page.tsx`:

```tsx title="app/routes/page.tsx"
export const theme = "sunset";
```

The build writes the theme as `[data-sf-theme="sunset"] { --paper: #fff7ed; --line: #fed7aa; }` in the `theme.named` layer and `fsr build --verbose` lists it:

```shell-session
themes    default, sunset
theme     routes/page.tsx#default            sunset
```

The host writes the route's theme on `<html>` with the scheme:

```text
<html lang="en" data-sf-locale="en" data-sf-theme="sunset" data-sf-scheme="light">
```

In light the home page has sunset's warm paper. Click the button and dark's paper wins, because the scheme sits in `theme` itself and a rule there outranks everything in its sublayers. The build fails and says which line to write when a theme sets a token the root doesn't define. It fails the same way when the root is left outside `@layer theme.root`.

## Two tokens for an accent

Keep the accent as two tokens even when they look like one colour. A brand colour bright enough to sit behind white text is usually too light to *be* text on a light background: white on `#f97316` is 2.8:1 and the same orange as a link on `#f0f4f7` is 2.53:1, both under the 4.5:1 that normal text needs. A token for the accent as a surface and another for the accent as text keep both readable in both schemes.

## Try it

Clear the site's cookies and reload. The session is new, `scheme` is empty and the page follows your system again. Switch your system between light and dark and the page follows it live, since that path is only the media query.

Then make the site dark unless the visitor picks light, the way this site does: change the `null` in `theme.ts` to `"dark"`. A first visit is dark whatever the system says. The media query block no longer applies, because the attribute is always there.

Next up: [370. Accept a file](https://snapfirers.com/fsr/learn/370-accept-a-file).
