Runtime themes
Let readers create palettes while the application runs, persist the definitions, and provide an editor.
A configured theme is written before the application starts. A runtime theme is written while it runs: a reader picks colours, names the palette, and expects every component to change without a rebuild.
Both providers accept the definitions through one controlled prop:
<AsheeUIProvider config={config} themes={themes}>
<App />
</AsheeUIProvider>
themes is an ExternalColorConfig, the same shape as config.color. The provider merges it over the
configured map before resolution, so a runtime palette goes through the same inheritance,
validation and painting as a configured one.
import type { ExternalColorConfig } from "@asheeui/core";
const themes: ExternalColorConfig = {
ocean: {
extends: "dark",
background: "#08111f",
primary: "#38bdf8",
border: "#1e3a5f",
},
};
ocean now appears in useTheme().availableThemes, can be selected with setTheme("ocean"), paints
as .theme-ocean on the web and as the resolved palette on a device.
What it may state
| Rule | Detail |
|---|---|
| A name is a slug | Lowercase letters, digits and hyphens; starts with a letter; up to 64 characters |
| Built-ins are reserved | light, dark and system are rejected; retune the built-ins in config.color |
| Colours are portable | Hex (#rgb, #rrggbb, #rrggbbaa) or rgb()/rgba(); alpha is ignored |
extends names something declared |
A built-in, a configured theme or another runtime theme |
| It is colours only | Radii, spacing, typography and component config remain in the static configuration |
An invalid map throws while the provider renders, naming the first issue. The editor package exposes the non-throwing validator through its field messages, so a form can point at the field instead of crashing.
Add, select, remove
Keep the map in application state and pass it down:
const [themes, setThemes] = useState<ExternalColorConfig>({});
const addOcean = () => {
setThemes((current) => ({
...current,
ocean: { extends: "dark", primary: "#38bdf8", background: "#08111f" },
}));
};
The provider must see a name before the controller can select it. A handler that adds a theme and calls
setTheme in the same event is selecting a name the current provider does not know:
// This can be dropped: `ocean` is not in availableThemes yet.
setThemes(next);
setTheme("ocean");
Select it after the render that carries the new map. The fitness reference application uses a pending name and an effect:
useEffect(() => {
if (pending && themes[pending]) {
setTheme(pending);
setPending(undefined);
}
}, [pending, themes, setTheme]);
Removing a name removes it from the picker and the stylesheet. If it overrode a configured theme, the configured definition comes back. If it was the selection, the controller falls back to the default and clears the stored selection; the same stale-value rule that handles a renamed configured theme.
Persistence
The definitions belong to the application. The library only accepts the map; it does not choose a storage key, schema or migration policy.
Store the map next to the rest of the reader's settings, then load it before the provider's first render when a stored selection may name a runtime theme:
const [themes, setThemes] = useState(() => readThemes());
const [ready, setReady] = useState(false);
useEffect(() => {
setThemes(readThemes());
setReady(true);
}, []);
The selection itself is still ashee-theme, written by setTheme. The definitions need their own key;
the reference applications use pulse.themes.
Two rules keep a reload predictable:
- Validate before passing stored data. A bad edit or an older version must not make the provider
throw during render. Validate the map and fall back to
{}when it is not acceptable. - Load the definitions before validating the selection. A stored
oceanlooks stale while the definitions are still loading. On the server there is no localStorage, so a web application usually starts with{}, loads the definitions in an effect, and restores a captured selection once. The server rendering section states the trade-off.
On a device, AsyncStorage is asynchronous while the controller's storage contract is synchronous. The
reference application loads both values, caches the selection in memory for the controller's synchronous
get, and mirrors writes back to AsyncStorage.
Server rendering
A theme known at render time is included in the pre-paint script and the first paint, exactly like a configured theme. A theme that only exists in the browser arrives after hydration: the page paints the configured default first, then the provider registers and applies the runtime palette.
That is a real trade-off, not a bug. The alternatives are to render the definitions on the server
(cookies or a request-scoped store) or to accept a short default frame. Loading themes from an effect
keeps the server HTML and the first client render identical, which is what prevents a hydration mismatch
on the stylesheet.
If a stored selection names a runtime theme, read the selection before the provider's first layout
effect. The controller may drop it while the definitions are absent. Once the definitions are present,
setTheme(stored) restores it.
The editor package
@asheeui/theme is optional. It imports asheeui and core, has no renderer imports, and works on both
platforms without a second implementation.
pnpm add @asheeui/theme
import { ThemeEditor, ThemeSwitcher } from "@asheeui/theme";
<ThemeSwitcher showSystem label="Theme" />
<ThemeEditor
themes={themes}
onChange={setThemes}
activeTheme={theme}
onApply={setTheme}
/>;
The package contains:
| Component | Use |
|---|---|
ThemeSwitcher |
Pick system, a built-in or a runtime theme |
ColorField |
A hex/rgb input with a swatch, presets and a validation message |
ThemeCard |
A palette preview with select/edit/delete actions |
ThemeEditor |
Name, slug, base, eight roles, contrast checks and save/apply/delete |
ThemeGallery |
Saved themes, a create action and optional starter presets |
ContrastBadge |
The WCAG ratio and level for two colours |
Every component is controlled. ThemeEditor never wraps the provider, never writes storage and never
calls setTheme except through onApply. That makes it usable in a settings drawer, a modal, a test or
a documentation demo.
The live demo edits an in-memory map. It does not apply the theme to this page: a docs demo that called
setTheme would change the site's own theme, and a preview that needs a scoped palette is a product
decision rather than an editor default.
Starter presets
THEME_PRESETS is a small set of ready definitions (Ocean, Forest, Sunset and Mono). The gallery can
show them without saving anything itself:
import { THEME_PRESETS, ThemeGallery } from "@asheeui/theme";
<ThemeGallery
themes={themes}
presets={THEME_PRESETS}
onPresetSelect={(preset) => {
setThemes((current) => ({
...current,
[preset.slug]: preset.definition,
}));
setPending(preset.slug);
}}
/>;
onPresetSelect is the application's decision: replace an existing preset, skip it, or add it beside
the reader's themes. Select the name on the next render, after the provider knows it, using the same
pending-name pattern as above.
The demo adds presets to its own map. It does not apply them to the documentation page.
Contrast
A palette is not valid just because it parses. ThemeEditor computes the ratio for body text and for a
primary action, and shows AA/AAA or a failing ratio. It warns rather than blocks: a deliberately
low-contrast palette is the application's choice.
The same calculation is exported from core for an application that builds its own form:
import { contrastRatio, contrastLevel } from "@asheeui/core";
contrastRatio("#ffffff", "#0b1220"); // about 17.9
contrastLevel(17.9); // "AAA"
Native
The device receives the same definitions. It paints the eight portable roles through its palette
variables; scrollbarThumb and scrollbarTrack are web-only and are ignored. Use hex or rgb() values;
an alpha channel is discarded because the framework's classes carry their own.
Next
- A custom theme for a theme that ships with the configuration.
- Theming for the controller, the resolved palette and server rendering.
- ColorSwatch for the primitive the editor builds on.
- Configuration for where
configandthemesmeet.