Skip to contentAsheeUIAsheeUI

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:

  • extends is "light" or "dark", and names the built-in palette everything else comes from. A theme that extends dark inherits the consumer's dark, so an application that restated color.dark.primary gets 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. midnight becomes .theme-midnight on <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 extends other than "light" or "dark" throws, because there is no other palette to inherit;
  • an extends on light or dark themselves 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 defaultTheme does 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-scheme and 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:

  • defaultTheme is never stored. Configuring a provider reads the stored value and applies it; it does not write one. A reader who has not chosen keeps seeing defaultTheme, 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, defaultTheme is 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.

  1. The class. <html> carries theme-midnight and no other theme-* class. This is what selects the palette.
  2. The variable. --ashee-primary computes to the value the theme states. This is what makes the components change colour.
  3. The block. The #ashee-theme-vars stylesheet 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 useTheme fields 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.