Version 0.1.0 just released! Check out the release notes from GitHub Releases

How Salty CSS Eliminates ThemeProvider Dependencies with Native CSS Variables and Server Component Compatibility

Provider-less Theming & RSC

The introduction of React Server Components (RSC) and server-first meta-frameworks like Astro fundamentally broke the traditional CSS-in-JS theming model. For years, the industry relied on React Context and <ThemeProvider> wrappers to distribute design tokens down the component tree. In a server-rendered world, that architecture is a liability.

Salty CSS abandons runtime context providers entirely. By shifting the responsibility of theme resolution to the browser's native CSS engine, Salty makes your application inherently compatible with Server Components, eliminates theme-related re-renders, and allows for seamless cross-framework island hopping.

The Problem with React Context

If your styling library requires a <ThemeProvider> at the root of your application, it forces that boundary to be a client component. Because Server Components cannot consume React Context, legacy theming methods force developers to add "use client" directives to massive portions of their application. This pushes rendering work back to the browser and negates the performance benefits of a server-first architecture.

Furthermore, context-based theming requires React to track state changes. When a user toggles from light to dark mode, the provider updates, triggering a cascading re-render across every styled component in the tree.

Salty CSS views theming as a CSS concern, not a JavaScript state concern.

The Architecture: Native CSS Variables

Salty CSS moves theming out of the JavaScript runtime. Themes are constructed using conditional variable grouping, which maps design tokens to parent selectors (like data-theme="dark").

export const themes = defineVariables({
  conditional: {
    theme: {
      light: { background: "{colors.white}", text: "{colors.black}" },
      dark: { background: "{colors.black}", text: "{colors.white}" },
    },
  },
});

When the compiler processes your configuration, it emits these themes as native CSS Custom Properties. Crucially, regardless of whether you configure your importStrategy to bundle component CSS globally or split it per-component, variables and templates are always emitted to the global stylesheet. This guarantees that your design tokens are universally available before any component renders.

Because theme resolution relies entirely on the browser's CSS engine, flipping a theme is as simple as updating an HTML attribute:

document.documentElement.dataset.theme = "dark";

This triggers zero React re-renders. The DOM node's attribute changes, and the browser repaints the affected elements using the highly optimized native CSS variable cascade.

Framework Agnosticism & Astro Islands

This provider-less architecture is a massive advantage for Astro developers utilizing the Islands architecture.

In Astro, your page might consist of static HTML, a React island, and a Vue island. If you rely on a React-specific <ThemeProvider>, your design tokens cannot easily bridge the gap to the Vue components or the raw .astro markup.

With Salty CSS, the data-theme attribute sits on the <html> element. The CSS Custom Properties cascade naturally down the DOM tree, penetrating every island automatically. A React component and a raw Astro component can both reference var(--theme-background) and receive the correct value simultaneously, with zero framework-specific bridges required. If the component is purely static, the styles are resolved via SSR, and the styled-client runtime is completely excluded from the browser payload.

FOUC Prevention and State Management

Because themes are resolved via HTML attributes, preventing a Flash of Unstyled Content (FOUC) becomes a matter of ensuring the correct attribute is present before the browser's first paint.

1. The Zero-JS Approach (Pure CSS)

If you do not need a user-facing toggle and simply want to respect the operating system's preference, you can skip data attributes entirely. By defining tokens as responsive variables keyed to the prefers-color-scheme media query, the browser handles the swap natively. No JavaScript is required, and FOUC is physically impossible.

2. Server-Side Rendering (Best Practice)

For SaaS applications or sites requiring a user toggle, the optimal approach is storing the user's preference in a database or an HTTP cookie. Because cookies are sent with the initial request, your server framework (like Next.js or Astro) can read the preference and inject the correct data-theme attribute into the HTML string before it leaves the server. This guarantees a perfect first paint.

3. Pre-Hydration Inline Scripts

If you are building a statically generated site (SSG) or a pure SPA where SSR is unavailable, you must inject a synchronous inline <script> into the <head>. This script reads localStorage and appends the data-theme attribute before the DOM body parses, preventing the flash.

Independent Themes & Composition

Salty CSS doesn't restrict you to a binary Light/Dark mode. The architecture supports multiple, independent theme axes natively.

You might have a site built with design-led sections (e.g., a dark hero block, a light content body, and a brand-colored footer). Because themes are just data attributes, you can nest them directly in your markup without writing JavaScript:

<main data-theme="light">
  <section data-theme="dark">...</section>
</main>

You can also overlay completely independent axes, such as a user-selected density preference:

<html data-theme="dark" data-density="compact"></html>

Because the browser resolves CSS variables contextually, it handles the permutations effortlessly without generating exponential amounts of CSS classes.

The Build-Time Color Boundary

Salty CSS includes a powerful build-time color() function that allows you to manipulate values (e.g., color('#000').alpha(0.5)). However, a common mistake is attempting to pass dynamic theme variables into this function.

Because color() executes during the compilation phase, it cannot manipulate a value that is only known at runtime in the browser. You cannot write color('{theme.background}').darken(0.1) because {theme.background} isn't a color—it's a reference to a CSS variable that will swap dynamically.

The Solution: Plan your theme tokens ahead of time. Instead of deriving shades on the fly inside your component files, define explicit interaction tokens within your theme configuration:

export const themes = defineVariables({
  conditional: {
    theme: {
      light: {
        buttonBase: "{colors.blue.500}",
        buttonHover: "{colors.blue.600}",
      },
      dark: {
        buttonBase: "{colors.blue.400}",
        buttonHover: "{colors.blue.300}",
      },
    },
  },
});

By structuring your design system deliberately, you maintain the flexibility of dynamic theming without sacrificing the performance benefits of zero-runtime styling.