The same hex is not the same color — the ground you put it on changes what you see, so pick and approve tokens in context, never on an empty artboard.
Pick a mid blue-gray chip. Put it on white, frost, warm sand, orange, teal, near-black, or a soft accent wash. The eyedropper still reports the same hex. Your eye does not. That is simultaneous contrast: the surround rewrites the chip.
Change the ground. Watch Actual stay fixed while Perceived shifts. The pair plate below makes the shift undeniable without tapping.
On light grounds the chip looks darker and cooler. On dark grounds it lifts and softens. On a warm ground it shifts cooler still — the eye pushes away from the surround. Perceived is pedagogical, not a colorimeter. What matters is that the same token is not a stable experience.
Scarce action color (#c0300f) is especially fragile. On a saturated hero
it can disappear; on frost it can scream. Toggle the chip to Action in the
demo and test both before you lock it.
Complements
Opposite hues on the wheel create tension. Use that tension for accents, not for equal area. A 50/50 primary–complement split is two loud colors with no job. A soft ground tinted from the primary plus one complementary chip as the CTA is product-useful.
The accent #c0300f stays scarce — one action job, never wallpaper.
Optional triad (+120° / +240°) gives you two accent candidates; still pick
one role, not a rainbow.
Pick in context
Never pick a brand blue on a blank Figma artboard and ship it on charcoal chrome. Put the live chip on every surface it actually sits on: page, card, frost panel, photo, saturated hero. Read the actual hex. Watch the relative-luminance contrast warning when the chip nearly vanishes.
If one hex fails on a ground, split the token by context (fg-on-light /
fg-on-dark) — or change the ground — instead of hoping the number is
"correct."
Basics to hacks
Checklist when choosing color for product UI:
- Role first — What job does this color own? Action, status, surface, text. Closed roles beat vibe swatches.
- Judge every ground — Approve the chip on white, frost, sand, dark, and any branded wash it ships on.
- OKLCH / HSL steps — Tune lightness and chroma with predictable steps; prefer OKLCH when equality across hues matters.
- Complements as accents — Opposite hue for tension on a CTA or badge — not 50/50 paint.
- Scarce action — The accent
#c0300fappears where the user should act. Everywhere else, neutrals work. - Neutrals work — Most of the UI should be
#111/ white / frost. Color earns its keep. - Contrast + simultaneous contrast — Pass relative luminance and trust your eye: the surround still shifts perception.
- Split tokens by context — If one hex fails on a ground, define roles per surface instead of one absolute value.
No purple sludge. No open "just one more blue." The ground is part of the color.
Usage
<!-- Same chip, two grounds — classic simultaneous contrast plate -->
<div class="grid grid-cols-2 overflow-hidden rounded-xl">
<div class="grid place-items-center bg-[#F4F5F7] p-10">
<span class="size-14 rounded-xl bg-[#6B7C93]"></span>
</div>
<div class="grid place-items-center bg-[#141414] p-10">
<span class="size-14 rounded-xl bg-[#6B7C93]"></span>
</div>
</div>
<!-- Scarce complement accent on a tinted ground — not 50/50 -->
<div class="flex items-end justify-end rounded-xl bg-[#E8F0FF] p-4">
<button class="rounded-lg bg-[#F59E0B] px-3 py-1.5 text-xs font-medium text-white">
Continue
</button>
</div>
:root {
--chip: #6B7C93;
--action: #c0300f; /* scarce CTA only */
--ground-frost: #F4F5F7;
--ground-dark: #141414;
}
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
}
.ground-light { background: var(--ground-frost); }
.ground-dark { background: var(--ground-dark); }
.chip {
width: 56px;
height: 56px;
border-radius: 12px;
background: var(--chip);
}
/* Split by context when one hex fails */
.fg-on-light { color: #0A4FD0; }
.fg-on-dark { color: #3D7EFF; }
When a token must work on multiple grounds, define roles (fg on light, fg on dark) instead of one absolute hex. The ground is part of the color.