Kosmesis
Guide

Theming

How Kosmesis's design tokens work in both style systems, and how to switch themes at runtime with @praxisjs/css.

How theming works

Every Kosmesis component reads color, radius, and spacing from a shared set of tokens — background, foreground, primary, secondary, muted, accent, destructive, border, input, ring, sidebar, and their -foreground counterparts — instead of hardcoding values. Both style systems declare the exact same token names, so a project looks identical the moment kosmesis init finishes, no matter which one was chosen. Only how those tokens reach the DOM differs.

Default token values

These are the values kosmesis init writes for both style systems — identical either way, just expressed as CSS custom properties (Tailwind) or as LightTheme/DarkTheme class fields (@praxisjs/css). All colors are oklch.

TokenLightDark
radius0.625rem(same)
backgroundoklch(1 0 0)oklch(0.145 0 0)
foregroundoklch(0.145 0 0)oklch(0.985 0 0)
cardoklch(1 0 0)oklch(0.205 0 0)
card-foregroundoklch(0.145 0 0)oklch(0.985 0 0)
popoveroklch(1 0 0)oklch(0.205 0 0)
popover-foregroundoklch(0.145 0 0)oklch(0.985 0 0)
primaryoklch(0.205 0 0)oklch(0.922 0 0)
primary-foregroundoklch(0.985 0 0)oklch(0.205 0 0)
secondaryoklch(0.97 0 0)oklch(0.269 0 0)
secondary-foregroundoklch(0.205 0 0)oklch(0.985 0 0)
mutedoklch(0.97 0 0)oklch(0.269 0 0)
muted-foregroundoklch(0.556 0 0)oklch(0.708 0 0)
accentoklch(0.97 0 0)oklch(0.269 0 0)
accent-foregroundoklch(0.205 0 0)oklch(0.985 0 0)
destructiveoklch(0.577 0.245 27.325)oklch(0.704 0.191 22.216)
destructive-foregroundoklch(0.985 0 0)oklch(0.985 0 0)
borderoklch(0.922 0 0)oklch(1 0 0 / 10%)
inputoklch(0.922 0 0)oklch(1 0 0 / 15%)
ringoklch(0.708 0 0)oklch(0.556 0 0)
sidebaroklch(0.985 0 0)oklch(0.205 0 0)
sidebar-foregroundoklch(0.145 0 0)oklch(0.985 0 0)
sidebar-primaryoklch(0.205 0 0)oklch(0.488 0.243 264.376)
sidebar-primary-foregroundoklch(0.985 0 0)oklch(0.985 0 0)
sidebar-accentoklch(0.97 0 0)oklch(0.269 0 0)
sidebar-accent-foregroundoklch(0.205 0 0)oklch(0.985 0 0)
sidebar-borderoklch(0.922 0 0)oklch(1 0 0 / 10%)
sidebar-ringoklch(0.708 0 0)oklch(0.556 0 0)

These are starting values, not a fixed contract — edit LightTheme/DarkTheme (or the :root/.dark blocks in your stylesheet) directly. Nothing about the CLI or the components depends on the specific numbers above, only on the token names existing.

Tailwind

kosmesis init writes the tokens as plain CSS custom properties into your global stylesheet — a :root { --background: ...; } block for light values, a .dark { --background: ...; } block for dark ones — plus a @theme inline block that maps each one into Tailwind v4's color scale (--color-background: var(--background)), which is what makes bg-background, text-foreground, etc. resolve.

Dark mode is nothing more than a .dark class on an ancestor element (typically <html>). Kosmesis doesn't ship any JavaScript to toggle it — flip the class however your project already handles that (a checkbox bound to classList.toggle("dark", ...), prefers-color-scheme, a persisted preference, anything).

@praxisjs/css

kosmesis init writes a theme module instead (default path src/lib/kosmesis-theme.ts) declaring:

  • KosmesisTokens — a TokenSheet skeleton listing every token name, with no values. Reading a static property off it or off anything wrapped in tokenVars() returns a CSS variable reference: KosmesisTokens.primary"var(--primary)".
  • LightTheme — a concrete class extending KosmesisTokens that assigns every token an actual value.
  • DarkTheme — extends LightTheme and overrides only what changes between the two.

Every component's Stylesheet reads tokens through tokenVars(KosmesisTokens), never a raw "var(--primary)" string:

import { tokenVars } from "@praxisjs/css";
import { KosmesisTokens } from "@/lib/kosmesis-theme";

const t = tokenVars(KosmesisTokens);

class ButtonStyles extends Stylesheet {
  $root = this.css({ background: t.primary, color: t.primaryForeground });
}

Renaming a token is a type error everywhere it's referenced, not a silent runtime breakage — the whole reason this indirection exists instead of writing "var(--primary)" by hand.

Unlike the Tailwind stylesheet, nothing here is static CSS on disk yet — LightTheme's values only become real :root custom properties once something applies the theme at runtime. That's what @Themed and theme() do, covered next.

Switching themes with @praxisjs/css

This section only applies to styleSystem: "praxisjs-css" projects — Tailwind projects toggle the .dark class directly and don't need a runtime API for it.

Wiring it up once

kosmesis init prints a reminder to add @Themed to your root component, above @Component():

import { Themed } from "@praxisjs/css";
import { Component } from "@praxisjs/decorators";
import { StatefulComponent } from "@praxisjs/core";
import { KosmesisTokens, LightTheme } from "@/lib/kosmesis-theme";

@Themed(KosmesisTokens, LightTheme, { persist: true, syncTabs: true })
@Component()
class App extends StatefulComponent {
  render() {
    return <YourApp />;
  }
}

@Themed installs a single app-wide ThemeInstance, injects LightTheme's values onto :root as CSS custom properties, and reads two options:

  • persist — saves the active theme's values to localStorage and restores them on the next page load, instead of always starting from LightTheme.
  • syncTabs — broadcasts theme changes to every other open tab via BroadcastChannel, so switching themes in one tab updates all the others live.

Switching at runtime

Call theme().switch(...) from anywhere, or read the singleton through the @Theme() field decorator:

import { theme } from "@praxisjs/css";
import { Button } from "@/components/ui/button";
import { DarkTheme, LightTheme } from "@/lib/kosmesis-theme";

@Component()
class ThemeToggle extends StatefulComponent {
  render() {
    const isDark = theme().current instanceof DarkTheme;
    return (
      <Button onClick={() => theme().switch(isDark ? LightTheme : DarkTheme)}>
        {isDark ? "Light" : "Dark"} mode
      </Button>
    );
  }
}
// Equivalent, using the field decorator instead of the theme() function directly:
import { Theme, type ThemeInstance } from "@praxisjs/css";

@Component()
class ThemeToggle extends StatefulComponent {
  @Theme() theme!: ThemeInstance;

  render() {
    return <Button onClick={() => this.theme.switch(DarkTheme)}>Dark</Button>;
  }
}

switch() instantiates the given theme class, recomputes every token's CSS variable, re-applies them to :root, and — per whatever @Themed was configured with — persists the result to localStorage and broadcasts it to other tabs. Any class that extends the same TokenSheet skeleton works, so a project isn't limited to just LightTheme/DarkTheme — add a third theme class and switch() to it the same way.

On this page