Introduction
When you're developing a new feature, how do you know what color to use? What about when you have 300 unique colors distributed as 1,000 hex codes in your .scss
files? How do you ensure your colors are used consistently, or have enough contrast to be accessible? Even if a designer gives you a Figma file with clearly defined colors, it might not match other colors on the same page - it might even bring you to 301 unique colors.
In my experience, designers and developers picking individual colors has been an unsustainable strategy. In order to maintain consistency, accessibility, and development speed, we need to limit colors to a predefined set and provide meaning to them beyond a hex code.
This article summarizes how I built and used dcwither/scss-codemods to reduce a codebase's approximately 1,000 hex codes to a much more maintainable 25.
Mitigation Strategy
Often designers tire of inconsistent colors before most engineers and will work on a design library to improve brand consistency, starting with colors, spacing, typography, etc. Once designers start this, the best move for engineers is to find and codify a common naming scheme that can be used between engineers and designers. Thus design tokens are born.
For the purpose of this article, we'll use Bootstrap's design tokens found in scss/_variables.scss.
Future Proofing
Once we have design tokens, we should make sure that no new colors are added to the codebase. We can protect against this using a lint rule.
Although it's not a perfect match, stylelint's color-no-hex
works pretty well to flag this. The rule warns whenever a hex color is used, making design tokens the path of least resistance. We can pair this with a code review tool like reviewdog that only applies lint rules to new changes.
Consolidating
Adopting tokens, however, does not fix all the accumulated debt. We still have 1,000 hex codes throughout the codebase, many of which will be redundant with the design token values.
Replacing Exact Matches
The exact matches can be fixed with Find and Replace, but with 40 color tokens in our case, it becomes worth it to write a script. For this, I wrote a postcss plugin to make the job a little cleaner for future changes.
module.exports = (opts) => {
function mapColorsInString(str) {
return str.replace(
/#[0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f][0-9a-f])?/gi,
(match) => {
const mapping = opts.config.find(({ hex }) => {
return hex.toLowerCase() === match.toLowerCase();
});
if (mapping) {
return mapping.name;
}
return match;
}
);
}
return {
postcssPlugin: "hex-to-tokens",
Declaration(decl) {
decl.value = mapColorsInString(decl.value);
},
};
};
where config
represents our design tokens in the following format:
[
{
hex: "#ffffff",
name: "$white"
},
{
hex: "#f8f9fa",
name: "$gray-100"
},
...
]
This works well for projects that consistently use the same hex values throughout, but falls short when developers have used colors inconsistently. In order to solve that, we'll use the Delta E algorithm to map colors to their closest design token.
ΔE* (Delta E) Algorithm
Delta E is an algorithm that takes two colors and produces a single value that represents the distance between them. We'll use Delta E to find the closest design token to the hex colors. We only consider a match and replace it if the distance is within a desired threshold.
Delta E estimates how humans perceive the difference between two colors rather than a Euclidean distance on the RGB spectrum. It adjusts the "distance" depending on how sensitive human perception is to that part of the color spectrum. You can learn more from the delta-e package documentation.
import convert from "color-convert";
import DeltaE from "delta-e";
// DeltaE works in the CIELAB color space, so we use
// color-convert to get a CIELAB color from our RGB values
function hexToLab(hexColor) {
const [L, A, B] = convert.hex.lab.raw(hexColor.substr(1));
return { L, A, B };
}
module.exports (opts) => {
opts = {
// default values
threshold: 0,
...opts,
};
const mappings = opts.config.map((mapping) => ({
...mapping,
lab: hexToLab(mapping.hex),
}));
function mapHexColorToBestMatch(hexColor) {
const labColor = hexToLab(hexColor);
let lowestDeltaE = 100;
let bestMatch = null;
for (const mapping of mappings) {
const deltaE = DeltaE.getDeltaE00(mapping.lab, labColor);
if (lowestDeltaE > deltaE) {
lowestDeltaE = deltaE;
bestMatch = mapping;
}
}
if (lowestDeltaE <= opts.threshold) {
return bestMatch.name;
} else {
return hexColor;
}
}
function mapColorsInString(str) {
return str.replace(
/#[0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f][0-9a-f])?/gi,
(match) => {
return mapHexColorToBestMatch(match);
}
);
}
return {
postcssPlugin: "hex-to-tokens",
Declaration(decl) {
decl.value = mapColorsInString(decl.value);
},
};
};
We can run this on our codebase with a desired threshold to clean up all the really close colors. I chose a threshold of 2, mapping to changes that require close inspection to spot. We went from about 1,000 hex codes down to about 200 that were not close enough to any design tokens.
You might want to make exceptions for some colors where even subtle differences can be easily perceived (e.g.
#FFF
for white).
Manual Migration
The last step for this migration was to generate color recommendations for a product designer and engineer to review. I repeated the Delta E process but logged the closest three colors instead of replacing any.
I copied the output to a Google spreadsheet and used a script to set background colors on cells containing hex codes. The designer picked the best recommendations from the spreadsheet and added other colors where the recommendations didn't make sense.
This final step brought us from about 200 hex codes to only 25 colors outside of the design tokens. We'll need additional design efforts to replace those particular colors.
Conclusion
Throughout this project we've:
- Introduced design tokens
- Added lint rules to prevent new hex colors
- Performed an automated migration using Delta E and postcss
- Manually migrated the rest of our colors using recommendations based on Delta E
We're now in a great state to move forward with future feature development, confident that our colors match what designers expect, and better able to ensure our color contrast meets WCAG AA accessibility standards.
If you're interested in doing the same kind of migration, check out dcwither/scss-codemods. The hex-to-token
command performs the automated Delta E transform on your entire codebase. The manual migration is not yet supported.
dcwither / scss-codemods
SCSS codemods written with postcss plugins
scss-codemods
This project uses postcss to refactor scss code to conform to lint rules that are intended to improve grepability/readability.
Installation
Globally via npm
npm i -g scss-codemods
Running on-demand
npx scss-codemods [command] [options]
union-class-name
"Promotes" CSS classes that have the &-
nesting union selector. Attempts to fix issues flagged by scss/no-union-class-name stylelint rule.
e.g.
.rule {
&-suffix {
color: blue;
}
}
// becomes
.rule-suffix {
color: blue;
}
Intended to improve "grepability" of the selectors that are produced in the browser.
Usage
scss-codemods union-class-name --reorder never <files>
Options
--reorder
Determines the freedom provided to the codemod to reorder rules to better match the desired format (default: never
).
Values:
-
never
: won't promote rules if it would result in the reordering of selectors. -
safe-only
: will promote rules that result in the reordering of selectors as long as the reordered selectors…
Top comments (0)