Version 0.4.0 now released! See the release notes on GitHub Releases

Theme toggle

A dark mode toggle is usually where styling libraries start shipping runtime: a provider at the root, context, a re-render on every switch. With Salty CSS both themes are compiled into one stylesheet, keyed off a data-theme attribute. The toggle is an attribute flip — the browser does the rest through the native variable cascade.

Declare the tokens

The palette goes in as static atoms. The themed values are molecules in a conditional group: the same token names in both themes, different atoms behind them.

styles/variables.css.ts
import { defineVariables } from "@salty-css/core/factories";

export default defineVariables({
  // Atoms — the fixed palette.
  colors: {
    white: "#ffffff",
    ink: "#111111",
    night: "#0b0d12",
    mist: "#f5f5f5",
    blue: { base: "#0070f3", light: "#7aa7ff" },
  },
  // Molecules — what components read, flipped by data-theme.
  conditional: {
    theme: {
      light: {
        bg: "{colors.white}",
        text: "{colors.ink}",
        accent: "{colors.blue.base}",
      },
      dark: {
        bg: "{colors.night}",
        text: "{colors.mist}",
        accent: "{colors.blue.light}",
      },
    },
  },
});

Use them in a component

Any React component reads the molecules, never the atoms. That one indirection is what lets it follow the theme without being touched.

components/panel.css.ts
import { styled } from "@salty-css/react/styled";

export const Panel = styled("section", {
  base: {
    background: "{theme.bg}",
    color: "{theme.text}",
    borderLeft: "4px solid {theme.accent}",
    padding: "2rem",
    transition: "background 0.2s ease, color 0.2s ease",
  },
});

Flip the attribute

A conditional group has no base — with no data-theme on an ancestor, the {theme.*} tokens resolve to nothing. So the markup always ships a default, and a small inline script in the <head> swaps it for the saved choice or the OS preference before the first paint.

app/layout.tsx
const themeScript = `
  const saved = localStorage.getItem("theme");
  const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
  document.documentElement.dataset.theme = saved ?? (prefersDark ? "dark" : "light");
`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // The script changes data-theme before hydration, so React is told not to warn.
    <html lang="en" data-theme="light" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML=undefined />
      </head>
      <body>{children}</body>
    </html>
  );
}

The toggle itself reads the current attribute, flips it and saves the choice. There is no theme state to keep in sync — the attribute is the state.

components/theme-toggle.tsx
"use client";

// Only this button is a client component — the layout and pages stay on the server.
export const ThemeToggle = () => {
  const toggle = () => {
    const root = document.documentElement;
    const next = root.dataset.theme === "dark" ? "light" : "dark";
    root.dataset.theme = next;
    localStorage.setItem("theme", next);
  };

  return (
    <button type="button" onClick={toggle}>
      Toggle theme
    </button>
  );
};

What ships to the browser

The compiler writes both themes into the global stylesheet at build time — one block per [data-theme] value — and the component rule only ever reads the variables:

Example
:root {
  --colors-white: #ffffff;
  --colors-night: #0b0d12;
  /* …the rest of the palette */
}

[data-theme="light"] {
  --theme-bg: var(--colors-white);
  --theme-text: var(--colors-ink);
}
[data-theme="dark"] {
  --theme-bg: var(--colors-night);
  --theme-text: var(--colors-mist);
}

.panel_hashed {
  background: var(--theme-bg);
  color: var(--theme-text);
}

Flipping the attribute changes which block wins, and the browser repaints through the cascade. No provider, no context, no re-render, and nothing from Salty CSS runs on the client to make it happen.

Gotchas

  • First-paint flash. The script has to be inline and synchronous in the <head>. Setting the theme in an effect or a deferred script runs after paint, and the page flashes the default theme first.
  • OS preference. prefers-color-scheme only seeds the first visit. Once the user picks a theme, the saved choice always wins over the OS.
  • Server-rendered default. The server can’t read localStorage, so it renders the default theme and the script corrects it on the client. suppressHydrationWarning on the root <html> keeps React quiet about the changed attribute.