Colors & Theming
Urengi’s colors are a three-layer system, and once you can name the three layers, rebranding the theme is a handful of edits rather than a search-and-replace across two hundred files.
The rule that makes it work is short: markup only ever uses token utilities. bg-primary, text-foreground, border-border, bg-muted, or the palette aliases text-base-700 and from-primary-800. Never a raw Tailwind color like bg-amber-300 or text-zinc-500 — those bypass theming and dark mode entirely, and a component written that way will look correct in whichever theme you happened to be viewing while you wrote it.
The three layers
Layer 1 — palette aliases, in @theme in src/styles/tailwind-theme.css. These name your brand on top of Tailwind’s own scale, so a rebrand is one edit per ramp:
@theme {
/* Urengi = AMBER */
--color-primary-50: var(--color-amber-50);
/* … through 950 */
/* base/neutral = STONE, the warm neutral the design pairs with amber */
--color-base-50: var(--color-stone-50);
/* … through 950 */
}
Both ramps land exactly on Tailwind’s own: the design file’s colour variables are amber-300 for the brand yellow, amber-50 for the page cream and stone-700 for the nav pill and footer surface. The brand is the alias, not a pile of arbitrary hex — which is what makes the rebrand a twenty-two-line edit. Repoint --color-primary-* at another ramp (var(--color-teal-50) and so on) and the entire site follows.
Layer 2 — semantic runtime variables, in @layer base in src/styles/global.css. These map the palette onto meaning, once per theme, and they are what flips:
:root {
--background: var(--color-primary-50); /* the page cream — amber-50, not a neutral */
--foreground: var(--color-base-800);
--card: var(--color-base-50);
--band: var(--color-primary-100);
--primary: var(--color-primary-300); /* the CTA yellow */
--secondary: var(--color-base-700); /* the nav pill + footer surface */
--muted: var(--color-base-100);
--border: var(--color-base-300);
/* … */
--radius: 0.5rem;
}
.dark {
--background: var(--color-base-950);
--foreground: var(--color-base-100);
--card: var(--color-base-900);
--band: color-mix(in oklab, var(--color-primary-300) 18%, var(--color-base-950));
--primary: var(--color-primary-300);
--secondary: var(--color-base-300);
/* … */
}
Layer 3 — the bridge, back in tailwind-theme.css, using @theme inline:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* … */
}
inline is required here. Without it these resolve to literal values at build time rather than to the runtime variable, and the theme stops flipping. This is the single most common way to break the system while thinking you have extended it.
The page ground is part of the brand ramp
Worth noticing before you retheme: --background in light mode is --color-primary-50, not a neutral. The design’s page cream is #fffbeb, which is amber-50 — so it stays inside the brand ramp and a rebrand carries it. Repoint primary at teal and the page ground becomes a pale teal, which is almost certainly what you want; if it is not, that is the one line to override.
The band token
Urengi’s pages alternate between the page ground and a tinted band — the problem stats, the results strip, the milestones, the setup steps, the risk heatmap. That tint is one token:
--band: var(--color-primary-100); /* light: one step up the ramp from --background */
--band: color-mix(in oklab, var(--color-primary-300) 18%, var(--color-base-950)); /* dark */
Eight sections used to spell bg-primary-100 dark:bg-primary-950 by hand. As a token the pair cannot drift, and retuning it is one line.
The dark value is the interesting half, and it is a lesson about ramps rather than about this theme. primary-950 is what those eight sections named, and it is wrong: the amber ramp’s darkest step sits at hue 45°, which is orange, and at that chroma it reads as burnt brown against stone-950 rather than as the brand. The token is a color-mix instead — the page ground carrying a wash of the brand yellow, same lightness as amber-950, roughly half the chroma, hue around 88°, which is an actual yellow. It is a mix off --color-primary-300 rather than a literal so a rebrand carries the band with it, which is the whole point of layer 1. Tune the 18%: higher is more colour, lower fades toward plain stone.
The accent does not flip
--primary is --color-primary-300 in both themes. That is deliberate: it is the brand mark, and in the design it already sits on the dark stone-700 footer surface, so it needs no dark-mode counterpart. Only its foreground moves, from base-800 to base-900.
The same is true of the status colours — info, success, warning — which reference Tailwind’s sky, green and amber ramps directly on both themes. Only error shifts a step, from red-700 to red-800.
The token vocabulary
| Token pair | Use for |
|---|---|
background / foreground |
the page ground and its body text |
card / card-foreground |
a raised surface and its text |
band |
the alternating tinted section ground |
primary / primary-foreground |
the CTA yellow |
secondary / secondary-foreground |
the dark stone surface — nav pill, footer card |
muted / muted-foreground |
recessed surfaces and secondary text |
accent / accent-foreground |
hover and selected states |
info success warning error |
status, each with its own foreground |
border / input / outline |
dividers, field grounds, focus rings |
Every one of them exists in both themes, and the whole reason a primitive may only use these is that the pair is guaranteed to be legible together.
Dark mode
Class-based, on <html>:
@variant dark (&:where(.dark, .dark *));
BaseHead sets it pre-paint from an inline script: a saved pick from the ThemeToggle primitive wins, otherwise the site follows prefers-color-scheme and keeps following live OS changes while nothing is pinned. It re-runs on astro:after-swap so a view-transition navigation does not lose it.
That script must stay inline and pre-paint. Moving it into a bundled <script> reintroduces a flash of the wrong theme, which is the one bug this arrangement exists to prevent.
Rebranding, in order
- Repoint the two ramps in
tailwind-theme.css. Twenty-two lines. - Check the light
--background, since it is a primary alias rather than a neutral. - Retune
--band’s darkcolor-mixif your accent’s darkest steps drift in hue the way amber’s do. Open a page with a band on it in dark mode; you will see it immediately. - Leave layer 3 alone. It is a bridge, not a place to make decisions.
- Open
/examples/uiin both themes. The catalog renders every primitive in every variant, and a missing token shows up instantly as an un-themed element. It is the fastest check there is.
Two utility classes in global.css also reach for the palette directly and are worth a look after a rebrand: .main-text-gradient (the accent heading gradient, primary-800 → primary-600 in light) and .primary-focus (the keyboard focus ring, primary-500).
The one documented exception
Brand logos keep their owners’ colours. The integrations orbit on the home page draws nine marks in their real palettes, because a monochrome Slack mark is not a Slack mark. Everything else on the site — including the five-logo wall, whose marks are drawn monochrome and carry fill="currentColor" so they follow the theme — goes through tokens.