When Sass Colours Break After a Next.js Production Build

In Brief
If a calculated Sass colour works in development but disappears after a successful Next.js production build, inspect the final rgb() value. Sass 1.101.4 changed fractional legacy RGB channels to percentages. Next.js's CSS optimisation can then remove % from a zero channel, leaving invalid mixed‑unit comma‑separated syntax. Round calculated RGB channels before minification, and test the Sass compiler and production optimiser together.
Earlier this week I became aware of an issue here on my personal website. Towards the bottom of every Case Study is a component which displays the following case study with a screenshot, a short description, and using that project's theme colours. Some of these themes had become corrupted with missing background colours or incorrect text colours, which ‑ bizarrely ‑ corrected themselves when hovered over.
The SCSS was present and had not been changed in some time; the theme data was correct, and the production build had completed successfully. Everything looked fine during development but somehow, these malformed components were sullying my pages.

The fact that this all worked locally but broke in production offered a useful clue. When a style works locally but disappears from production output, it is unlikely that it is the browser that is overriding it. It's more likely that the declaration has become invalid somewhere between the Sass compiler and the final CSS output.
If the production command itself exits with an error, we would start by finding the first real build failure, but this was a different problem: a green build emitted a styling declaration that the browser could not use.
In this case, a routine Sass dependency update had exposed an older unit‑stripping problem in the CSS minification used by Next.js. Both tools received valid input and made individually understandable decisions. Together, they produced an invalid rgb() value, which the browser then quite correctly ignored.
Starting with the Smallest Reproduction
The original styles came from a larger theme map, but the failing behaviour only needs one calculated colour:
@use 'sass:color';.panel { background: color.adjust(#001e48, $lightness: 5%);}With Dart Sass 1.101.0, that compiles to numeric RGB channels:
.panel { background: rgb(0, 40.625, 97.5);}With Dart Sass 1.103.1, the same SCSS compiles to percentage channels instead, like this:
.panel { background: rgb(0%, 15.931372549%, 38.2352941176%);}Both declarations are valid CSS and represent the same colour closely enough for this purpose. The change was intentional; Dart Sass 1.101.4 changed legacy RGB serialisation so that a colour containing a fractional channel uses percentages rather than non‑integer numeric channels, which retains precision whilst remaining compatible with older implementations (which only accept integers in the numeric form).
The trouble appears in the next step. Next.js runs the compiled stylesheet through its production CSS optimiser. The percentage form can then become:
.panel{background:rgb(0,15.931372549%,38.2352941176%)}Only the first channel has changed. 0% has become 0, an optimisation which is harmless in many CSS properties because a zero length usually does not need its unit. However, in RGB colours, this is not harmless.
Why the Browser Drops the Colour
CSS supports two forms of rgb() syntax. The modern form uses spaces and permits numbers and percentages to be mixed:
color: rgb(0 15.93% 38.24%);The legacy form uses commas. In that form, all three colour channels must be numbers or all three must be percentages, they cannot be mixed:
/* Valid legacy forms */color: rgb(0, 41, 98);color: rgb(0%, 15.93%, 38.24%);/* Invalid legacy form */color: rgb(0, 15.93%, 38.24%);This is made really explicit in the CSS Color Module Level 4 grammar: mixed numeric and percentage channels are allowed in the modern space‑separated form, but not in the legacy comma‑separated form.
The browser is therefore not choosing the wrong background, it simply has no valid background declaration from that rule to apply. It discards the value and diligently continues through the cascade. Depending on the component, the visible result may be a transparent panel, an inherited text colour, a shared fallback colour, or ‑ in my case ‑ a hover state which appears to repair the component because that state happens to use a different declaration.
This is why the screenshots initially looked like several unrelated defects. The backgrounds, text, gradients, overlays, and hover states all consumed theme colours at different points. The malformed declaration was the common cause whilst the cascade determined how each failure looked.
Why It Appeared After a Routine Upgrade
This unit‑stripping behaviour is not new. A Next.js discussion from March 2024 demonstrates CSSnano changing initial-value: 0% to initial-value: 0 inside an @property rule. The context here is different, but the unsafe assumption is the same: the optimiser removes a unit without considering the grammar which gives that unit meaning in the first place.
What changed recently was the input supplied to that optimiser.
In my project, the lockfile stayed on Sass 1.101.0 until a dependency update at the start of the month moved it to 1.103.1. The behaviour which matters here had landed in Sass 1.101.4 on 22 July, but the pinned lockfile meant the site did not receive it until now.
If we compare the relevant Sass and Next.js versions independently, the issue becomes fairly obvious:
| Sass version | Next.js minifier | Sass colour form | Result after minification |
|---|---|---|---|
| 1.101.0 | 16.2.10 | Numeric channels | Valid |
| 1.101.0 | 16.3.4 | Numeric channels | Valid |
| 1.103.1 | 16.2.10 | Percentage channels | Invalid mixed units |
| 1.103.1 | 16.3.4 | Percentage channels | Invalid mixed units |
This matters because Next.js was upgraded at the same time. Blaming the largest or most visible dependency change would have been easy. It would also have been wrong. Both tested Next.js minimisers mishandled the newer percentage input, whilst neither broke the older numeric input.
There is another awkward detail. The Next.js CSS minimiser calls its bundled cssnano-simple with colormin disabled. You can see that in the Next.js CSS minimiser source. Disabling colour minification does not disable every value optimisation, so another pass can still remove the % from zero without converting the complete colour to a safe hexadecimal value.
The practical lesson is not that Sass 1.101.4 is broken; its output is valid, and its release note explains the compatibility reason for the change. The failure sits in between two tools; the old optimiser assumption only became visible when a newer compiler began producing a valid form it had not handled safely.
Fix the Colour Before It Reaches the Optimiser
For ordinary sRGB theme colours, the simplest fix is to round each calculated channel to a whole number before Sass serialises it, like this:
@use 'sass:color';@use 'sass:math';@function normalise-colour($colour) { @return color.change( $colour, $red: math.round(color.channel($colour, 'red', $space: rgb)), $green: math.round(color.channel($colour, 'green', $space: rgb)), $blue: math.round(color.channel($colour, 'blue', $space: rgb)), $space: rgb );}The sass:color module lets us read the channels explicitly in the RGB colour space, then color.change() returns the colour with those rounded values. Applying the helper to the earlier example:
.panel { background: normalise-colour( color.adjust(#001e48, $lightness: 5%) );}now produces a stable hexadecimal value:
.panel { background: #002962;}There is no percentage zero for the optimiser to shorten, and no fractional channel for Sass to preserve through percentage serialisation.
Rounding changes an affected channel by no more than half of one 8‑bit RGB step, which is more than an acceptable trade‑off for conventional interface theme colours. It is not a universal colour‑processing function. Please don't go blindly applying it to wide‑gamut colours, missing channels, or colour spaces such as display-p3, lab, or oklch; converting those to rounded sRGB may discard information deliberately retained by the design.
If you're in a situation where the colour must remain absolutely exact, then store the approved output as a literal rather than deriving it. My helper above is most useful when a system intentionally generates a family of sRGB colours with Sass.
Apply the Fix at the Theme Boundary
Fixing the one panel visible in a bug report is tempting. It also leaves the next randomly selected card, hidden project, gradient, or overlay waiting to fail.
Apply normalisation wherever a calculated theme colour crosses from Sass data into emitted CSS:
@each $theme, $data in $themes { $background: normalise-colour(map.get($data, 'panelBackground')); $text: normalise-colour(map.get($data, 'panelText')); .panel--#{$theme} { background: $background; color: $text; &:hover { background: normalise-colour( color.adjust($background, $lightness: -5%) ); } }}In the real component set, that meant checking more than just the default card state. The same theme values fed full‑width footer gradients, image overlays, metadata panels, carousel treatments, and hover states.
As you might appreciate, this is an excellent place for a shared function. It gives every theme consumer the same output contract and leaves the reason in one comment instead of scattering mysterious math.round() calls around component stylesheets. The broader case for centralising that contract is covered in Managing Design Values in Front‑End Code.
Test the Compiler and Minifier Together
A Sass unit test on its own is not enough. Sass produced valid CSS throughout this incident. The failure only appeared after a second tool transformed that output.
The regression test should therefore compile representative SCSS and pass the result through the same optimiser configuration used by Next.js, something like this:
import postcss from 'postcss';import { compileString } from 'sass';// Next.js does not publish types for this bundled internal module.// eslint-disable-next-line @typescript-eslint/no-require-importsconst cssnanoSimple = require('next/dist/compiled/cssnano-simple');const getMixedUnitLegacyRgbValues = (css: string) => [...css.matchAll(/rgba?\(([^)]*)\)/g)] .map(([, channels]) => channels) .filter((channels): channels is string => !!channels) .filter((channels) => { const colourChannels = channels.split(',').slice(0, 3); const containsPercentage = colourChannels.some((channel) => channel.includes('%') ); return ( containsPercentage && colourChannels.some((channel) => !channel.includes('%')) ); });it('keeps calculated theme colours valid after minification', async () => { const compiled = compileString(` @use 'sass:color'; @use 'sass:math'; @function normalise-colour($colour) { @return color.change( $colour, $red: math.round(color.channel($colour, 'red', $space: rgb)), $green: math.round(color.channel($colour, 'green', $space: rgb)), $blue: math.round(color.channel($colour, 'blue', $space: rgb)), $space: rgb ); } .panel { background: normalise-colour( color.adjust(#001e48, $lightness: 5%) ); } `); const minified = await postcss([ cssnanoSimple({ colormin: false }, postcss), ]).process(compiled.css, { from: undefined }); expect(getMixedUnitLegacyRgbValues(minified.css)).toEqual([]); expect(minified.css).toContain('#002962');});Importing a bundled Next.js module is deliberately coupled to a framework implementation detail. I am comfortable with that in this particular regression test because the implementation detail is the thing which broke the CSS. If Next.js moves or replaces the optimiser, the test will need to change. A slower but less coupled alternative is to build a fixture application and inspect its emitted CSS.
For a shared theme system, compile every stylesheet which consumes the theme rather than testing one invented selector. Assert that known variants are present, then scan the complete minified result for mixed‑unit legacy RGB values. That catches a future colour added to a rarely displayed component as well as the examples which originally exposed the fault.
What Reviewers Should Look for
This failure has a fairly distinctive signature. A human or automated pull‑request reviewer should investigate when several of these conditions appear together:
- Sass is upgraded across version 1.101.4, or the lockfile starts resolving a later version.
color.adjust(),color.scale(),color.mix(), or another calculation produces fractional RGB channels.- The interface works during development but loses colours after production CSS optimisation.
- The final asset contains comma‑separated
rgb()orrgba()with a mixture of percentages and unitless channels. - A missing background exposes a parent colour, whilst a separate hover or focus declaration appears correct.
- The proposed repair only patches the currently visible variant instead of every consumer of the calculated theme value.
Do not recommend !important; specificity is not the problem. Do not assume the browser needs a fallback for percentage RGB; the pre‑minified value is valid. Disabling CSS minification or pinning an old Sass version may be useful as a short‑lived incident response, but neither deals with the unsafe boundary permanently.
Also check the lockfile, not only package.json. A permissive version range tells you what may be installed. The lockfile tells you which compiler produced the broken asset.
The broader Next.js production triage checklist is useful when you have not yet isolated the failing layer. Once the evidence points at generated CSS, reduce the case to compiler input, compiler output, and optimiser output. Three small artefacts are usually more informative than another round of selector changes.
Wrapping Up
The difficult part of this bug was not the Sass function or the eventual fix; it was recognising that the declaration could be valid after compilation and invalid after optimisation.
Sass 1.101.4 made a reasonable compatibility change. Next.js continued using an optimisation path with an older assumption about zero units. A dependency update brought those behaviours together, and the browser exposed the result through several apparently unrelated visual defects.
When generated colours disappear only in production, inspect the final CSS before changing the cascade. If calculated sRGB channels are crossing an unreliable optimiser boundary, normalise them once, apply that contract to every theme consumer, and keep a regression test around the complete transformation. That is much safer than fixing whichever card happened to be visible when somebody noticed.