Tutorial 23 of 36 · for anyone adding a dark mode · Markdown

Add light and dark themes

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 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 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#

$ fsr new tones --with react
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#

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 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:

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:

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:

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:

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:

app/routes/layout.tsx
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#

$ 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:

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

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

<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:

app/styles/app.css
@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:

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:

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:

themes    default, sunset
theme     routes/page.tsx#default            sunset

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

<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.