Skip to content
8 min readSisyphos UI Contributors

Runtime theming with CSS variables: applyTheme() explained

themingcssarchitecture

Sisyphos UI has no <ThemeProvider>. There is no context to wrap, no styled-components runtime, no Tailwind config to regenerate. The entire theming system is one function — applyTheme() — that writes CSS custom properties to document.documentElement. This post explains the token architecture behind it, how to theme all three framework bindings with the same call, and how to ship dark mode without the dreaded flash of the wrong theme.

The token layer

Every visual decision in the library resolves to a CSS variable defined in the core package's default theme. Components never hard-code a color or a radius; they reference tokens like var(--sisyphos-color-primary) or var(--sisyphos-radius-md). The default palette gives you five semantic colors — primary (orange, #ff7022), success, error, warning, info — plus a neutral ramp for surfaces, borders, and text.

Each semantic color is not a single value but a set of six shade keys with defined jobs:

  • main — the base hue,
  • light — hover states,
  • lighter — subtle backgrounds and the soft variant fill,
  • dark — pressed states,
  • darker — borders and dividers,
  • contained — a dedicated solid tone for variant="contained" buttons.

Beyond color, the theme covers spacing (xxs through 3xl), typography (family, a size scale from xs up to heroXl, weights, line heights), border radius, opacity steps, animation durations, and the z-index tiers for tooltips, pickers, and overlays. All of it is typed:

@sisyphos-ui/core — ThemeConfig (abridged)ts
export interface ThemeConfig {
  colors?: SemanticColors;      // primary | success | error | warning | info
  neutral?: NeutralColors;      // surfaces, borders, text ramp
  spacing?: SpacingScale;       // xxs … 3xl
  typography?: TypographyScale; // fontFamily, sizes, weights, lineHeights
  borderRadius?: BorderRadiusScale;
  opacity?: { xs?: number; s?: number; md?: number; lg?: number };
  duration?: { s?: number | string; m?: number | string };
  zIndex?: { tooltip?: number; pickers?: number; overlay?: number };
}

How components consume the tokens

The other half of the contract lives in the component stylesheets. Every rule references tokens through var() — always with the default baked in as a fallback, so a component stays usable even if the base stylesheet loads late or a variable is accidentally unset. The Switch is representative:

Switch.scss (excerpt)scss
.sisyphos-switch {
  &:focus-visible {
    outline: 2px solid var(--sisyphos-color-primary, #ff7022);
  }
  &.unchecked {
    background-color: var(--sisyphos-color-neutral, #f9fafb);
    border-color: var(--sisyphos-color-border, #c4cdd5);
  }
  &.checked.primary {
    background-color: var(--sisyphos-color-primary, #ff7022);
  }
  &.checked.success {
    background-color: var(--sisyphos-color-success, #22c55e);
  }
}

Notice that the focus ring uses the primary token too. Override your brand color and the checked fill, the focus outline, and every primary-colored control across all 33 components move together — that is the entire value proposition of routing every visual decision through one variable namespace. It also makes theming debuggable with nothing but devtools: inspect <html>, look at the inline style attribute, and you are looking at your applied theme.

What applyTheme() actually does

applyTheme() takes a partial ThemeConfig and writes only the variables you supplied — everything else keeps its default. A color can be a plain string (sets the main shade) or a full shade object. Numbers are converted to px; strings pass through untouched, so rem, clamp(), and oklch() all work:

theme.tsts
import { applyTheme } from "@sisyphos-ui/core";

applyTheme({
  colors: {
    primary: { main: "#7c3aed", light: "#8b5cf6", dark: "#6d28d9" },
    success: "#10b981", // string shorthand → main shade only
  },
  spacing: { md: 20 },                        // → 20px
  borderRadius: { md: "0.625rem" },           // strings pass through
  typography: { fontFamily: "Inter, sans-serif" },
});

Under the hood this is a series of document.documentElement.style.setProperty("--sisyphos-color-primary", …) calls. That has two consequences worth internalizing. First, re-theming is not a re-render: no component updates, no virtual DOM work, no style recalculation beyond what the browser does natively for a custom-property change. Second, the same call works in every framework, because the DOM is the shared substrate — there is no React-specific or Vue-specific theming API to learn.

Core also ships four preset palettes (themes.default, blue, purple, green) and a mergeThemes() helper for layering a tenant override on top of a base:

white-label.tsts
import { applyTheme, mergeThemes, themes } from "@sisyphos-ui/core";

const tenantTheme = mergeThemes(themes.purple, {
  colors: { primary: tenant.brandColor },
  borderRadius: { md: tenant.rounded ? 12 : 4 },
});

applyTheme(tenantTheme);

Wiring it up in React, Vue, and Angular

Because applyTheme() touches document, call it once on the client, as early as possible. Each framework has a natural home for that:

app/theme-init.tsx (React / Next.js)tsx
"use client";
import { useEffect } from "react";
import { applyTheme } from "@sisyphos-ui/core";

export function ThemeInit() {
  useEffect(() => {
    applyTheme({ colors: { primary: "#7c3aed" } });
  }, []);
  return null;
}
// Render <ThemeInit /> once in your root layout.
src/main.ts (Vue 3)ts
import { createApp } from "vue";
import { applyTheme } from "@sisyphos-ui/core";
import "@sisyphos-ui/vue/styles.css";
import App from "./App.vue";

applyTheme({ colors: { primary: "#7c3aed" } });
createApp(App).mount("#app");
app.config.ts (Angular 18)ts
import { type ApplicationConfig, provideAppInitializer } from "@angular/core";
import { applyTheme } from "@sisyphos-ui/core";

export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(() => {
      applyTheme({ colors: { primary: "#7c3aed" } });
    }),
  ],
};

In a Vite-built SPA (the Vue and Angular cases above) the theme call runs before first paint, so there is nothing more to do. Server-rendered apps need one extra step — which brings us to dark mode.

Dark mode without the flash

Mode switching is separate from token overrides. Core exposes setThemeMode("light" | "dark"), getThemeMode(), and toggleThemeMode(), which work by swapping a sisyphos-theme-dark / sisyphos-theme-light class on <html>. The dark class remaps the neutral ramp and surface variables in the stylesheet — your brand overrides from applyTheme() survive the switch, because they live on :root and the mode class only redefines the variables that should change.

The classic failure mode in SSR apps: the server sends light-mode HTML, JavaScript later reads localStorage and flips to dark, and the user sees a white flash. The fix is a tiny inline script in <head> that applies the class before the first paint, synchronously, ahead of your bundle:

index.html / root layout <head>html
<script>
  (function () {
    var stored = localStorage.getItem("theme");
    var dark =
      stored === "dark" ||
      (!stored && matchMedia("(prefers-color-scheme: dark)").matches);
    document.documentElement.classList.add(
      dark ? "sisyphos-theme-dark" : "sisyphos-theme-light"
    );
  })();
</script>

Later, your toggle button just calls toggleThemeMode() and mirrors the result into localStorage. Because the source of truth is a class on <html>, the inline script and the runtime helpers can never disagree about what “current mode” means.

SSR for brand tokens: generateThemeCSS()

The same flash problem applies to brand overrides: if your tenant's primary color is applied in a useEffect, the default orange paints first. For that, core provides generateThemeCSS(), which turns a ThemeConfig into a static :root { … } block you can render on the server:

app/layout.tsx (Next.js)tsx
import { generateThemeCSS } from "@sisyphos-ui/core";

const themeCss = generateThemeCSS({
  colors: { primary: "#7c3aed" },
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <style dangerouslySetInnerHTML={{ __html: themeCss }} />
      </head>
      <body>{children}</body>
    </html>
  );
}

The generated CSS ships in the initial HTML, so the correct brand color is there from the first byte. You can still call applyTheme() afterwards for user-driven changes — inline styles set by setProperty win over the static block, so the two mechanisms compose cleanly.

Scoped overrides and the fine print

Because everything is a custom property, per-subtree theming falls out for free: set the same variable on any container element (style="--sisyphos-color-primary: #0ea5e9") and every component inside inherits it, cascade rules intact. This is the cheapest way to give a marketing section or an embedded widget its own accent without forking the theme.

Two honest caveats. applyTheme() validates structure through TypeScript, but not values — { primary: "#definitely-not-a-color" } compiles fine and silently produces an invalid property. And no runtime can check contrast for you: if you override lightershades, verify your text still clears WCAG ratios. The system hands you the levers; which colors are legible is still your call. Everything else — propagation, specificity, SSR, and the dark-mode handshake — is the platform's job, which is exactly why the whole theming engine fits in one file and needs no provider at all.