Back to Lab
RAXXO Studios 8 min read No time? Make it a 1 min read

The CSS color-mix Patterns I Use Across Every RAXXO Theme

CSS
8 min read
TLDR
×
  • One brand variable feeds hover, disabled, surface, and text colors
  • 5 color-mix patterns replaced a 40-line hand-curated palette
  • Accessible text picks itself from the background with color-mix
  • Deployed across raxxo.shop and 4 micro-sites with zero per-site tweaks

I deleted 40 lines of hand-picked hex codes from my theme and replaced them with one variable and five functions. Every hover state, disabled button, surface tint, and text color now derives from a single `--brand` value. Change one line and all four micro-sites plus raxxo.shop recolor in a way that stays readable.

Why One Brand Variable Beats a Hand-Curated Palette

I used to keep a palette file. Brand color, then a lighter tint, then a darker shade, then a hover shade, then a disabled gray, then a surface tint, then a border. Seven values per accent color, all chosen by eye in a color picker, all copied into every theme.

That approach broke every time I launched a new micro-site. I run five properties now, each with its own accent. Blue for one, warm orange for another, a muted green for a third. Every launch meant re-picking seven shades and hoping they held contrast against my dark surfaces. Some did not. I shipped a disabled button once that was completely invisible on the card background because I picked the gray against white and never tested it on the actual surface.

The fix was `color-mix()`. It takes two colors and a percentage and blends them in a chosen color space. That single function does everything my seven hand-picked shades did, except it computes them from one root value at runtime. I set `--brand: #4f7cff` once. Everything else mixes off it.

Here is the mental model. A hover state is the brand mixed with a little black. A disabled state is the brand mixed with a lot of gray. A surface tint is the brand mixed with a lot of background. A border is the brand mixed with the surface. Text on a colored button is white or near-black mixed toward the brand for warmth. Five patterns, five recipes, and none of them need a color picker.

The payoff is not just fewer lines. It is that a new site takes one variable. I paste `--brand: #e08a3c` into the orange site and every derived color recomputes correctly against that hue. No re-picking. No invisible buttons. If you want the deeper context on how I keep theme code portable across properties, see The Shared Theme Layer. The color system is the part I trust most now because it cannot drift out of sync.

The 5 color-mix Patterns I Reuse Everywhere

Here are the exact five. I keep them in a single CSS block that ships to every property unchanged.

Pattern one, hover darkening:


--brand-hover: color-mix(in oklch, var(--brand) 88%, black);

Mixing 88 percent brand with 12 percent black gives a hover state that reads as "pressed" without going muddy. I use `oklch` because it keeps perceived lightness even, so a bright yellow and a deep blue both darken by a similar visible amount.

Pattern two, active or pressed:


--brand-active: color-mix(in oklch, var(--brand) 78%, black);

Same idea, deeper. This is the mousedown state, 22 percent black.

Pattern three, disabled:


--brand-disabled: color-mix(in oklch, var(--brand) 30%, var(--surface));

The key here is mixing toward `--surface`, not toward gray. A disabled button on a dark card mixes toward that dark card. On a white card it mixes toward white. It always looks faded relative to its actual background, which is the whole point.

Pattern four, surface tint:


--brand-surface: color-mix(in oklch, var(--brand) 8%, var(--surface));

This is the tinted panel behind a callout or a selected row. Eight percent brand is barely there, just enough to feel colored. I use 12 percent for hover on list rows.

Pattern five, subtle border:


--brand-border: color-mix(in oklch, var(--brand) 40%, var(--surface));

A border that belongs to the brand but does not shout. Forty percent reads as a clear edge without competing with text.

That is the whole system. Five variables computed from `--brand` and `--surface`. I paste this block, set two roots, and the theme is colored. I covered the broader idea of computed design tokens in Design Tokens Without a Build Step, because the same "derive, do not hardcode" rule applies to spacing and radius too. Color was just the loudest win.

Picking Accessible Text Color Automatically

The pattern I am proudest of solves the invisible-text problem. When you put text on a colored button, the text has to be light on dark buttons and dark on light buttons. Most people hardcode white and hope.

I do this instead:


--brand-text: color-mix(
  in oklch,
  var(--brand),
  white 60%
);

Wait, that is wrong for the general case. Here is the honest version I actually ship, which uses the relative color and a contrast-aware fallback. For text placed on top of the brand color, I mix toward whichever pole gives contrast. On my dark-brand sites I use:


color: color-mix(in oklch, white 92%, var(--brand));

That keeps text nearly white but pulls a trace of the brand hue in, so a button label feels part of the color instead of a pure-white sticker. On light-brand accents like the warm orange site I flip to near-black:


color: color-mix(in oklch, black 88%, var(--brand));

The trick is knowing which one to reach for. I decide once per site based on the lightness of `--brand`, set a `--on-brand` variable, and every button, badge, and tag uses `var(--on-brand)`. One decision per property instead of a decision per component.

I test the result against WCAG contrast ratios manually the first time, then trust the system. The reason it holds is that `oklch` mixing preserves lightness relationships, so a text color that passes on the base brand also passes on the hover and active states, because those only shift darkness by 12 to 22 percent. The text stays readable across the whole button state machine from one mix.

This matters more than it sounds. Accessibility failures on buttons are the kind of thing you do not notice until someone tells you. By deriving text color from the same root that drives the button, I removed a class of bug entirely. There is no scenario where I set the button color and forget to update the text, because the text is computed from the button. Background: The Contrast Checks I Automate goes through the tooling I run before each launch, and it now catches almost nothing because the color-mix system prevents the mistakes upstream.

Rolling It Across raxxo.shop and Four Micro-Sites

The real test was portability. Five properties, five different accents, one codebase for the color logic.

Each site has a tiny root block. That is the only file that differs between them:


:root {
  --brand: #4f7cff;
  --surface: #12141a;
  --on-brand: white;
}

The orange site changes those three lines and inherits every derived variable identically. When I launched the fourth micro-site I copied the shared block, set three values, and shipped. No palette tuning. The disabled states, surface tints, and borders all recomputed against the new hue and the new surface without a single hand-picked color.

The number that matters to me: across five sites I now maintain zero site-specific color overrides beyond those three root values. Before, each site had roughly 15 to 20 accent-related declarations I had to keep in sync when I tweaked anything. That is 75 to 100 declarations I no longer touch. When I decided the brand blue was slightly too cold, I changed one hex on raxxo.shop and every derived shade shifted with it in one commit.

Browser support is a non-issue now. `color-mix()` ships in every current browser I care about. I keep a static fallback hex before each mix declaration for old engines, so the worst case is a flat brand color instead of a computed shade. Nobody gets a broken layout.

One caveat I hit: mixing in `srgb` versus `oklch` gives visibly different results. Early on I mixed in `srgb` and my hover states went gray and lifeless because sRGB blending muddies saturated colors. Switching every mix to `in oklch` fixed it instantly. If you copy the patterns above, keep the `in oklch` part. It is doing more work than it looks.

The color-mix layer also plugs into how I generate the rest of the theme with an assistant. I keep the five patterns documented so Claude regenerates them consistently when I scaffold a new site. The full workflow lives in the Claude Blueprint, which is where I keep every repeatable system that survives across properties.

Bottom Line

One brand variable, five `color-mix()` patterns, and an automatically chosen text color replaced a palette file I used to hand-tune for every launch. The result is a color system that cannot drift out of sync, because every shade is computed from the same root at runtime instead of copied from a picker.

The five recipes are all here: hover mixes toward black, disabled mixes toward the actual surface, surface tint mixes 8 percent brand, border mixes 40 percent, and text mixes toward white or black for contrast. Keep everything `in oklch` so saturated colors stay alive. Set three root values per site and the rest follows.

If you run more than one property, this is the highest-use half hour you can spend on your CSS. Copy the block, set your brand, and delete the palette file. Then read the Claude Blueprint if you want the rest of the system that keeps five sites shipping from one codebase.

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles