Neutrals Do the Heavy Lifting: Choosing Grays for Interfaces
Designers obsess over the accent color and ignore the ninety percent of the screen that is gray. That ratio is exactly backwards.
Turn one brand hex code into a full design system palette: tint and shade ramps, neutral scales, semantic colors, dark mode, and export-ready tokens — step by step.
The brief arrives with confidence and one line: "Our brand color is #2563EB. Make it work." No neutrals, no secondary palette, no dark mode. The good news: expanding one color into a complete system is mostly mechanical — you build lightness ramps for the hue, a desaturated neutral scale, semantic tokens for states, and a dark-mode set, then export them as code tokens. None of it requires taste; it requires a reliable process. This guide runs the whole pipeline on #2563EB, hands you the tokened result, and flags the steps where people usually stop and improvise.
Never hard-code five random colors. Build a ramp by holding hue and saturation roughly constant and walking lightness upward and downward from your base.
Ramp for #2563EB:
#EFF4FF — 50 (brand-50)#D9E4FC — 200#9DB8F5 — 300#5D8BF0 — 400#2563EB — 500 (the brand color itself)#1D4ED8 — 600#1E40AF — 700#1E3A8A — 800#172554 — 900The 50/100 end is for tinted backgrounds and badges; the 900 end is for deep headers and on-light text. This ramp alone covers buttons, links, focus rings, chips, and table headers.
Pure grays will clash with a blue brand. Your neutrals should carry the brand's undertone — mix a touch of the brand hue into the gray — while staying distinct enough to read as "neutral."
Neutral "ink" scale for a blue system:
#FFFFFF — surface absolute#F8FAFD — surface#EFF2F7 — surface raised#CBD5E1 — border#64748B — muted text / icons#334155 — secondary text#0F172A — primary text (ink)Test critically: #64748B on #F8FAFD is about 4.9:1 — passes AA for body text. #CBD5E1 is decoration only; never text.
Semantic tokens are about meaning, not aesthetics: success, warning, danger, and info must stay visually distinct from each other and from the primary brand color. A classic failure: primary blue and info blue look identical; danger red and warning orange blur at 88% screen brightness.
Semantic set:
#16A34A (dark mode #22C55E)#D97706 (dark #F59E0B)#DC2626 (dark #EF4444)#0284C7 (dark #38BDF8)#1D4ED8The dark-mode variants exist because raw #16A34A on dark surfaces reads muddy; brighter dark-mode fags fix legibility without changing meaning.
Don't invent a new palette for dark mode — remap the ramp. On dark surfaces, the 900s become background, the 300s become text, and the primary accent rises to the 400/500 band to hold contrast.
Dark remap for this system:
#0F172A, card #172038, raised #1E293B#F1F5F9, secondary #94A3B8#3B82F6 (brand-400), focus ring #60A5FARule: keep the lexicon identical — surface, text, primary, danger all still exist; only their values swap. That's what lets engineers reuse tokens and what keeps the design system consistent in both themes.
Don't give every surface the same dark fill. Dark mode needs elevation by lightness: cards sit one step lighter than the page background, hover states one step lighter still, and modal overlays a step up again — otherwise your UI flattens into a single black slab. The three dark surfaces above (#0F172A, #172038, #1E293B) are that elevation story in miniature, and it's the difference between a dark mode that feels layered and one that feels like one giant rectangle.
Design systems fail when the values live in a deck and not in code. Produce three artifacts: CSS custom properties, a Tailwind colors extension, and a JSON token file. Palette 3 — the deliverable shape:
:root {
--color-primary: #2563eb;
--color-primary-600: #1d4ed8;
--color-surface: #f8fafd;
--color-text: #0f172a;
--color-muted: #64748b;
--color-success: #16a34a;
}
Naming is where systems quietly rot. If your team references colors as "brand-blue-500" and "darker-blue," nothing reusable survives a rebrand. Name tokens by role, and only keep the ramp names as a second, technical layer:
color.brand.primary, color.brand.primary-hover, color.brand.primary-activecolor.surface.default, color.surface.raised, color.surface.overlaycolor.text.primary, color.text.secondary, color.text.disabledcolor.border.default, color.status.success, color.status.dangerThe rule I enforce: a designer should be able to change status.success and every success state updates automatically, without anyone hunting for a fill. If your names map to jobs ("success", "active", "muted") rather than hues ("blue2"), you can re-theme the entire product in an afternoon — worth it precisely because the brand team changes its mind every quarter.
Theory gets concrete when you watch one small screen get converted. Take a login card that currently hard-codes #4A90E2, #F5F7FA, and #333333:
#333333 → color.text.primary (resolves to #0F172A). Everything that was gray becomes the system ink, and the 15:1 contrast comes for free.#F5F7FA → color.surface.default (#F8FAFD). Close enough visually that users see no change, but the card now follows theme.#4A90E2 → color.brand.primary (#2563EB), plus new hover/active and disabled tokens it never had. The disabled state, previously a forgotten gray, gets a real semantic value.color.focus.ring (#1D4ED8) goes on the email and password fields — an accessibility upgrade that costs one line.#4A90E2 in design files and CSS.That's the pitch for this process: the ramp gives you the product, the naming gives you the system, and the tokens give you dark mode and rebrands for free.
#64748B-on-white passes; #94A3B8-on-white fails (≈2.9:1). Any token destined for text needs the 4.5:1 check, in both themes.5 is enough to ship a Lean design system; 9–10 gives you the full tailwind-style range (50–900). Start with 50, 200, 400, 500, 600, 700, 900 and expand only when a real screen asks for a missing step.
Every product color should. Whitespace and hero decorations can be flat brand fills; anything used as text, surface, or state needs the ramp mechanics so contrast math holds.
Functionally yes — and that's fine. The value you add is your ramp, your boundaries, and your dark-mode mapping, not inventing a proprietary scale. Copying the pattern and swapping in your hue is exactly right.
Tokenize everything and treat the raw hex as a reference, not the source of truth. When brand ships a new primary, you regenerate ramps from it, version the JSON, and let designers re-import from the tokenized source rather than manually re-picking fills.
Two ramps, one neutral family, and a semantic layer. Concretely: your brand ramp at 5 steps, a warm/cool neutral ramp, plus text/border/success/danger tokens. Everything above that — elevation, focus ring, dark-mode values — is refinement. If your team can change the brand hue in one JSON file and every component follows, you have a system; the rest is polish.
Start from any hex in the palette generator — lock the hue and reshuffle until you like the ramp — then run it through the palette exporter to get the CSS variables, Tailwind config, and JSON tokens in one export, ready to drop into your repo.
Founder & Developer of Color Palette — Sagar Chachapara is an independent developer who builds color tools and writes about color theory, accessible design, and working with palettes in the browser.
sagarchachapara1997@gmail.comTry the techniques from this guide in our free tools — no sign-up required.
More in Web Design · All articles · FAQ
Designers obsess over the accent color and ignore the ninety percent of the screen that is gray. That ratio is exactly backwards.
The difference between a gradient that feels modern and one that feels dated comes down to restraint, color harmony, and where you place it.
Constraint is not a limitation, it is a design tool. The palettes that look most considered are usually the smallest ones.