Change your brand colour
You want the buttons, links, focus rings, charts and shadows to be your colour instead of Deck's. That is one number, set in one place, with no rebuild, no config file and no second stylesheet. This page is that number, and then everything that follows from it being a number rather than a hex code.
The short answer
Put a hue angle on the <html> tag. That is the whole thing.
<html style="--hue-brand: 42"> <!-- rust -->
<html style="--hue-brand: 265"> <!-- violet -->
<html style="--hue-brand: 152"> <!-- green -->
A hue is an angle around the colour wheel, from 0 to 360. 0 is pink-red, 30 is orange, 90 is olive, 150 is green, 200 is cyan, 265 is violet, 330 is magenta. Deck ships at 196, which is a harbour teal.
<div class="cluster">
<button class="btn btn-primary">Primary</button>
<button class="btn btn-soft">Soft</button>
<button class="btn btn-outline">Outline</button>
<span class="badge badge-brand">Badge</span>
<a href="#answer">A link</a>
</div>
<div class="cluster">
<button class="btn btn-primary">Primary</button>
<button class="btn btn-soft">Soft</button>
<button class="btn btn-outline">Outline</button>
<span class="badge badge-brand">Badge</span>
<a href="#answer">A link</a>
</div>
Press the button below to move this whole page. It calls
Deck.hue(), which sets the same custom property at runtime — every
surface, border, focus ring, shadow tint and chart series on the page follows,
including the documentation chrome around it.
<div class="cluster">
<button class="btn btn-sm" onclick="Deck.hue(42)">Rust, 42</button>
<button class="btn btn-sm" onclick="Deck.hue(152)">Green, 152</button>
<button class="btn btn-sm" onclick="Deck.hue(265)">Violet, 265</button>
<button class="btn btn-sm" onclick="Deck.hue(330)">Magenta, 330</button>
<button class="btn btn-sm btn-ghost" onclick="Deck.hue(196)">Back to 196</button>
</div>
<div class="cluster">
<button class="btn btn-sm" onclick="Deck.hue(42)">Rust, 42</button>
<button class="btn btn-sm" onclick="Deck.hue(152)">Green, 152</button>
<button class="btn btn-sm" onclick="Deck.hue(265)">Violet, 265</button>
<button class="btn btn-sm" onclick="Deck.hue(330)">Magenta, 330</button>
<button class="btn btn-sm btn-ghost" onclick="Deck.hue(196)">Back to 196</button>
</div>
Why a number and not a hex code
Most frameworks ask for #0f766e and then ask for the ten shades around it
as well, because a brand colour on its own is not a palette. You need a light tint for
a selected row, a dark step for text on white, a mid step for a border. Picking eleven
hex codes that look like a family is a design job, and doing it again for every tenant
is a design job you do not want.
Deck asks for the hue and generates the rest in OKLCH, where the first number is perceptual lightness. That matters more than it sounds. In HSL, yellow at 50% lightness and blue at 50% lightness look nothing like the same lightness — yellow is glaring and blue is nearly black. In OKLCH they match, because the space is built around how eyes actually work. So the same ramp recipe produces a usable palette at every hue, which is what makes one number enough.
--brand-500: oklch(60.8% var(--chroma-brand) var(--hue-brand));
--brand-600: oklch(51.6% var(--chroma-brand) var(--hue-brand));
--brand-700: oklch(43.4% calc(var(--chroma-brand) * .92) var(--hue-brand));
The lightness is fixed per step, the hue comes from your number, and the chroma tapers at the ends — because a very light or very dark colour at full chroma looks artificial, so the tints and shades pull their saturation back.
Here is the ramp as it stands on this page right now:
| Token | Live | Declared value |
|---|---|---|
--brand-50 |
oklch(97.4% calc(var(--chroma-brand) * .16) var(--hue-brand)) |
|
--brand-100 |
oklch(94.2% calc(var(--chroma-brand) * .30) var(--hue-brand)) |
|
--brand-200 |
oklch(88.6% calc(var(--chroma-brand) * .48) var(--hue-brand)) |
|
--brand-300 |
oklch(80.4% calc(var(--chroma-brand) * .70) var(--hue-brand)) |
|
--brand-400 |
oklch(70.2% calc(var(--chroma-brand) * .90) var(--hue-brand)) |
|
--brand-500 |
oklch(60.8% var(--chroma-brand) var(--hue-brand)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--brand-700 |
oklch(43.4% calc(var(--chroma-brand) * .92) var(--hue-brand)) |
|
--brand-800 |
oklch(35.6% calc(var(--chroma-brand) * .78) var(--hue-brand)) |
|
--brand-900 |
oklch(28.2% calc(var(--chroma-brand) * .62) var(--hue-brand)) |
|
--brand-950 |
oklch(20.4% calc(var(--chroma-brand) * .46) var(--hue-brand)) |
Turning the saturation down
The second knob is --chroma-brand: how colourful, independent of which
colour. Deck ships at .118, which is a confident but not loud brand.
Lower it for something institutional, raise it for something consumer.
<html style="--hue-brand: 265; --chroma-brand: .04"> <!-- nearly grey -->
<html style="--hue-brand: 265; --chroma-brand: .16"> <!-- vivid -->
Above about .2 you leave the sRGB gamut at some hues, and the browser
clamps — so a wide-gamut screen shows something a normal screen cannot, and the two
stop matching. If a colour looks flat on one monitor and electric on another, chroma
is why.
<div class="cluster">
<button class="btn btn-sm" onclick="document.documentElement.style.setProperty('--chroma-brand', '.04')">Muted</button>
<button class="btn btn-sm" onclick="document.documentElement.style.setProperty('--chroma-brand', '.118')">Default</button>
<button class="btn btn-sm" onclick="document.documentElement.style.setProperty('--chroma-brand', '.17')">Vivid</button>
</div>
<div class="cluster">
<button class="btn btn-sm" onclick="document.documentElement.style.setProperty('--chroma-brand', '.04')">Muted</button>
<button class="btn btn-sm" onclick="document.documentElement.style.setProperty('--chroma-brand', '.118')">Default</button>
<button class="btn btn-sm" onclick="document.documentElement.style.setProperty('--chroma-brand', '.17')">Vivid</button>
</div>
One theme per tenant, from PHP
This is the case Deck was built for. The tenant's colour is a column in your database, and it has to be right in the first paint — not applied by a script after the page has already flashed the wrong brand.
<?php use EchoDial\Deck\Deck; ?>
<html <?= Deck::htmlAttributes(lang: 'en') ?> <?= Deck::theme(
hue: $tenant->hue, // 265
chroma: $tenant->chroma, // .12, or null for the default
mode: $user->theme, // 'light', 'dark', or null to follow the OS
) ?>>
<!-- renders as -->
<html lang="en" style="--hue-brand:265;--chroma-brand:.12" data-theme="dark">
Every argument is optional and a null one is left out entirely, so a tenant with no brand colour and a user with no preference produce an empty attribute and the defaults apply. There is no per-tenant CSS file, no build per customer, and no cache to invalidate: the stylesheet is byte-identical for everybody and the theme is two declarations in the HTML.
The full signature is on the PHP helper
page. If you are not using PHP, the point stands — emit
style="--hue-brand:265" on the <html> tag from whatever
renders it.
Letting a person choose, at runtime
Deck.hue(n) sets the same property from JavaScript. It is not persisted
and not validated — it is a live preview, and saving the choice is your job.
<label class="label" for="hue">Brand hue</label>
<input class="range" id="hue" type="range" min="0" max="360" value="196"
oninput="Deck.hue(this.value)">
<div class="field" style="max-inline-size:26rem">
<label class="label" for="g-hue">Brand hue</label>
<input class="range" id="g-hue" type="range" min="0" max="360" value="196" oninput="Deck.hue(this.value)">
</div>
<div class="field" style="max-inline-size:26rem">
<label class="label" for="g-hue">Brand hue</label>
<input class="range" id="g-hue" type="range" min="0" max="360" value="196" oninput="Deck.hue(this.value)">
</div>
To make it stick, save the number and render it server-side next time. Doing it the
other way round — reading localStorage in a script and calling
Deck.hue() on load — means the page paints in the default colour first and
then jumps, which is the flash of unthemed content and it is very visible on a slow
connection.
The one that will catch you: it has to be on the root
--hue-brand on anything but the root silently does nothingNo error, no warning, no visual change. This is the single most likely way to lose an hour with Deck's theming, so it is worth understanding rather than memorising.
<!-- Does NOT work. The card stays teal. -->
<div class="card" style="--hue-brand: 42">
<div class="card-body"><button class="btn btn-primary">Still teal</button></div>
</div>
The reason is how custom properties resolve. --brand-600 is declared once,
on :root, as oklch(51.6% var(--chroma-brand) var(--hue-brand)).
That var() is substituted where the property is declared, not
where it is used — so --brand-600 computes to a finished colour on the root
element, and everything below inherits that finished colour. Your
--hue-brand: 42 is inherited correctly by the card and read by nothing,
because nothing below the root recalculates the ramp.
If you genuinely need a differently-themed region — a preview pane in a theme editor is the honest use case — redeclare the ramp steps you use on that element:
<div class="card" style="
--hue-brand: 42;
--brand-400: oklch(70.2% calc(var(--chroma-brand) * .90) var(--hue-brand));
--brand-500: oklch(60.8% var(--chroma-brand) var(--hue-brand));
--brand-600: oklch(51.6% var(--chroma-brand) var(--hue-brand));
--brand: light-dark(var(--brand-600), var(--brand-400));
">
That works — the var()s now resolve on the card, where
--hue-brand is 42. It is also verbose and easy to get half-right, which
is why the answer for almost every real case is: put it on the root and render one
theme per page.
Dark mode is already done
You do not theme twice. Deck's semantic tokens are declared with
light-dark(), so each one holds both values and the browser picks:
--surface: light-dark(var(--ink-0), var(--ink-950));
--text: light-dark(var(--ink-900), var(--ink-100));
--brand: light-dark(var(--brand-600), var(--brand-400));
Note the last one. In light mode the brand is the 600 step, and in dark it is the 400 — lighter, because the same colour that reads as solid on white reads as muddy on near black. Change the hue and both steps move together, so a purple tenant gets a correct dark mode without anyone choosing a second purple.
That is why the rule is reference the roles, not the ramps. Write
var(--surface) and dark mode is free; write var(--ink-0) and
you have hard-coded white into a page that will one day be dark. There are 16 role
tokens and they are listed on the token
reference. Adding a switch a person can press is
its own short guide.
Text on a coloured surface, and where contrast-color() fits
Generating a palette from one number has one genuinely hard problem in it: what colour is the text on top of the brand fill? White works on a dark blue and is unreadable on a bright yellow, and you do not know which you are getting until the tenant picks.
Deck ships static answers for the five saturated surfaces — they are correct for the shipped hues and they are guesses about yours:
--text-on-brand: oklch(99% .01 var(--hue-brand)); /* near white */
--text-on-warn: oklch(24% .06 var(--hue-warn)); /* near black */
Then it overwrites them where the browser can do better:
@supports (color: contrast-color(red)) {
:root {
--text-on-brand: contrast-color(var(--brand-600));
--text-on-warn: contrast-color(var(--warn-500));
}
}
contrast-color() asks the browser to pick black or white against the
colour as it actually computes, so it follows your hue wherever you drag it.
It aims at 4.5:1, which is the AA threshold for body text. Where it is missing the
static values stand in, and the page is still readable — just not provably so at
every hue.
The ramps are built to keep that promise honest: every surface that carries text is
kept out of a middle band of lightness, roughly 54–62% depending on the hue. In
that band neither of Deck's static fallback text colours — the near-white and the
near-black used where contrast-color() is missing — reaches 4.5:1, and
even the better of pure black and pure white only just clears it. If you set a custom
--chroma-brand above .15 and use
.btn-primary heavily, check one button with a contrast tool. It is a
two-minute check and it is the only part of this that a number cannot guarantee.
When one hue is not enough
There are five more, and they work exactly the same way. Set them on the root together:
| Token | Live | Declared value |
|---|---|---|
--hue-brand |
196 |
|
--hue-accent |
42 |
|
--hue-good |
152 |
|
--hue-warn |
74 |
|
--hue-bad |
24 |
|
--hue-neutral |
232 |
--hue-neutral is the interesting one. The greys are not grey: they carry a
trace of that hue at --chroma-neutral: .012, so they sit beside the brand
without looking dirty. Move it toward your brand hue for a warmer page, or set
--chroma-neutral: 0 for true neutral grey.
Beyond the hues, override a role rather than a ramp step. This is the supported way to depart from the generated palette:
@layer app.base {
:root {
/* a specific brand colour the ramp cannot produce */
--brand: #0b5cff;
--brand-hover: #0847cc;
/* everything else still follows --hue-brand */
}
}
Your @layer app.base beats deck.tokens with no
!important, because layer order decides it. If you
do this, override --text-on-brand as well — the generated one is computed
from a hue you have just stopped using.
What not to override
-
Not the ramp steps, one at a time. Setting
--brand-600and leaving the other ten alone gives you a button that no longer matches its own hover state. Change the hue, or change the roles. -
Not
--ink-*to get a dark page. That is dark mode, and it already exists. Inverting the neutral ramp by hand gives you a dark page whose components still think they are on white. - Not the lightness of a surface that carries text. The steps avoid the middle lightness band deliberately. Moving one into it means neither of Deck's static fallback text colours reaches 4.5:1 on that surface, at the hues Deck ships.
-
Not
--hue-brandon a component. Covered above. It does nothing, quietly. -
Not with
!important. If an override is not landing, the layer is wrong, not the specificity. The layers guide is four minutes and will save you the escalation.