A custom theme
Declare a palette, extend a built-in theme, switch to it, and check that it applied.
A theme is a value in the configuration rather than a stylesheet: you state a palette, it is
resolved against the built-in one it extends, and the provider applies it as one class on the
document root and a block of --ashee-* variables. Theming states that
model. This page is the work, in the order you do it: declare, use, persist, check.
Declare the palette
One object under color, keyed by the name you want to write everywhere else:
// asheeui.config.ts
import type { ExternalConfig } from "@asheeui/web";
const config: ExternalConfig = {
color: {
midnight: {
extends: "dark",
background: "#0b0b12",
primary: "#8b5cf6",
border: "#2d2a45",
},
},
};
export default config;
midnight is your own string, and it needs no declaration: the palette key, defaultTheme and
setTheme all accept it as it is. The theme's existence is checked at runtime, when the
provider renders, so a typo in defaultTheme throws there rather than failing to compile, the
name compiles because a theme name is an ordinary string, and the provider rejects one that names
no palette in the configuration.
Three rules keep a custom theme short:
extendsis"light"or"dark", and names the built-in palette everything else comes from. A theme that extendsdarkinherits the consumer'sdark, so an application that restatedcolor.dark.primarygets its own value here rather than the library's.- A role you do not state is inherited. The palette has ten roles, and each one becomes a variable the framework reads.
- The name is the class.
midnightbecomes.theme-midnighton<html>, which is the only thing that makes the block apply, see Check that it applied.
The ten roles, and what each one paints:
| Key | Variable | What it paints |
|---|---|---|
background |
--ashee-background |
The page and its surfaces |
foreground |
--ashee-foreground |
Body text |
primary |
--ashee-primary |
Actions, accents, the chosen row |
secondary |
--ashee-secondary |
Cards, panels, alternating rows |
border |
--ashee-border |
Rules and input outlines |
danger |
--ashee-danger |
Errors and destructive actions |
warning |
--ashee-warning |
Caution |
success |
--ashee-success |
Confirmation |
scrollbarThumb |
--ashee-scrollbar-thumb |
The scrollbar's handle |
scrollbarTrack |
--ashee-scrollbar-track |
The scrollbar's track |
The trap: a theme with no extends
A custom theme that states no extends inherits the application's default family: dark when
defaultTheme is "dark", and light in every other case, including "system", and
including a defaultTheme that names another custom theme. So a product that follows the
system and declares a custom theme without extends gets a light-based palette: white
backgrounds and dark text, in the middle of a dark application, at night.
State extends on every custom theme unless light is genuinely what you mean.
What the theme is checked for
The object is validated once, when a provider mounts, and a mistake is reported rather than silently replaced:
- a colour value that is not a string throws, naming the theme and the role;
- a role name that is not one of the ten warns in development and changes nothing, which is what a typo looks like;
- an
extendsother than"light"or"dark"throws, because there is no other palette to inherit; - an
extendsonlightordarkthemselves is reported as an unknown key, because those are the palettes everything else inherits and there is nothing above them.
The full set of rules is in Configuration.
Use it
defaultTheme states what a reader with no stored choice sees:
const config: ExternalConfig = {
defaultTheme: "midnight",
color: {
midnight: { extends: "dark", primary: "#8b5cf6" },
},
};
"system" is the other useful value: it follows prefers-color-scheme and keeps following it
while the reader changes it. Either way, a stored choice wins over this one on later visits,
that is Persist it.
The switch control
useTheme reads the controller and re-renders when the selection changes:
import { Button, HStack, useTheme } from "@asheeui/web";
export function ThemePicker() {
const { theme, setTheme } = useTheme();
return (
<HStack gap="sm" wrap>
{(["light", "dark", "midnight", "system"] as const).map((choice) => (
<Button
key={choice}
size="sm"
variant={theme === choice ? "solid" : "bordered"}
onClick={() => setTheme(choice)}>
{choice}
</Button>
))}
</HStack>
);
}
theme is the selection and resolvedTheme is what it resolved to, so a control that shows an
icon needs both: while the selection is "system", only resolvedTheme says which palette is
on the screen. availableThemes is a readonly ThemeName[], the same type setTheme takes,
so a picker built from that list passes the chosen name straight back with no cast, exactly as
the written list above does.
Outside React
themeController is the same operations as an object, for the places a hook cannot go, a
keyboard shortcut, an effect that reacts to a route, a script:
import { themeController } from "@asheeui/web";
themeController.setTheme("midnight"); // choose it, and persist the choice
themeController.toggleTheme(); // advance to the next registered name
themeController.getSelection(); // "midnight", or "system"
themeController.getResolvedTheme(); // "midnight", never "system"
toggleTheme cycles the registered names and skips "system", because "follow the system" is
not a palette. A two-state control that should offer it is setTheme("system") and
setTheme("midnight") explicitly.
The trap: a nested provider adds, never removes
There is one controller and one stylesheet for the document, and every provider configures them
with the themes its configuration declares. A provider mounted below the application's own, a
page that demonstrates its own configuration, a preview panel, therefore states the built-ins
and knows nothing about midnight.
That is allowed, and it cannot take your theme away: the registered names are unioned, so a
nested provider can add a palette but never remove one, and the stylesheet is rebuilt from the
union rather than written once, so .theme-midnight keeps its variables whichever provider
reached the stylesheet first.
Two more rules follow from the same direction, an outer provider's configuration is the application's, a nested one is a visitor:
- A nested provider's
defaultThemedoes not become the document's default. The outermost provider owns that, so a preview panel's own default cannot replace what a first visit to the application sees. - A nested provider emits no pre-paint script. Only the application's provider emits the
script that writes the parse-time class, the
color-schemeand the stylesheet, so a nested configuration cannot paint its own theme over the application's before hydration.
What the controller puts on the page states the mechanics, and Theming states which provider writes the script.
Persist it
The choice is stored in localStorage under ashee-theme, as the selection itself, a theme
name, or "system":
| Fact | Value |
|---|---|
| The key | ashee-theme, exported as THEME_STORAGE_KEY |
| What it holds | The selection: a theme name, or "system" |
| Who writes it | setTheme, and nothing else |
| When it wins | Whenever it names a theme this build declares |
Two consequences follow from who writes it, setTheme, and nothing else:
defaultThemeis never stored. Configuring a provider reads the stored value and applies it; it does not write one. A reader who has not chosen keeps seeingdefaultTheme, which is what makes a change to it reach them.- A stored value is validated on every load. A name that this build does not declare, a
theme you renamed, a theme removed in a release, a value another application on the same
origin wrote, is dropped,
defaultThemeis applied, and the entry is removed. That is the self-healing path, so a stale preference is a single wrong-looking visit and not a support ticket.
Renaming a theme is therefore a one-line change: the old name stops validating, its entry is
cleared, and readers land on defaultTheme.
The key is exported if you need it yourself, and hand-clearing a preference is the same operation the stale check performs:
import { THEME_STORAGE_KEY } from "@asheeui/web";
localStorage.removeItem(THEME_STORAGE_KEY); // the next load is a first visit
Check that it applied
"It looks dark" is not a check: a theme that failed to resolve looks like the default theme, which may also be dark. There are three things to look at, and each rules out a different failure.
- The class.
<html>carriestheme-midnightand no othertheme-*class. This is what selects the palette. - The variable.
--ashee-primarycomputes to the value the theme states. This is what makes the components change colour. - The block. The
#ashee-theme-varsstylesheet contains.theme-midnight. Without one the class selects nothing, and the page keeps whatever the previous block said.
const root = document.documentElement;
root.className; // "… theme-midnight"
getComputedStyle(root).getPropertyValue("--ashee-primary").trim(); // "#8b5cf6"
document.getElementById("ashee-theme-vars").textContent.includes(".theme-midnight"); // true
Read the three together and the symptom says what happened:
| What you see | What it means |
|---|---|
theme-light on <html> |
The name never reached a provider: the class is the default, so check which config object the provider was given, and whether the stored selection names a theme that no longer exists |
theme-midnight on <html>, values unchanged |
The class is right and the block is missing or overridden, two providers registered midnight, and the later registration's palette is the one in the stylesheet |
The provider throws defaultTheme: "midnight" is not a valid value |
The name compiles, because a theme name is an ordinary string, but this configuration declares no such palette: color has no such theme, or the object the provider received is not the one you edited |
The console warns color.midnight.primray: unknown key, and one colour does not change |
A role name is misspelt. The ten are in the table above, and the warning is development-only |
A reader still sees the old theme after you changed defaultTheme |
Their stored selection still names a theme this build declares, and a stored choice wins over the default. Clearing THEME_STORAGE_KEY in their browser shows what a first visit sees |
One more thing the three checks do not show: the applied theme is also written to the root's
color-scheme, which is what turns the browser's own surfaces, scrollbars, form controls, the
caret, with the theme. The pre-paint script writes it on first load and the controller writes it
again on every apply, so a switch made after the first paint moves those surfaces with the
palette rather than leaving them on the scheme the page opened with.
Next
- Theming for the model, the
useThemefields and server rendering. - Configuration for where the object goes and how every other value resolves.
- A data table for the screen these colours are most likely to be worn by.