Themes

A theme is a named ThemeDefinition: an id, a token map, and an optional colorScheme ("light" or "dark"). The controller registers one or more themes and resolves which one is active from mode, system preference, and the current theme id.

Overview

Themes are registration units. Mode (light / dark / system) decides which scheme and which of lightThemeId / darkThemeId to prefer. setTheme selects a concrete id and may exit system mode when you pin a theme while mode was system.

When to use

  • Shipping light and dark (or multi-brand) token sets
  • Mapping OS preference to specific theme ids
  • Registering themes at runtime (feature flags, white-label)

When not to use

Installation

See Installation. Presets:

ts
import { lightTheme, darkTheme, lightTokens, darkTokens } from "@sometic/theme/presets";

Usage

Register themes with the controller

ts
import { createThemeController } from "@sometic/theme";
import { lightTheme, darkTheme } from "@sometic/theme/presets";

const theme = createThemeController({
    themes: [lightTheme, darkTheme],
    defaultThemeId: lightTheme.id,
    lightThemeId: lightTheme.id,
    darkThemeId: darkTheme.id,
    mode: "system",
    density: "comfortable",
    direction: "ltr",
});

Custom theme definition

ts
import { defineTokens } from "@sometic/theme/tokens";
import type { ThemeDefinition } from "@sometic/theme";

const oceanTokens = defineTokens({
    color: {
        bg: "#0b1f2a",
        fg: "#e8f4f8",
        primary: "#38bdf8",
        danger: "#f87171",
    },
    space: { 1: "0.25rem", 2: "0.5rem", 3: "0.75rem", 4: "1rem" },
    radius: { sm: "0.25rem", md: "0.5rem", lg: "0.75rem" },
});

const oceanDark: ThemeDefinition = {
    id: "ocean-dark",
    colorScheme: "dark",
    tokens: oceanTokens,
};

Switch mode and theme id

ts
theme.setMode("dark"); // mode dark + themeId → darkThemeId
theme.setMode("light"); // mode light + themeId → lightThemeId
theme.setMode("system"); // follow prefers-color-scheme

theme.setTheme("ocean-dark"); // pins that id; if mode was system, mode becomes colorScheme of that theme (or "light")

Register / unregister at runtime

ts
theme.registerTheme(oceanDark);

theme.unregisterTheme("ocean-dark");

You cannot unregister the last remaining theme. Unregistering the active themeId falls back to another registered id.

How it works

Resolution order (simplified)

  1. Read preferences (mode, themeId, density, direction, flags).
  2. Resolve resolvedColorScheme:
    • mode === "system" → OS dark ? "dark" : "light" (no-preference treated as light for scheme)
    • else → "dark" or "light" from mode
  3. Pick preferred theme id:
    • systemdarkThemeId or lightThemeId
    • else → preferences.themeId
  4. Fallbacks if missing: themeIdlightThemeId → first registered theme.
  5. Build CSS variables from that theme’s tokens; build attributes.

setMode("dark") and themeId

Setting mode to "dark" or "light" also moves themeId to darkThemeId / lightThemeId so resolved tokens match the scheme. Setting "system" updates mode only and lets light/dark ids drive resolution from the OS.

Snapshot attributes

AttributeSource
data-themeresolvedThemeId
data-color-schemeresolvedColorScheme
data-densitypreferences.density
dirpreferences.direction
data-high-contrastpresent when resolved high contrast is true
data-reduced-motionpresent when resolved reduced motion is true

API

ThemeDefinition

FieldTypeRequiredDescription
idstringyesStable theme id
tokensThemeTokensyesToken map
colorScheme"light" | "dark"noUsed when setTheme exits system mode
OptionTypeDefaultDescription
themesreadonly ThemeDefinition[](required)Initial registry; must be non-empty
defaultThemeIdstring(required)Must exist in themes
lightThemeIdstringdefaultThemeIdPreferred id for light / system-light
darkThemeIdstringdefaultThemeIdPreferred id for dark / system-dark
modeThemeMode"system"Initial mode
densityThemeDensity"comfortable"Comfortable / compact / spacious / custom string
directionThemeDirection"ltr"ltr or rtl
highContrastSystemAwareFlagfalsetrue | false | "system"
reducedMotionSystemAwareFlag"system"true | false | "system"
prefixstring"sometic"CSS variable prefix
persistbooleanfalsePrefer Theme store
storage / storageKeyadapter / stringsee store docsPersistence

Controller theme methods

MethodDescription
registerTheme(theme)Add or replace by id; rebuilds snapshot
unregisterTheme(id)Remove; throws if last theme; no-op if unknown
setMode(mode)Update mode (and themeId for light/dark)
setTheme(themeId)Select id; throws if unknown
setDensity / setDirection / setHighContrast / setReducedMotionPreference setters

Presets (@sometic/theme/presets)

ExportDescription
lightTokens / darkTokensMinimal token maps
lightTheme / darkTheme{ id, colorScheme, tokens }

Edge cases

CaseBehavior
Empty themes arrayThrows at create
Unknown defaultThemeIdThrows at create
Unknown setTheme(id)Throws
Unregister last themeThrows
Unregister active themeSwitches themeId to another registered id
mode: "system" + OS changeSnapshot rebuilds when scheme subscription fires
Missing preferred idFalls back through themeId → lightThemeId → first theme

FAQ

What does setMode("dark") do to themeId?

It sets mode to dark and moves themeId to darkThemeId so tokens match.

Why aren’t presets on the root entry?

Size budget: keep @sometic/theme lean; import presets when needed.

Can I have more than two themes?

Yes. Register as many as you need. lightThemeId / darkThemeId only matter for light/dark/system resolution shortcuts.

Do themes persist automatically?

Only preference fields persist when persist: true. Token maps live in memory (or your module graph). See Theme store and Store.

Is colorScheme on the definition required?

No. It matters when setTheme is called while mode is system: mode becomes theme.colorScheme ?? "light".