Migrating a Legacy Palette to Color Scales
The redesign shipped with one blue. Eighteen months of feature work later, a
designer preparing the next visual pass runs a grep over the stylesheets and
finds 63 distinct color values — 19 of them blues, no two identical, none
documented. Nobody decided this. Each value was a reasonable local choice: an
eyedropper pick from a mockup, a lighten() call, a hover state matched by
eye late on a Friday. The palette didn’t fail; it accreted.
Refactoring that legacy palette into color scales is a five-step migration: inventory every color value in the codebase, cluster the values by the job they do rather than by how they look, generate fresh ramps seeded from the canonical brand color, map each legacy value to its nearest step on the new ramps, and migrate incrementally through tokens with old and new visible side by side. Most legacy values land within a just-noticeable difference of a generated step; the handful that don’t are design decisions to make, not conversions to automate. This article walks the five steps with a worked mapping; the ramp machinery underneath is covered across our color scales guide.
Why do codebases accumulate so many colors?
Because each color decision is made locally, and nothing checks it against the whole. The developer matching a border from a mockup has no way of knowing that an almost-identical border blue already exists three stylesheets away — and two blues a hundredth apart in lightness cannot be told apart on sight, so code review cannot catch the duplication either. Each borrowed or invented value is a small loan against a future cleanup — the accumulation dynamic software engineering calls technical debt — and color pays its interest in confusion: after enough loans, nobody can say which blue is the real one.
The count itself is ordinary. Auditing a product’s stylesheets typically turns up somewhere between 40 and 80 distinct color values doing roughly fifteen jobs — the same shape a spacing audit finds, many values serving few intentions. The number is not the disease; the missing structure is.
How do you inventory a legacy palette?
Mechanically. Grep the codebase for hex values, rgb(), hsl() and named
colors, normalize case and format, and count occurrences per value. Include
component props and inline styles, not just the CSS — drift hides in
JavaScript. And record where each value occurs, because the next step needs
context, not just coordinates.
For the 63-value codebase above, the distribution tells the story before any clustering does: the top value appears a few hundred times, the median value three times, and 22 values appear exactly once. A value used once is almost always drift — a one-off match that never got reconciled with anything.
Should you cluster by role or by similarity?
By role. Similarity clustering — sorting the 63 values by hue and lightness — feels natural and answers the wrong question: it will happily merge a disabled-button fill with a border that happens to share its gray. The question that matters for each value is what is this trying to be? Which of the 19 blues are attempts at the brand blue? Which are borders? Which are selected-row washes? Which are link text that someone darkened to pass contrast?
Sorted this way, the 63 values collapse into roughly fifteen roles — a brand core, a hover on it, two or three border weights, a family of tints, text on those tints, a handful of neutrals, the functional reds and greens. That cluster count is the honest size of the palette. Everything beyond it is noise around those intentions, which is what makes the migration tractable: you are not replacing 63 colors, you are replacing fifteen jobs.
Which color should seed the new ramps?
The canonical brand color — usually the logo’s, not the most-used variant in
the code. The most-used value is frequently itself drift: a slightly-off copy
that won by replication rather than by decision. For this codebase the
canonical blue is #2563eb, which is oklch(0.546 0.215 262.9).
One seed is enough. Generated natively in OKLCH, it becomes a ten-step ramp — lightness stepping from 0.97 down to 0.25, chroma shaped to peak at the brand core and capped under the gamut ceiling, steps labeled 50–900 — and the same machinery derives a tinted neutral scale and functional colors alongside. The point of generating rather than re-curating: every new step has a stated reason, which is precisely what the legacy values lacked.
How do you map legacy values to the new steps?
Convert each legacy value to OKLCH and find its nearest step. Here are five of the 19 blues against the generated ramp:
| Legacy value | OKLCH (L / C) | Nearest step | Distance |
|---|---|---|---|
#4D80E9 | ≈0.62 / 0.17 | 500 #4E82EE (≈0.63 / 0.17) | ΔL ≈0.008 — under one JND |
#3B82F6 | ≈0.62 / 0.19 | 500 #4E82EE | ΔL ≈0.002, hue ≈3° off |
#7FA9F2 | ≈0.73 / 0.12 | 400 #7DA5F2 (≈0.72 / 0.12) | ΔL ≈0.010 — under one JND |
#1E56D6 | ≈0.50 / 0.21 | 600 #3A64BA (≈0.52 / 0.14) | ΔL ≈0.017 — but ΔC ≈0.06, visibly more vivid |
#17A2B8 | ≈0.66 / 0.11, hue 212 | — | ≈50° away in hue: a decision, not a mapping |
Scale Composer warns when neighboring steps come within 0.02 of each other in lightness — a working just-noticeable difference — and the same threshold reads this table. The first three rows are conversions: swapping them for their steps is invisible, and most of a typical inventory looks like them. The fourth row is borderline — its lightness maps cleanly but it carries visibly more chroma than the step, so someone has to decide whether that extra vividness ever meant anything. And the teal is the real find: fifty degrees from the palette’s hue, it is either an undocumented secondary color or six-year-old drift, and no distance metric can say which. Expect a handful of these per migration; they are the migration’s actual design work.
Open a legacy-to-ramp mapping in Scale Composer — an imported legacy token file with its values alongside the generated ramp, the within-a-JND matches and the genuine outliers visible at a glance.

Can the migration be incremental instead of big-bang?
Yes — and tokens are what make it so. During the transition, keep legacy
names alive as aliases pointing at their mapped steps: $blue-old resolves
to brand/500, both visible in the same file, and call sites migrate to
the new names at their own pace instead of in one hazardous sweep.
Scale Composer’s import supports this directly: an existing DTCG tokens file loads as-is, the color sections can be adjusted against the generated ramps, and everything you didn’t touch survives the round-trip unchanged — load, save and load again is a tested fixpoint. In practice that means typography and spacing tokens pass through byte-identical while color migrates, so the first pull request can be one ramp, not the whole system.
Will users notice the new palette?
Honestly: maybe — and pretending otherwise is how migrations get rolled back. Each individual shift in the table above is invisible; that is what mapping within a JND means. But forty near-JND shifts on one screen are not forty independent events. If most of them lean the same way — slightly cooler, slightly lighter — the page’s overall temperature changes even though no single element visibly did. Screenshot-diff the key screens before and after, and review the diffs as a design decision rather than dismissing them as noise. Usually the new rendering is also the better one — the shifts point toward the curve instead of away from it — but that is a call to make with eyes open.
The least risky way to start is to see how small the first step can be. Load a legacy tokens file and export the diff — the brand ramp rewritten to generated steps, every section you didn’t touch byte-for-byte intact — and the migration becomes a series of reviewable increments instead of a leap.