Theming# Themes are plain CSS. Every component reads a small set of :root custom properties; switching theme or mode just toggles a class on <html>.
Open the settings sidebar and flip theme or mode: the whole preview updates at once.
Default Primary Secondary Danger Ghost Default Primary Secondary Danger Outline
Each chip's fill is var(--<name>), so the grid tracks theme swaps with no JS.
--accent --accent-fg --page-bg --page-fg --muted --btn-bg --btn-fg --btn-border --btn-hover-bg --danger-bg --danger-fg --danger-hover-bg
Default theme# The full default palette as it ships in theme-default.css: every variable a custom theme can override:
/* theme-default.css: the kit's default palette. Channel values use
* the modern OKLCH colour space and map onto the kit's role tokens:
* primary -> --accent
* foreground -> --page-fg
* background -> --page-bg
* muted-fg -> --muted
* border -> --btn-border
* secondary -> --btn-hover-bg
* destructive -> --danger-bg
*
* The default palette has no theme- class; it lives on the bare
* `:root` selector so a page with no JS (or before the app's theme
* switcher runs) still reads as the kit's default look. The @media
* block at the bottom gives no-JS users dark mode via their OS
* preference. */
:root {
color-scheme: light;
/* Neutral light. Primary is near-black, no hue, the
* "default" button colour. */
--accent: oklch(0.205 0 0);
--accent-hover: oklch(0.205 0 0 / 0.9);
--accent-fg: oklch(0.985 0 0);
--page-fg: oklch(0.145 0 0);
--page-bg: oklch(1 0 0);
--muted: oklch(0.556 0 0);
/* Baseline font. Other themes override --page-font-family; without
* a default here, body's var() would fall back to the UA serif. */
--page-font-family: ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, sans-serif;
/* Body-prose links. The neutral palette's --accent ≈ --page-fg
* (near-black), so links need their own hue; a conventional blue
* + the always-on underline in morpheus.css give links a clear,
* accessible style distinct from text and buttons. */
--link: #2563eb;
--link-hover: #1d4ed8;
--link-visited: #7c3aed;
/* Default button = outline button: background matches the page
* (--page-bg), border = --btn-border, hover swaps in
* --btn-hover-bg. Primary buttons (variant="primary") get
* --accent / --accent-fg above. */
--btn-bg: oklch(1 0 0);
--btn-fg: oklch(0.145 0 0);
--btn-border: oklch(0.922 0 0);
--btn-hover-bg: oklch(0.97 0 0);
--danger-bg: oklch(0.577 0.245 27.325);
--danger-fg: oklch(0.985 0 0);
--danger-hover-bg: oklch(0.577 0.245 27.325 / 0.9);
--neo-tooltip-bg: oklch(0.205 0 0);
--neo-tooltip-color: oklch(0.985 0 0);
}
:root.dark {
color-scheme: dark;
/* Neutral dark. Primary inverts to near-white. Borders use a
* translucent white tint (`oklch(1 0 0 / 10%)`) over the dark
* background so they read as a soft hairline rather than a hard
* grey rule. */
--accent: oklch(0.922 0 0);
--accent-hover: oklch(0.922 0 0 / 0.9);
--accent-fg: oklch(0.205 0 0);
--page-fg: oklch(0.985 0 0);
--page-bg: oklch(0.145 0 0);
--muted: oklch(0.708 0 0);
/* Lighter blue/violet for contrast on the dark page bg. */
--link: #60a5fa;
--link-hover: #93c5fd;
--link-visited: #c4b5fd;
--btn-bg: oklch(0.205 0 0);
--btn-fg: oklch(0.985 0 0);
--btn-border: oklch(1 0 0 / 0.1);
--btn-hover-bg: oklch(0.269 0 0);
--danger-bg: oklch(0.704 0.191 22.216);
--danger-fg: oklch(0.985 0 0);
--danger-hover-bg: oklch(0.704 0.191 22.216 / 0.9);
--neo-tooltip-bg: oklch(0.985 0 0);
--neo-tooltip-color: oklch(0.145 0 0);
/* Card fill is lighter than the page bg in dark mode, so the
* translucent-white --btn-border that works for styling on page-bg
* disappears here. Use an opaque, slightly lighter hairline. */
--neo-card-border-color: #2e2e2e;
}
/* No-JS dark-mode fallback: if <html> has no component-applied mode
* class (so neither .light nor .dark) and the OS asks for dark, fall
* back to the default-dark palette (mirroring the rule above). */
@media (prefers-color-scheme: dark) {
:root:not(.light):not(.dark) {
color-scheme: dark;
--accent: oklch(0.922 0 0);
--accent-hover: oklch(0.922 0 0 / 0.9);
--accent-fg: oklch(0.205 0 0);
--page-fg: oklch(0.985 0 0);
--page-bg: oklch(0.145 0 0);
--muted: oklch(0.708 0 0);
--link: #60a5fa;
--link-hover: #93c5fd;
--link-visited: #c4b5fd;
--btn-bg: oklch(0.205 0 0);
--btn-fg: oklch(0.985 0 0);
--btn-border: oklch(1 0 0 / 0.1);
--btn-hover-bg: oklch(0.269 0 0);
--neo-tooltip-bg: oklch(0.985 0 0);
--neo-tooltip-color: oklch(0.145 0 0);
--neo-card-border-color: #2e2e2e;
}
}
How it works# Two class hooks on <html> drive every visual choice:
theme-<name>: the palette. No theme-* class means the kit's default.light / dark: the colour mode. No class lets prefers-color-scheme pick from the OS, so a no-JS visit still respects dark mode.Each palette is one file (e.g. theme-ocean.css) with a :root.theme-ocean rule for light and a :root.theme-ocean.dark rule for dark overrides. Structural defaults (--page-radius, --page-spacing, --page-font-family) live in morpheus.css; themes only redeclare what they change.
Switching at runtime# No kit-owned theme component; anything that writes the two classes works. This site's picker is a Datastar component holding two signals ($_theme, $_mode); a data-effect writes the classes to document.documentElement and persists them to localStorage.
System mode# Mode also accepts "system": the saved value stays "system" while <html> wears whichever of light or dark matches the OS. A matchMedia listener re-resolves on OS flips; explicit "light" or "dark" are never overridden.
Best practice# Apply the classes from an inline <script> at the top of <head>, before stylesheets, for no FOUC once your framework hydrates. Keep the default palette on bare :root (no class) so a pre-JS render still looks right. Pair every :root.theme-X with a :root.theme-X.dark that only overrides what dark mode changes. The cascade carries the rest. Touch the structural vars (--page-radius, --page-spacing, --page-font-family) only when a theme wants a different feel. Set color-scheme so native controls and scrollbars match.