Colors & Theming
Finly’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 palette is teal on a warm olive canvas: brand teal for action, a cool blue-slate for every text tone and dark surface, and a custom olive ramp that makes white cards read as paper.
The three layers
Layer 1 — palette aliases (src/styles/tailwind-theme.css, inside @theme). Your brand names, pointed at Tailwind’s own ramps:
@theme {
--color-primary-500: var(--color-teal-500);
--color-base-800: var(--color-gray-800);
/* …50 through 950 for each */
}
Two full ramps, primary and base. Repointing --color-primary-* at another Tailwind ramp rebrands the whole site — that is the entire job of this layer.
Layer 2 — semantic runtime variables (src/styles/global.css, in @layer base). Plain CSS custom properties on :root and .dark, mapped onto the aliases:
:root {
--background: var(--color-olive-200);
--foreground: var(--color-base-800);
--card: var(--color-white);
--primary: var(--color-primary-500);
--primary-foreground: var(--color-base-950);
/* … */
}
.dark {
--background: var(--color-base-950);
--card: var(--color-base-900);
--primary: var(--color-primary-400);
/* … */
}
This is the layer that flips. Everything below it is the same in both themes.
Layer 3 — the bridge (tailwind-theme.css, inside @theme inline). This is what turns those runtime variables into utilities:
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
/* … */
}
inline is required, and it is the one piece of syntax people get wrong. Without it, Tailwind substitutes the variable’s value at build time and the utility freezes to whatever the light theme said. With it, bg-primary compiles to background-color: var(--primary), which resolves at runtime — so the same class renders teal-500 in light and teal-400 in dark, and dark mode costs nothing per component.
The rule for markup
Use bg-primary, text-foreground, border-border, text-base-700. Never bg-teal-500 or text-gray-300 — a raw Tailwind color bypasses both the theming and the dark mode, and it will look right in review and wrong the moment someone toggles the theme.
The semantic set is: background / foreground, card / card-foreground, primary / primary-foreground, secondary / secondary-foreground, muted / muted-foreground, accent / accent-foreground, info, success, warning, error (each with a -foreground pair), plus border, input, outline and link.
The palette aliases (base-700, primary-200) are available where a semantic token genuinely does not exist for what you are drawing — the footer uses text-base-300 for exactly one reason, described below — but reach for a semantic token first.
The olive ramp
One ramp is defined outright rather than aliased, because Tailwind has no equivalent:
--color-olive-50: oklch(98.5% 0.004 106.5);
--color-olive-100: oklch(96.6% 0.005 106.5);
--color-olive-200: oklch(93% 0.007 106.5);
--color-olive-300: oklch(87.2% 0.009 106.5);
It is base’s lightness steps at a warm hue with near-zero chroma. It is the design’s signature: a warm greige canvas with pure-white cards floating on it, separated by a hairline border rather than a shadow. That paper contrast is what the whole light theme is built on.
It stops at 300 on purpose. Olive is a light-surface tint, and dark mode uses the cool base ink instead — warmth belongs to the light canvas, and the cool ground is what makes the teal read as luminous against it. Extend the ramp downward only if a genuinely warm dark surface is ever designed.
Dark mode is not invented
The design already ships its own dark surface in the footer and the statement band. Dark mode is that palette extended into a full elevation ladder: base-950 canvas, base-900 cards, base-800 insets. Accents step up one stop — primary-500 becomes primary-400 — to hold their brightness against the dark ground.
Dark mode is class-based, declared once:
@variant dark (&:where(.dark, .dark *));
That variant matches the element carrying .dark as well as its descendants, which is what makes the footer work. The footer is permanently dark in a design whose default theme is light, so rather than ten palette overrides it simply wears class="dark":
<footer class="dark">
Every semantic token inside then resolves to its dark value, and every ui/* primitive lands on the design’s own colours for free. In dark mode the nesting is a no-op and base-900 sits one step above the base-950 canvas, so the band still reads as a distinct surface. Nothing in that subtree needs a dark: pair.
One caveat that has bitten people: class="dark" re-points token variables, but color is an inherited computed value. Anything inside a pinned-dark subtree without an explicit colour class keeps light-mode ink and renders invisible. The NotchedCard primitive’s tone="ink" carries text-foreground for exactly that reason, and it is not redundant.
The theme is set before paint
BaseHead ships an inline script that runs before first paint:
function initTheme() {
let saved = null;
try { saved = localStorage.getItem("colorTheme"); } catch { /* storage blocked */ }
document.documentElement.classList.toggle("dark", saved === "dark");
}
initTheme();
document.addEventListener("astro:after-swap", initTheme);
Three things about it are deliberate.
Light is the default, and prefers-color-scheme is not consulted. Only a saved "dark" — written by the ThemeToggle primitive — turns dark mode on. A first-time visitor always lands on the light theme the design was built around, whatever their OS is set to. If you want the site to follow the device instead, that is a two-line change here plus a siteSettings flag at the toggle’s call site.
It reads localStorage defensively. Accessing it throws in a sandboxed iframe or when site data is blocked, and this runs pre-paint at the top level — an unguarded throw would also skip the listener below it.
It stays inline. Moving it into a bundled <script> reintroduces a flash of the wrong theme, because the bundle loads after first paint. The astro:after-swap listener re-runs it across view transitions.
The contrast check
scripts/theme-contrast.test.mjs asserts that all 45 semantic text pairs clear WCAG AA, in both themes, and it runs as part of pnpm test:
ok dark --muted-foreground on --card — 6.82:1 (needs 4.5)
…
All 45 theme contrast pairs pass WCAG AA.
It exists because the token layer is the one place a rebrand silently breaks accessibility. Repointing --color-primary-* at a lighter ramp, or nudging --muted-foreground one stop, is a one-line edit whose damage is invisible until someone cannot read a button.
It resolves the real token graph rather than a hard-coded copy of it — Tailwind’s own palette, then the aliases and the olive ramp, then the :root and .dark blocks — so it reads the same source of truth the browser does and cannot drift out of step with it. Contrast is computed properly: oklch to linear sRGB, then WCAG relative luminance.
Three of the shipped decisions come directly from it:
--primary-foregroundis ink, not white. White on the design’s teal is 2.4:1 and fails AA; ink on it clears 7:1.--linkiscyan-800in light rather than the design’scyan-700, which only reached 4.30:1 on the olive canvas.- The footer’s small print renders at
--muted-foreground(6.82:1) rather than the design’sbase-500(3.67:1).
If you rebrand and the check fails, it is telling you the truth. Move a stop rather than lowering the threshold.
Rebranding, in order
- Repoint
--color-primary-*intailwind-theme.cssat another Tailwind ramp — one line per stop. - Adjust the olive ramp if your canvas is not warm, or delete it and point
--backgroundand--mutedatbasesteps for a neutral surface. - Check the semantic mappings in
global.css:--primary-foregroundin particular, since whether ink or white sits on your brand colour depends entirely on the colour. - Run
pnpm test. The contrast check will name any pair that no longer clears AA, in the theme where it fails. - Open
/examples/ui/and toggle light and dark. A missing token shows up instantly as an un-themed element.
The radius scale lives beside the colours and rebrands the same way: --radius: 1rem in :root, with radius-xs through radius-3xl derived from it in the @theme inline block. Changing that one value moves every corner on the site.