Why Deck themes from one hue

Deck builds its whole brand palette from one number, a hue angle between 0 and 360, so it cannot express a brand with two primary colours, or reproduce an exact brand hex code, without extra work. Deck accepts that limitation because a palette computed from one number can be stored per customer, rendered into the first byte of HTML, and paired with a matching dark mode without anyone choosing a second set of colours.

What one hue cannot do

A brand with two equally important colours has nowhere in Deck to put the second one. Deck defines a second hue token, --hue-accent, but only five rules in its source read it: the highlight behind the <mark> element, the .btn-accent button and its hover state, and two rules that colour rating stars. Links, focus rings, soft tinted fills and every other brand-coloured surface in Deck read from --hue-brand alone.

Giving one region of a page a second brand colour takes 19 declarations in Deck. Setting --hue-brand on a container is not enough, because Deck's 18 brand tokens are declared on the root element and resolve there, so everything inside the container inherits colours already computed from the root's hue. Measured in Chrome, a container given only --hue-brand: 24 still renders its primary button in the page's original colour, while a container that redeclares all 18 tokens alongside the hue renders its buttons, links, badges and focus colour in the new one.

.brand-secondary {
  --hue-brand: 24;
  --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));
  --text-on-brand: oklch(99% .01 var(--hue-brand));
  --brand: light-dark(var(--brand-600), var(--brand-400));
  --brand-hover: light-dark(var(--brand-700), var(--brand-300));
  --brand-soft: light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)));
  --brand-soft-text: light-dark(var(--brand-800), var(--brand-200));
  --focus: light-dark(var(--brand-500), var(--brand-300));
  --ring: 0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42));
}

An exact brand colour is usually not one of the eleven shades Deck generates. Each Deck shade has a fixed perceptual lightness, from 97.4% for the lightest to 20.4% for the darkest, so a brand colour lands on a shade only when its own lightness matches one. The orange #ff5a1f has a lightness of 68.2%, between Deck's 70.2% and 60.8% shades; at Deck's default saturation the nearest generated shade is #d8876e, and with the saturation raised to match the orange it is #fd6b3b. The navy #1b2a6b has a lightness of 31.6%, between the 35.6% and 28.2% shades, and no saturation setting brings its nearest shade closer than #29386c.

Deck's supported answer for an exact brand colour is to override its role tokens rather than its hue. Setting --brand and --brand-hover to the brand's own values inside an application layer puts the exact colour on every button and link that reads those roles, while tints, borders and the rest of the palette continue to follow --hue-brand. The application then owns the text colour on that fill, --text-on-brand, because Deck computed that value for a colour the application has replaced, and it gives up the lighter dark-mode shade Deck would otherwise have paired with the brand colour.

@layer app.base {
  :root {
    --brand: #ff5a1f;
    --brand-hover: #e14a12;
    --text-on-brand: #1a0a04;
  }
}

Why one number is enough

One number can drive a whole palette because Deck generates the palette in OKLCH, a colour space in which the lightness value tracks how light a colour looks. In HSL, pure yellow and pure blue share a lightness of 50% yet have WCAG relative luminances of 0.93 and 0.07, so a recipe written in HSL produces shades of very different visual weight at different hues. In OKLCH, one recipe of lightness and saturation per shade yields a comparable ramp at any hue. The oklch() function has been Baseline widely available since May 2023, having first shipped in Chrome and Edge 111, Firefox 113 and Safari 15.4.

A single custom property controls a large share of Deck's appearance. Changing --hue-brand recalculates 18 brand tokens, and 198 declarations spread across 20 of Deck's 26 stylesheets read those tokens. Of Deck's 60 colour tokens, 59 are computed from a hue or from another computed token; the only fixed colour token is pure white.

Deck's source contains almost no literal colours that could drift away from its palette. Outside its print rules Deck has 25 hex values, and none of them sets a visible interface colour: 22 are #000 inside CSS mask gradients, where only opacity is read, and three are the #fff and #000 defaults that keep a QR code scannable.

What one number buys

Theming from one number makes per-customer branding a value in a database instead of a separate build. Deck's PHP helper, Deck::theme(), turns a tenant's stored hue into one inline style on the <html> element, so the correct colours are present in the first byte of HTML and no script has to repaint the page after it loads. Every tenant shares the same stylesheet file, so adding a customer adds no CSS file, no build and no extra cache entry.

<html <?= Deck::theme(hue: $tenant->hue) ?>>
<!-- renders as: <html style="--hue-brand:265"> -->

Deck's dark mode follows the brand hue without anyone choosing a second palette. Deck declares its --brand role as light-dark(var(--brand-600), var(--brand-400)), which uses the 51.6%-lightness shade on light pages and the 70.2%-lightness shade on dark pages, and both shades move whenever the hue changes. A tenant who picks purple gets a dark-mode purple produced by the same recipe as the light one.

Computing colours from a number creates one hard problem: choosing a readable colour for text that sits on a brand-coloured fill. Where the browser supports it, Deck uses contrast-color(), which MDN describes as returning only white or black and as commonly meeting the WCAG AA contrast ratio of 4.5:1; MDN lists contrast-color() as Baseline newly available since April 2026. Where the browser lacks it, Deck falls back to two fixed text colours: a near-white at 99% lightness and a near-black at 24% lightness.

Deck's two fixed text colours reach 4.5:1 only on fills that avoid a middle band of lightness, and Deck places its text-bearing fills outside that band. Calculated with the WCAG 2 formula at Deck's brand hue, neither fallback reaches 4.5:1 on a fill between about 54% and 60% lightness, and at Deck's warning and error hues the band runs from about 58% to 62%. The five fills in Deck that carry text sit at 51.6%, 52%, 52%, 74% and 76% lightness. Pure black or pure white would reach at least about 4.58:1 on any fill, but MDN warns that meeting that ratio on a mid-tone background does not guarantee readable text.

Who this model does not suit

Deck's one-hue model does not suit a brand defined by two co-equal primary colours that both need to appear across buttons, links and focus states. Deck can express that brand only by redeclaring its 18 brand tokens in every region that uses the second colour, and an interface that alternates between the two colours throughout will repeat that work in every such region.

Deck's one-hue model also does not suit a design system specified as an exact palette of hex values from a brand book, where every shade must match a published swatch. Deck's shades are computed from fixed lightness steps, so matching a prescribed palette means overriding Deck's generated shades one by one, at which point the generation that makes Deck's theming cheap is no longer doing any work.

A product with one fixed brand, a single site, no tenants and no runtime theme switching gains the least from Deck's approach. That product could choose its eleven shades once, by hand or with a palette tool, and Deck's ability to recompute the palette from a database value would go unused.

The case in one paragraph

Deck's argument for theming from one hue is that, for multi-tenant and white-labelled applications, a readable palette in each customer's own colour matters more than a second brand colour or an exact swatch match. One number produces eleven shades, a dark-mode pairing and 198 declarations of brand styling, can be stored per customer and rendered into the first byte of HTML, and needs no build per tenant. The price is 19 declarations for each region that needs a second brand colour, and three role overrides for an exact brand hex.

Related: why Deck has no build step, why Deck uses cascade layers, and the task-oriented guide to changing your brand colour.