The Journal

Interface Craft3 min read

Building a Color System for Your Web App (Without a Designer)

You don't need a designer to have great colors. Here's a practical system for choosing, organizing, and using colors in your web app.

You picked a nice blue for your buttons. Then you needed a lighter one for hover, a darker one for the pressed state, and a faint one for a background tint. Now you have nine blues, all slightly wrong. The fix is to stop choosing colors and start building ramps.

Build ramps, not colors

A ramp is one hue at eleven fixed lightness steps: 50, 100, 200, all the way up to 900 and 950. 50 is your faintest tint; 950 is near-black. You generate it once — Radix Colors or an OKLCH generator does the math — and every shade you’ll ever need already exists and stays in the same family. Ten or eleven steps is the sweet spot: enough range for hover, borders, and disabled states without a pile of near-duplicates.

Alias by meaning

Components should never reference blue-600 directly. Add a layer of semantic tokens on top and point each one at a step in a ramp. Rebranding then means repointing a handful of aliases, not find-and-replacing hex across the whole app. A component then says bg-background or text-foreground and never learns which hue sits underneath.

globals.css
:root {
  --primary: var(--blue-600);
  --primary-hover: var(--blue-700);
  --background: var(--gray-50);
  --foreground: var(--gray-950);
  --border: var(--gray-200);
}

Meet the contrast floor

Color isn’t done until it’s readable. Body text needs a 4.5:1 contrast ratio against its background; large text and UI can drop to 3:1. This is where fixed steps pay off — test each pair once and you know gray-600 on gray-50always passes. Never ship a gray you haven’t checked. Run each pair through a contrast checker once — Chrome DevTools has one built in — and you never have to guess again.

Dark isn’t light reversed

The rookie move is flipping the ramp — 50 becomes 950 — and shipping it. It looks wrong because dark UIs need less contrast, not inverted contrast. Build a separate dark ramp: near-black surfaces around gray-950, off-white text near gray-100, and brand hues nudged brighter and less saturated so they don’t glow. Same aliases, second set of values.

Ship it

Generate two ramps, alias five semantic tokens, check every pair against 4.5:1, and you have a color system a designer would sign off on. Component libraries like Spectrum UI ship these tokens by default, so you can borrow the structure instead of hand-rolling it. Either way: ramps first, aliases second, raw hex never.

Frequently asked questions

How do I build a color system for a web app without a designer?
Stop choosing individual colors and build ramps: one hue at eleven fixed lightness steps from 50 to 950, generated once with a tool like Radix Colors or an OKLCH generator. Then alias semantic tokens on top so components reference meaning instead of raw hex.
What contrast ratio does text need to be accessible?
Body text needs a 4.5:1 contrast ratio against its background, while large text and UI elements can drop to 3:1. Test each color pair once with a contrast checker such as the one built into Chrome DevTools, and never ship a gray you have not verified.
What are semantic color tokens and why should I use them?
Semantic tokens are named aliases like background or foreground that point at a step in a color ramp, so components never reference a raw shade like blue-600. Rebranding then means repointing a handful of aliases instead of find-and-replacing hex across the whole app.