
Quick answer: CSS has many ways to write a colour — hex, named, rgb(), hsl() and the modern oklch() — and color-mix() derives a consistent palette from a few base colours. For sizes, use rem for text and spacing, ch for line lengths, and pick the viewport unit that matches the behaviour you want: dvh, svh or lvh.

Table of Contents

CSS color-mix(): colour syntax, then and now
.a { color: #0f766e; } /* hex */
.b { color: rgb(15 118 110); } /* rgb, space-separated */
.c { color: hsl(175 80% 24%); } /* hsl: hue, saturation, lightness */
.d { color: oklch(48% 0.09 180); } /* perceptually uniform */
.e { color: rgb(15 118 110 / 0.6); } /* with transparency */The modern space-separated syntax inside rgb() and hsl() is the current style; the old comma form still works. oklch() is worth learning because its lightness channel is perceptually even — two colours with the same lightness value actually look equally light, which is not true in HSL.
Derive a palette with CSS color-mix()
Instead of hard-coding ten shades of one brand colour, define the base and mix the rest. Change the base once and the whole palette follows.
:root {
--brand: oklch(55% 0.12 180);
--brand-light: color-mix(in oklch, var(--brand), white 35%);
--brand-dark: color-mix(in oklch, var(--brand), black 25%);
}
.button { background: var(--brand); }
.button:hover { background: var(--brand-dark); }The interpolation space matters. Mixing in oklab or in oklch blends through a perceptually even path, so the midpoint of teal and white looks like teal, not a muddy grey — which is what happens in the default sRGB space.

Units: which one, and when to use CSS color-mix()
| Unit | Relative to | Use for |
|---|---|---|
rem | The root font size | Text, spacing, most sizing |
em | The element’s own font size | Padding that scales with its text |
ch | The width of the 0 character | Line length limits |
% | The parent’s matching size | Fluid widths |
vw / vh | The viewport | Full-screen sections |
dvh | Dynamic viewport height | Mobile full-height layouts |
Prefer rem for anything a user might want to zoom: because it is relative to the root font size, changing the browser’s default text size scales the whole layout proportionally. Use ch to set a comfortable reading width — max-inline-size: 65ch is roughly the classic 60-to-75 character target.
The viewport-unit trap on mobile
On phones, browser bars appear and disappear as you scroll, which changes the visible height. The classic vh corresponds to the large viewport and can push content under the URL bar. Three newer units give you control:
.hero {
min-height: 100svh; /* small viewport: never hidden behind browser UI */
/* 100dvh follows the bars as they move, 100lvh assumes they are gone */
}svh— the smallest viewport, stable while the bars are visible. Best for a first full-screen section.dvh— tracks the bars as they slide, but can resize during scrolling.lvh— the largest viewport, as if no bars were present.
For most full-height sections, svh gives the least surprising result on a phone. Test all of them in a device emulator with the browser bar shown and hidden.
Next: Flexbox. To learn CSS from the ground up with a mentor, Ampersand Academy teaches web development one-to-one.
CSS color-mix() browser support and debugging
Treat color-mix() as a computed value, not a stored one. Every use of it is recalculated by the browser whenever an input changes, which is what makes themed palettes so convenient and what makes debugging confusing: the value you see in the inspector is the resolved colour, not the expression you wrote, so two elements that look identical may come from entirely different source rules. When a derived shade looks wrong, go back to the declaration and check three things — the interpolation space, the percentage direction, and whether a fallback fired because the function was unsupported in that context. CSS color-mix() errors almost always live in one of those three places.
Percentage direction is the mistake beginners make most. color-mix(in oklch, var(--brand), white 35%) means the result is 35 per cent white, not 35 per cent brand, and swapping the order silently changes how far the mix travels. Writing both percentages explicitly removes the ambiguity entirely: a mix of 65 per cent brand and 35 white reads the same to a reviewer as it does to the browser. Also remember that mixing in the default sRGB space goes through grey, so a teal lightened towards white can pass through a muddy midpoint; oklab and oklch avoid that path and are the sensible default for any CSS color-mix() palette work.
Browser support has been broad for several years, but graceful degradation is still worth designing. Because an unsupported declaration is discarded, the property falls back to its previous value or the inherited one, so place a plain CSS colour declaration before the CSS color-mix() line and older engines receive a usable colour rather than nothing at all. In practice this means writing the fallback first and letting the modern line override it, a pattern that costs one extra line and removes a whole category of bug reports. Where the fallback matters most is brand surfaces — buttons, banners, navigation — because those are the elements users notice when they render unstyled or transparent.
For debugging, the browser tools are enough. Inspect the computed colour, then use the colour picker to see the exact value and its position in the gamut; if the result is out of gamut on a wide-gamut display, the browser clips it and the visual result will not match the maths. Test the same declaration inside a @supports query when you need to branch on the function itself, though most projects do not need to because the fallback-first pattern already handles both worlds. When a whole palette looks off, check the root custom property: one wrong base colour propagates through every CSS color-mix() expression that reads it, and the mix is innocent.
Keep the palette small and the naming honest. Three base colours and a handful of derived shades generated with CSS color-mix() outperform a folder of hard-coded hex values that nobody dares touch, because the relationship between a shade and its base is expressed in the file rather than memorised. Name tokens for their role — brand, surface, border, text — not for their appearance, so a rebrand is a one-line change at the root. The same discipline applies to units in the same stylesheet: pair the palette with rem for spacing and ch for line length, and the design becomes a set of rules you can reason about rather than a collection of exceptions.

Understanding precision and rounding in css color-mix
When browsers evaluate css color-mix, they first convert each operand to the chosen colour space, then perform linear interpolation on floating‑point values. The intermediate result is kept in high precision, but the final step writes the colour back to the CSS cascade, which forces a rounding to the nearest 8‑bit channel. This step explains why a mix that looks correct in a design tool can appear slightly off in the browser, especially when the two inputs lie near a channel boundary.
Rounding modes across browsers
Different rendering engines apply slightly different rounding strategies. Chrome and Edge truncate after the fifth decimal place, while Firefox rounds to the nearest integer before applying gamut clipping. The net effect is a one‑or‑two‑percent colour shift that becomes noticeable on high‑contrast UI elements. To minimise surprise, test the same css color-mix expression across Chrome, Firefox and Safari, and consider adding a tiny bias (e.g., 0.5 % of the target colour) if you need pixel‑perfect parity.
Because the result is stored as a floating‑point value, the final step rounds each channel, which can produce a 1‑2 % shift compared to manual calculations in css color-mix. Using a perceptually uniform space such as oklch reduces the visual impact of this rounding, because the lightness channel stays smooth even if the chroma or hue component snaps.
When you need absolute colour fidelity—for example in brand logos—pair css color-mix with a fallback hex that matches the reference exactly. The fallback will be used if the browser’s rounding pushes the result outside the intended gamut, providing a safety net without sacrificing the dynamic benefits of the mix.
Mixing semi‑transparent layers with css color-mix
Mixing semi‑transparent values with css color-mix follows the same linear algorithm, but the alpha channel is interpolated separately. The function treats opacity as another dimension, so a 30 % white over 70 % blue yields a result whose colour and alpha are both blended according to the specified percentages.
Alpha order matters
Remember that css color-mix treats the last percentage as the contribution of the second argument, so swapping order changes the visible opacity. For instance, color-mix(in srgb, var(--base) 20%, transparent 80%) produces a much lighter, more translucent output than color-mix(in srgb, transparent 20%, var(--base) 80%). Always write both percentages explicitly to avoid accidental inversions.
When layering a semi‑transparent overlay on top of a background colour, you can avoid extra DOM elements by letting css color-mix compute the final colour in one step. This approach reduces repaint cost and keeps the stylesheet declarative, which is particularly valuable in complex component libraries.
/* Example: 40 % translucent accent over white */
.accent {
background: color-mix(in oklch, white, var(--accent) 40%);
}
/* Example: 40 % translucent accent over white */
.accent {
background: color-mix(in oklch, white, var(--accent) 40%);
}
Testing the mixed colour with the browser’s colour picker confirms that the resulting alpha matches the expected value. If the visual result differs, verify the order of arguments and ensure that the percentages add up to 100 % when you want a proportional blend.
Robust fallback strategies for unsupported browsers
A reliable pattern places a solid fallback before the css color-mix declaration, guaranteeing a visible colour even when the function is unknown. Write the fallback as a plain colour rule, then follow it with the mixing expression; the latter will override the former only in capable browsers.
Within an @supports (color-mix: in srgb, red, blue 50%) block you can conditionally serve the advanced palette and keep legacy rules untouched, all without extra JavaScript. This technique keeps the cascade simple and ensures that older browsers never render a transparent or broken element.
/* Fallback first */
.button {
background: #0066cc; /* fallback */
}
@supports (color-mix: in srgb, red, blue 50%) {
.button {
background: color-mix(in srgb, #0066cc, #ffd700 30%);
}
}
/* Fallback first */
.button {
background: #0066cc; /* fallback */
}
@supports (color-mix: in srgb, red, blue 50%) {
.button {
background: color-mix(in srgb, #0066cc, #ffd700 30%);
}
}
If you need to support Safari < 14, which still lacks css color-mix, the fallback will be the primary colour. For Safari 14+ you can add a @media (prefers‑color‑scheme) block inside the @supports to tweak the mix for dark mode, keeping the same graceful‑degradation strategy.
When distributing a component library, document the required fallback pattern in the README so downstream developers do not accidentally remove the first line, which would leave the component invisible on older browsers.
Creating a dynamic theme switch using css color-mix
For a runtime theme switch, update a root custom property and let css color-mix recalculate all dependent shades on the fly. The browser performs the mix instantly, so the UI updates without a full page refresh or JavaScript‑driven colour replacement.
Because the mix runs in the browser, the UI updates instantly without a reflow, which is why css color-mix shines in dark‑mode toggles. You only need to toggle a single variable—such as --theme-base—and every derived token updates automatically, keeping the stylesheet DRY.
/* JavaScript toggle */
document.documentElement.dataset.theme = 'dark';
/* CSS */
:root {
--theme-base: oklch(55% 0.12 180);
}
[data-theme="dark"] {
--theme-base: oklch(30% 0.10 250);
}
.button {
background: var(--theme-base);
}
.button:hover {
background: color-mix(in oklch, var(--theme-base), black 20%);
}
/* JavaScript toggle */
document.documentElement.dataset.theme = 'dark';
/* CSS */
:root {
--theme-base: oklch(55% 0.12 180);
}
[data-theme="dark"] {
--theme-base: oklch(30% 0.10 250);
}
.button {
background: var(--theme-base);
}
.button:hover {
background: color-mix(in oklch, var(--theme-base), black 20%);
}
To avoid flash‑of‑unstyled‑content on initial load, embed the initial --theme-base value inline in the HTML <style> element generated by the server. This ensures the first paint already uses the correct colour, and the subsequent JavaScript toggle will only change the value, never replace the stylesheet.
For large applications, consider scoping the theme variables to a container element instead of :root. This allows independent sections of the page to use different palettes simultaneously, a technique useful for multi‑tenant dashboards.
Debugging edge cases and gamut clipping
When a mixed colour lands outside the target gamut, browsers clip it, and the visual output may differ from the mathematical expectation of css color-mix. This clipping is most noticeable on wide‑gamut displays, where a colour that appears soft on sRGB becomes a harsh, oversaturated hue after clipping.
Using colour‑gamut visualiser
You can detect clipping by comparing the computed value against the original mix using the colour() function as a probe, a trick useful for debugging css color-mix edge cases. Wrap the expression in color(srgb …) to force the browser to expose the unclipped result in the computed style, then read it back with JavaScript for analysis.
/* Debug snippet */
.test {
background: color-mix(in oklch, var(--brand), white 35%);
}
.test::before {
content: attr(style);
/* Force display of raw value */
color: color(srgb, var(--brand) 65%, white 35%);
}
/* Debug snippet */
.test {
background: color-mix(in oklch, var(--brand), white 35%);
}
.test::before {
content: attr(style);
/* Force display of raw value */
color: color(srgb, var(--brand) 65%, white 35%);
}
If the two colours differ, you know clipping occurred. Adjust the mix percentages or move the computation to a narrower gamut (e.g., srgb) to keep the result inside the displayable range. This approach prevents unexpected brand colour shifts on high‑end monitors.
For production code, wrap the colour‑mix in a @supports block that falls back to a pre‑computed hex when clipping is detected via a media query targeting color-gamut: p3. This hybrid strategy preserves visual fidelity across both standard and wide‑gamut devices.
Frequently asked questions
What is the best CSS colour format?
Any of them work. Hex is compact for fixed values, rgb and hsl are readable, and oklch is worth using when you want perceptually even lightness across a palette.
What does color-mix() do in CSS?
It blends two colours by a given percentage so you can derive lighter and darker shades from a base colour instead of hard-coding every variant.
Should I use rem or px for font sizes?
Use rem for text and spacing. Because rem is relative to the root font size, the layout scales when a user increases their browser text size, which is an accessibility win.
What is the difference between dvh, svh and lvh?
They describe mobile viewport heights. svh is the smallest, with browser bars visible and stable; lvh is the largest, as if the bars were gone; dvh follows the bars as they move.
How do I set a comfortable line length in CSS?
Cap the width with ch units, which measure the width of the 0 character. A value around 65ch gives the 60 to 75 character line that is easiest to read.
Last updated on · Written by Dinesh Kumar R

