Skip to content

Colours, dark mode & the mode switcher

How theme.json colours flow to var(--primary), writing light and dark palettes, and booting the mode switcher without a flash.

8 min readUpdated Aug 13, 2026
On this page

Colours, dark mode & the mode switcher#

A Salieno theme is not painted with a fixed set of colours. The operator picks a brand colour in the admin, and your theme has to honour it — in light mode, in dark mode, on every page. The mechanism is simple once you see it: your palette lives in CSS custom properties, the brand colour is injected into them from theme.json at request time, and a small pre-paint script decides which mode is active before the first pixel is drawn.

This article shows the whole pattern: the theme.json colors{} block, how it becomes var(--primary) in the browser, how to write a light + dark palette in your precompiled CSS, the mode switcher, and the flash-free boot. Assets and how they are served are covered in Assets; this article is only about colour and mode.

The one rule: never hardcode the brand hex#

Everything below follows from a single principle. The brand colour is a token, not a literal. If you write color: #ef4444 anywhere in your CSS or markup, you have hardcoded the reference theme's red, and the operator's chosen colour will never reach that element. Instead you write color: var(--primary), and the colour flows in from theme.json (or the admin) automatically. A theme that is token-driven re-themes for free; a theme that hardcodes hex looks broken the moment someone changes their brand colour.

The colors{} block in theme.json#

theme.json carries a colors{} object. These are your theme's default palette — the values that ship in the box. The reference theme declares a full ladder; a minimal theme needs far fewer:

json
{
    "name": "Aurora",
    "version": "1.0.0",
    "supports": { "sections": true, "dark_mode": true, "rtl": false },
    "settings": { "show_mode_switcher": true },
    "colors": {
        "primary": "#4f46e5",
        "neutral_dark": "#08080a",
        "neutral_mid": "#111114",
        "neutral_border": "rgba(255,255,255,0.07)",
        "neutral_muted": "#8f8f99",
        "footer_bg": "#111114",
        "footer_text": "#fafafa"
    }
}

You read any of these from Blade with the themeMeta() helper (dot-path into the manifest):

php
themeMeta('colors.primary')            // "#4f46e5"
themeMeta('colors.neutral_dark', '#08080a')  // value, or the default if the key is absent

themeMeta is the canonical helper. The reference theme's layout still calls the older templateMeta — it is a kept-for-compatibility alias of the same function, but write themeMeta in new code. The full helper set is in Helpers.

How the brand colour reaches the browser#

Your colors.primary is the theme's default. The operator's brand colour, set in the admin, arrives separately through clientAreaSettings(). Your layout's job is to resolve one final value and pour it into a CSS variable in an inline :root block, in the <head> of layouts/app.blade.php. This is the bridge between server-side config and your stylesheet:

blade
@php
    // Admin brand colour if set, otherwise the theme's default from theme.json.
    $primary   = clientAreaSettings()['primary_color'] ?? themeMeta('colors.primary', '#4f46e5');
    $neutralBg = themeMeta('colors.neutral_dark', '#08080a');
    $muted     = themeMeta('colors.neutral_muted', '#8f8f99');
@endphp
<style>
    :root {
        --primary: {{ $primary }};
        --neutral-dark: {{ $neutralBg }};
        --neutral-muted: {{ $muted }};
    }
</style>

Now every rule in your precompiled stylesheet — and every inline style in your markup — refers to var(--primary) and gets whatever the operator chose. Nothing else in your CSS needs to know the hex. This is the entire point of the pattern: one server-injected line re-colours the whole theme.

A useful trick from the reference theme: derive tints and shades from the single brand token with color-mix(), so hovers and glows track the brand automatically instead of being separate hardcoded colours:

css
:root {
    --primary-tint:  color-mix(in srgb, var(--primary), white 20%);
    --primary-shade: color-mix(in srgb, var(--primary), black 20%);
    --primary-glow:  color-mix(in srgb, var(--primary), transparent 85%);
}

Which values live in theme.json versus which you compute in CSS is your call. The brand colour and the neutral surfaces belong in theme.json so the operator (and re-theming) can reach them; purely derived shades can stay in the stylesheet.

Light and dark in one stylesheet#

Salieno resolves mode by putting a class on the root element: <html class="dark"> for dark, no class for light. The pre-paint controller (below) also mirrors it as data-theme="dark" / data-theme="light" so you can key CSS off either.

Because uploaded themes ship precompiled plain CSS (no @tailwind, no @apply at runtime — see Assets), you author both palettes as ordinary custom-property blocks: a light :root, then a .dark override that redefines only the tokens that change. Every rule elsewhere reads the token, so retargeting the variable retargets everything that uses it — you do not touch individual components:

css
/* Light first — the default palette */
:root {
    --page-bg:    #f4f4f5;   /* a grey canvas so white cards have an edge */
    --card-bg:    #ffffff;
    --text:       #18181b;
    --text-muted: #52525b;   /* AA on the light canvas */
    --border:     #e4e4e7;
}

/* Dark override — only the tokens that differ */
.dark {
    --page-bg:    var(--neutral-dark);          /* #08080a */
    --card-bg:    var(--neutral-mid);           /* #111114 */
    --text:       #ffffff;
    --text-muted: var(--neutral-muted);         /* #8f8f99 — AA on near-black */
    --border:     rgba(255, 255, 255, 0.07);
}

/* Components never branch on mode — they read the token */
body        { background: var(--page-bg); color: var(--text); }
.card       { background: var(--card-bg); border: 1px solid var(--border); }
.eyebrow    { color: var(--primary); }        /* brand accent, same in both modes */

Two things worth stealing from the reference:

  • The light canvas is grey (`#f4f4f5`), not white. If the page and the cards are both #ffffff, nothing has an edge. A slightly-off canvas behind white cards is what makes elevation read in light mode — it is the counterpart to the #08080a → #111114 step dark mode gets for free.
  • `--text-muted` is a different hex in each mode. A muted grey that passes contrast on near-black fails on a light canvas, and vice-versa. Muted/subtle text is exactly the kind of token that must be overridden in .dark, not shared.

When supports.dark_mode is "system"

If your theme follows the OS preference (see the next section), also fold prefers-color-scheme into the same tokens so the correct palette is present even before any JavaScript runs. The controller still stamps .dark for the class-based rules, but the media query removes any dependency on script for the initial colours:

css
@media (prefers-color-scheme: dark) {
    :root:not(.dark) {
        --page-bg: var(--neutral-dark);
        --card-bg: var(--neutral-mid);
        --text:    #ffffff;
    }
}

supports.dark_mode: declaring your intent#

The supports.dark_mode key in theme.json tells Core how your theme behaves. It takes three values:

ValueMeaning
trueTheme starts in dark mode.
falseTheme is light-only.
"system"Follow the visitor's OS preference (prefers-color-scheme).

Whatever you set, the visitor's own choice from the mode switcher (stored in localStorage) wins over it on return visits. The controller reads this key as themeMeta('supports.dark_mode', 'system') and resolves the initial mode from it.

The flash-free boot (the theme controller)#

If you applied .dark from a script at the bottom of the page, the visitor would see a white flash before it kicked in — the classic FOUC. Salieno avoids this with a tiny script that runs before the body renders, reads the saved preference, and stamps the root element immediately. Core ships it as a shared partial, so you do not write this logic yourself — you @include it high in your <head>, before your stylesheet:

blade
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {{-- FOUC-safe: applies .dark / data-theme before first paint --}}
    @include('partials.theme-controller')

    {{-- your injected :root brand variables --}}
    <style>:root { --primary: {{ themeMeta('colors.primary', '#4f46e5') }}; }</style>

    {{-- then your precompiled stylesheet --}}
    <link rel="stylesheet" href="{{ themeAsset('css/theme.css') }}">
</head>

partials.theme-controller is a Core view, resolvable from any theme. Its resolution order, in one pre-paint pass, is:

  1. If the visitor has a saved localStorage('theme') ("dark" or "light"), use it.
  2. Otherwise, if supports.dark_mode is "system", match prefers-color-scheme.
  3. Otherwise, use the true/false from supports.dark_mode.

It then adds/removes .dark and sets data-theme on <html>, and exposes the result to Alpine as $store.theme.on. It also stores the mtime of your theme.json as a version stamp, so if the operator changes supports.dark_mode the stale localStorage preference is reset rather than fighting the new default. Because it re-runs on livewire:navigated, the mode survives SPA navigation between pages. The layout shell that hosts all of this is covered in Layouts.

The mode switcher#

The controller registers an Alpine store, so a toggle button is a two-line affair. Gate it on your settings.show_mode_switcher flag so an operator can hide it, and drive it from $store.theme:

blade
@if(themeMeta('settings.show_mode_switcher', true))
<button @click="$store.theme.toggle()"
        aria-label="{{ __('Toggle dark mode') }}"
        class="mode-switch">
    {{-- sun when dark is on --}}
    <svg x-show="$store.theme.on" aria-hidden="true" viewBox="0 0 16 16" ...>...</svg>
    {{-- moon when dark is off --}}
    <svg x-show="!$store.theme.on" x-cloak aria-hidden="true" viewBox="0 0 16 16" ...>...</svg>
</button>
@endif

$store.theme.toggle() flips the mode, writes the new value to localStorage('theme'), and updates both the .dark class and data-theme — so your CSS reacts instantly and the choice persists. $store.theme.on is the reactive boolean you bind x-show to for the icon swap; add x-cloak to the initially-hidden icon so it does not flicker before Alpine mounts. Add settings.show_mode_switcher to your theme.json settings{} block (default it to true in the themeMeta call so an older install without the key still shows the toggle).

Checklist#

  • Every colour is a token. No literal brand hex anywhere in CSS or markup — only var(--primary) and friends.
  • Brand colour is injected into an inline :root in your layout head from themeMeta('colors.primary') (with the admin clientAreaSettings()['primary_color'] taking precedence).
  • Light `:root` + `.dark` override, redefining only the tokens that change; muted text gets a distinct value per mode.
  • `@include('partials.theme-controller')` first in `<head>`, before your stylesheet, for a flash-free boot.
  • `supports.dark_mode` and `settings.show_mode_switcher` declared in theme.json and read with themeMeta.

Next#

Continue with Testing your theme to install your theme locally and run it through the completeness checklist — including verifying both modes render correctly and the boot never flashes.

themesdark-modecsstheming
Was this article helpful?
Still stuck?Contact support
Colours, dark mode & the mode switcher · Salieno Docs