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.

Deck at its shipped hue, 196
Badge A link
<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.

Live. This changes the page you are reading
<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:

Design tokens this component reads
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.

Same hue, three chromas
<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)">
Drag it. The whole page follows, sixty times a second
<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

Setting --hue-brand on anything but the root silently does nothing

No 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:

Design tokens this component reads
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-600 and 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-brand on 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.