Some features documented here might be available only with `dev` version tag in NPM

Theming

Salty CSS themes are plain CSS custom properties scoped to a parent selector. You declare two (or more) value sets under defineVariables' conditional scope, flip an attribute on an ancestor element (usually <html>), and every consumer of those tokens updates instantly.

That means no theme provider, no React context, no <ThemeProvider> wrapper, no re-render on switch, and no flash on hydration (with the inline-script pattern lower on this page). The same mechanism powers dark mode, high-contrast modes, brand themes — anything you can name with an attribute.

1. Declare the themes

Group your themed tokens under conditional.theme (the group name is yours to pick; theme is conventional). Each child key is one mode, and each mode declares the same set of token names.

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

export const themes = defineVariables({
  conditional: {
    theme: {
      dark: {
        background: "{colors.black}",
        altBackground: "{colors.altBlack}",
        terminalBackground: "{colors.terminalBlack}",
        color: "{colors.white}",
        altColor: "{colors.altWhite}",
        highlight: "{colors.highlight}",
      },
      light: {
        background: "{colors.white}",
        altBackground: "{colors.altWhite}",
        terminalBackground: "{colors.terminalWhite}",
        color: "{colors.black}",
        altColor: "{colors.altBlack}",
        highlight: "{colors.highlight}",
      },
    },
  },
});

The tokens reference static variables defined elsewhere ({colors.black}, {colors.white}, …) so the palette stays in one place. You're free to inline raw values too.

2. Consume the tokens

Reference them with the {theme.xxx} path — the group name becomes the namespace:

import { styled } from "@salty-css/react/styled";

export const Surface = styled("section", {
  base: {
    background: "{theme.background}",
    color: "{theme.color}",
    borderColor: "{theme.altBackground}",
  },
});

You can use the same tokens inside defineGlobalStyles, defineTemplates, className, the color() helper — anywhere a Salty style accepts a value.

3. Activate a theme

Set the matching attribute on an ancestor. The attribute name mirrors the group key (theme) and the value mirrors the mode key (dark or light):

<html data-theme="dark">
  ...
</html>

That's it — every element under <html data-theme="dark"> now reads the dark values. Switching to data-theme="light" flips them instantly. Salty CSS emits the underlying rules (roughly [data-theme="dark"] { ... }) for you; you only need to set the attribute.

4. Wire up a toggle

A theme toggle is plain DOM work — read/write the attribute. Here's a React version that persists the choice in localStorage and respects the user's OS setting on first load:

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

import { useEffect, useState } from "react";

type Theme = "dark" | "light";

const STORAGE_KEY = "theme";

const readInitial = (): Theme => {
  if (typeof window === "undefined") return "light";
  const saved = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
  if (saved === "dark" || saved === "light") return saved;
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
};

export const ThemeToggle = () => {
  const [theme, setTheme] = useState<Theme>("light");

  useEffect(() => {
    const initial = readInitial();
    setTheme(initial);
    document.documentElement.dataset.theme = initial;
  }, []);

  const toggle = () => {
    const next: Theme = theme === "dark" ? "light" : "dark";
    setTheme(next);
    document.documentElement.dataset.theme = next;
    window.localStorage.setItem(STORAGE_KEY, next);
  };

  return (
    <button type="button" onClick={toggle} aria-label="Toggle theme">
      {theme === "dark" ? "☀ Light" : "☾ Dark"}
    </button>
  );
};

To avoid a flash of the wrong theme on first paint in Next.js, inline a tiny pre-hydration script in app/layout.tsx (or _document.tsx for the Pages Router) so the attribute is set before React hydrates:

// /app/layout.tsx
const setThemeScript = `
  try {
    var t = localStorage.getItem("theme");
    if (t !== "dark" && t !== "light") {
      t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
    }
    document.documentElement.dataset.theme = t;
  } catch (e) {}
`;

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <script dangerouslySetInnerHTML= />
      </head>
      <body>{children}</body>
    </html>
  );
}

Picking up the OS preference

The browser exposes the user's color-scheme preference via the prefers-color-scheme media query. Salty's fluent media builder gives you media.dark and media.light for this — no manual string interpolation.

Define named queries you can reuse in styles:

// /styles/media.css.ts
import { defineMediaQuery } from "@salty-css/react/config";

export const prefersDark = defineMediaQuery((media) => media.dark);
export const prefersLight = defineMediaQuery((media) => media.light);

Then choose how to honour the preference. There are two viable patterns and they aren't mutually exclusive:

Honour the OS preference, no user toggle. Apply data-theme="dark" once on <html> when matchMedia('(prefers-color-scheme: dark)') matches, and subscribe to its change event so the page tracks the OS:

// /components/auto-theme.ts
"use client";
import { useEffect } from "react";

export function AutoTheme() {
  useEffect(() => {
    const query = window.matchMedia("(prefers-color-scheme: dark)");
    const apply = (matches: boolean) => {
      document.documentElement.dataset.theme = matches ? "dark" : "light";
    };
    apply(query.matches);
    query.addEventListener("change", (event) => apply(event.matches));
    return () => query.removeEventListener("change", apply as any);
  }, []);
  return null;
}

Honour OS preference until the user overrides it. This is what the toggle snippet above does: read localStorage first, fall back to matchMedia if nothing is stored, and write to localStorage on every toggle. Combine that with the inline pre-hydration script (next section) for a no-flash experience.

If you'd rather use the named query in styles directly, both prefersDark and prefersLight are valid keys in any style object — "@prefersDark": { ... } works inline.

Animations that respect reduced motion

Pair theming with the reducedMotion media query so users who've requested less motion don't get a jarring transition when the theme flips:

import { styled } from "@salty-css/react/styled";

export const Surface = styled("section", {
  base: {
    background: "{theme.background}",
    color: "{theme.color}",
    transition: "background 200ms ease, color 200ms ease",

    "@media (prefers-reduced-motion: reduce)": {
      transition: "none",
    },
  },
});

Deriving shades from themed tokens

The color() helper accepts token references, so you can derive hover, focus, and disabled shades from a single themed token instead of declaring every variation manually:

import { styled } from "@salty-css/react/styled";
import { color } from "@salty-css/core/helpers";

export const Button = styled("button", {
  base: {
    background: "{theme.background}",
    color: "{theme.color}",

    "&:hover": {
      background: color("{theme.background}").lighten(0.05),
    },

    "&:disabled": {
      color: color("{theme.color}").alpha(0.5),
    },
  },
});

Note: color() works with static token references. For values that already change at runtime (conditional or responsive tokens), the derivation is computed at build time against the value's compile-time form — useful for tinting a themed color, but be mindful that the derivation isn't recomputed when the theme flips. For dynamic, runtime-derived shades, declare the variants explicitly in your conditional group.

Multiple, independent groups

Conditional groups are independent. Add a density group alongside theme and toggle the two attributes separately:

defineVariables({
  conditional: {
    theme: {
      dark: { background: "#0a0a0a" },
      light: { background: "#fafafa" },
    },
    density: {
      compact: { gap: "8px" },
      spacious: { gap: "24px" },
    },
  },
});
<html data-theme="dark" data-density="compact">
  ...
</html>

The two attributes compose freely — four combinations from two groups, with no extra config.

Avoiding the flash on first paint

When you defer the theme decision to client-side JS, users can see a brief flash of the default theme before the toggle script runs. The fix is to apply the attribute as early as possible. In Next.js App Router, render a small inline script in layout.tsx that reads localStorage and sets the attribute on <html> before the body hydrates. The snippet above includes that pattern.

See also