Theme store ​

Theme preferences (mode, theme id, density, direction, contrast, motion) can persist through @sometic/store/persistent. This page documents that bridge. For tokens, CSS variables, system prefs, and applying snapshots to the DOM, use the full Theming section.

Overview ​

createThemeController from @sometic/theme always keeps preferences in an @sometic/store instance:

  • Without persist: true: an in-memory createStore holds preferences. hydrated resolves immediately.
  • With persist: true: a createPersistentStore holds preferences. Default storage is createMemoryStorage() unless you pass a web (or custom) adapter. Await hydrated before trusting restored values.

A second internal store derives the public ThemeSnapshot (tokens, CSS variables, attributes) whenever preferences or system signals change.

When to use this page ​

  • Wiring persist, storage, and storageKey correctly
  • Understanding why theme prefs survive reloads
  • Debugging hydrate races or memory-only defaults

When not to use ​

  • Designing token scales or CSS variable prefixes → Tokens and CSS variables
  • Framework-only theme providers without Sometic engines → keep those at the app boundary; this controller stays framework-neutral

Installation ​

Theme depends on store already. Install the theme package (and store if you call persistence APIs yourself):

pnpm
pnpm add @sometic/theme @sometic/store
npm
npm install @sometic/theme @sometic/store
yarn
yarn add @sometic/theme @sometic/store
bun
bun add @sometic/theme @sometic/store

How persistence is wired ​

From @sometic/theme's controller (simplified):

ts
import { createStore } from "@sometic/store";
import { createPersistentStore, createMemoryStorage } from "@sometic/store/persistent";

const shouldPersist = options.persist === true;

const persistentStore = shouldPersist
    ? createPersistentStore(initialPreferences, {
          key: options.storageKey ?? "sometic-theme",
          storage: options.storage ?? createMemoryStorage(),
          version: 1,
      })
    : undefined;

const preferenceStore = persistentStore ?? createStore(initialPreferences);
const hydrated = persistentStore?.hydrated ?? Promise.resolve();

Important defaults:

OptionDefault when persisting
storageKey"sometic-theme"
storagecreateMemoryStorage() (not localStorage)
version1 (fixed by the controller today)

If you set persist: true but omit storage, preferences persist only for the lifetime of that memory adapter instance (useful in tests). For durable browser prefs, pass createWebStorageAdapter("localStorage") (or sessionStorage).

Usage ​

Persist theme preferences to localStorage ​

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

const theme = createThemeController({
    themes: [lightTheme, darkTheme],
    defaultThemeId: lightTheme.id,
    lightThemeId: lightTheme.id,
    darkThemeId: darkTheme.id,
    mode: "system",
    persist: true,
    storageKey: "sometic-theme",
    storage: createWebStorageAdapter("localStorage"),
});

await theme.hydrated;

applyThemeToElement(document.documentElement, theme.get());

theme.subscribe((snapshot) => {
    applyThemeToElement(document.documentElement, snapshot);
});

theme.setMode("dark");
js
import { createThemeController, applyThemeToElement } from "@sometic/theme";
import { lightTheme, darkTheme } from "@sometic/theme/presets";
import { createWebStorageAdapter } from "@sometic/store/persistent";

const theme = createThemeController({
    themes: [lightTheme, darkTheme],
    defaultThemeId: lightTheme.id,
    lightThemeId: lightTheme.id,
    darkThemeId: darkTheme.id,
    mode: "system",
    persist: true,
    storageKey: "sometic-theme",
    storage: createWebStorageAdapter("localStorage"),
});

await theme.hydrated;

applyThemeToElement(document.documentElement, theme.get());

theme.subscribe((snapshot) => {
    applyThemeToElement(document.documentElement, snapshot);
});

theme.setMode("dark");
html
<script src="https://cdn.jsdelivr.net/npm/@sometic/theme@1.1.3/dist/cdn/sometic-theme.iife.js"></script>
<script>
    const theme = SometicTheme.createThemeController({ themes: [], defaultThemeId: "light" });
</script>
html
<script type="module">
    import { createThemeController } from "https://cdn.jsdelivr.net/npm/@sometic/theme@1.1.3/dist/cdn/sometic-theme.esm.js";

    const theme = createThemeController({ themes: [], defaultThemeId: "light" });
</script>

Without persistence ​

Omit persist (or set false). Preferences reset on reload; hydrated is already resolved.

ts
const theme = createThemeController({
    themes: [lightTheme, darkTheme],
    defaultThemeId: lightTheme.id,
});

await theme.hydrated; // resolves immediately

Custom storage adapter ​

Any StorageAdapter from @sometic/store/persistent works (getItem / setItem / removeItem, sync or async):

ts
import type { StorageAdapter } from "@sometic/store/persistent";

const cookieLike: StorageAdapter = {
    name: "cookie-bridge",
    getItem(key) {
        return readCookie(key);
    },
    setItem(key, value) {
        writeCookie(key, value);
    },
    removeItem(key) {
        clearCookie(key);
    },
};

const theme = createThemeController({
    themes: [lightTheme, darkTheme],
    defaultThemeId: lightTheme.id,
    persist: true,
    storage: cookieLike,
    storageKey: "theme-prefs",
});

What gets persisted ​

Only preferences, not the full snapshot:

  • mode, themeId, density, direction
  • highContrast, reducedMotion flags (including "system")

Resolved color scheme, token maps, and CSS variable objects are recomputed after hydrate from registered themes and current system signals.

Edge cases ​

CaseBehavior
persist: true + default memory storageSurvives within the process, not across reloads in a new JS realm
Apply DOM before hydratedMay flash default prefs; await hydrate first for durable storage
Storage quota / private modeHandled by persistent store error path; controller keeps running with last good memory state
SSRNo import-time window; pass memory storage or hydrate only on the client
Changing storageKeyOld key is orphaned; migrate manually if needed
Controller disposeDisposes preference and snapshot stores; stop applying after dispose

Deep API for modes, tokens, contrast, and scoped roots: Theming, Runtime switching, Installation.

How it works (store side) ​

  1. Preferences land in createStore or createPersistentStore.
  2. Preference changes rebuild a snapshot store (custom equality over a stable snapshot key).
  3. System matchMedia subscriptions rebuild when mode or flags are "system".
  4. You call get / subscribe on the controller and typically applyThemeToElement.

Persistence mechanics (envelopes, migrations, onPersistError) are documented on Store. The theme controller currently ships version: 1 without custom migrations; treat preference shape as stable or clear storage if you fork the controller.

FAQ ​

Does theme require React? ​

No. Controllers are framework-agnostic. Bind with subscribe + applyThemeToElement, or wrap with adapter hooks later.

Why is my theme not surviving reload? ​

Confirm persist: true and a durable storage adapter. Memory storage is the default when storage is omitted.

Should I call createPersistentStore myself for theme? ​

Usually no. Prefer createThemeController({ persist: true, storage, storageKey }). Use the persistent store directly for non-theme app prefs.

Is hydrated required when not persisting? ​

It resolves immediately, so awaiting is harmless and keeps one code path.

Can I use Immer for theme prefs? ​

Not through the controller. Preferences use the plain store / persistent store. See Immer adapter for app state elsewhere.

Where are CSS variables documented? ​

CSS variables and Tokens.

Does this sync theme across tabs? ​

Not automatically. Preference writes go to storage; another tab does not get a live store subscription unless you add cross-tab yourself or listen to storage events and recreate/apply. Out of the box, a reload in another tab picks up persisted prefs via hydrate.

Bundle impact? ​

Theme controller stays on the theme package budget; persistence code comes from @sometic/store/persistent when you enable persist.