What this guide covers

  • Keep a raw 50-950 ramp separate from semantic roles.
  • Components should consume semantic tokens, not raw steps.
  • Tailwind v4 can define colors with CSS-first @theme values.
  • A mid-tone brand color is not automatically safe for white text.
  • Migrate one component family at a time and verify contrast.

Live generator

Generate an 11-step color scale

Change the base color and copy the full 50-950 ramp as CSS variables or Tailwind colors.

50-950
100
Surface and border

Use the light steps for tinted backgrounds and the mid steps for borders and secondary actions.

600
Primary action

Use a step that supports readable button text. This one uses #FFFFFF text.

900
Strong text

Use the dark steps for headings and body copy, then measure the final pair.

Copy the scale
CSS variables
:root {
  --color-brand-50: #F8FAFB;
  --color-brand-100: #E9F1F6;
  --color-brand-200: #CEE4F3;
  --color-brand-300: #A6D3F2;
  --color-brand-400: #72BEF3;
  --color-brand-500: #0B84D8;
  --color-brand-600: #0C69A9;
  --color-brand-700: #0D4E7B;
  --color-brand-800: #0C334E;
  --color-brand-900: #081823;
  --color-brand-950: #060E13;
}
Tailwind colors
export const brand = {
  colors: {
    50: "#F8FAFB",
    100: "#E9F1F6",
    200: "#CEE4F3",
    300: "#A6D3F2",
    400: "#72BEF3",
    500: "#0B84D8",
    600: "#0C69A9",
    700: "#0D4E7B",
    800: "#0C334E",
    900: "#081823",
    950: "#060E13",
  },
};
Keep this palette

Email a private link to reopen it and its CSS, Tailwind, shadcn, Figma and OKLCH exports.

No account. We store the palette colors and your email, never the image.

Why tokens instead of raw HEX values

A HEX value in a component is a decision with no name. When the brand changes, every occurrence has to be found and replaced. When a designer asks why a button is a particular blue, the answer is buried in a diff.

A token gives the value a name and a job. `bg-primary` is easier to reason about than `bg-[#0B84D8]`, and it can change in one place. The raw value still exists, but it lives in the theme layer rather than in every component.

Tokens also make contrast review possible. You can list the semantic pairs that matter and test them, instead of guessing which raw values ended up together.

Start with a raw 50-950 scale

A raw scale is the family of values: brand-50 through brand-950. It is useful because components need several steps of the same hue for backgrounds, borders, hover states and text.

The scale should have visible steps and a believable light and dark end. If 400 and 500 look the same, the scale is not giving the designer a real choice. If 50 is too saturated, it will not work as a tinted background.

Generate the scale from the base brand color, then review the ends. A designer may want a warmer 50 or a cooler 950. The generator gives you a consistent starting point; the final values are a design decision.

Add a semantic layer

The semantic layer maps jobs to raw steps. `--color-primary` might map to brand-600, `--color-primary-hover` to brand-700, `--color-surface` to brand-50, and `--color-border` to brand-200.

Components should use the semantic tokens. A button uses `bg-primary` and `text-primary-foreground`; it does not know that primary is brand-600. That separation is what makes a future rebrand tractable.

Keep the semantic set small. Background, surface, text, muted text, border, primary, primary foreground, accent and status colors cover most product interfaces. Add a token when a real component needs it, not because a palette has an extra color.

Semantic tokenRaw stepUsed by
--color-primarybrand-600Primary buttons and links
--color-primary-hoverbrand-700Hover and pressed states
--color-surfacebrand-50Cards and panels
--color-borderbrand-200Dividers and input borders
--color-textneutral-900Body text
--color-mutedneutral-600Labels and helper text

Use the scale in Tailwind v4

Tailwind v4 supports a CSS-first theme. You can declare `--color-brand-50` through `--color-brand-950` inside `@theme`, and Tailwind will generate utilities such as `bg-brand-500` and `text-brand-700`.

For semantic tokens, declare `--color-primary: var(--color-brand-600)` and use `bg-primary`. If you want the semantic token to resolve through a CSS variable at runtime, use `@theme inline` so the generated utility references the variable rather than copying the value.

The exact syntax depends on the Tailwind version and the build setup. The principle is stable: raw values in the theme, semantic values on top, components consume the semantic layer.

If a component imports a raw step directly, the semantic layer is not doing its job. Treat that as a code smell and move the mapping into the theme.

Naming that survives a rebrand

Names should describe jobs, not appearance. `primary`, `surface`, `muted`, `border` and `danger` survive a change from blue to green. `blue-500` and `light-blue` do not.

For raw ramps, a neutral prefix is fine: `brand-500`, `neutral-200`. For semantic tokens, use the job. Avoid names that encode a specific component unless the token is genuinely component-scoped.

If a token is only used by one component, consider whether it should exist at all. A local CSS variable inside the component may be simpler than a global token.

Check contrast at the semantic layer

A raw ramp does not guarantee accessibility. The fact that brand-600 is darker than brand-500 does not mean that white text on brand-600 passes 4.5:1. You still need to measure the pair.

Check the pairs the product actually uses: button text on primary, body text on background, muted text on surface, border against surface and focus ring against both. If a pair fails, choose a different raw step or a different foreground.

Run the check after every theme change. A small adjustment to the primary step can move the ratio below the threshold, and the regression is easy to miss in a visual review.

A migration checklist

Start by inventorying the raw HEX values in the codebase. Group them by job. You will usually find several near-duplicates that should collapse into one token.

Add the raw scale and the semantic layer, then migrate one component family at a time. Buttons first, then cards, then forms, then navigation. Verify contrast and visual hierarchy after each family.

Remove the old values only when the migration is complete. Keep a short record of the mapping so a future developer can understand why a value changed. The goal is a smaller, clearer theme, not a larger one.

  • Inventory raw HEX values and group by job.
  • Add the raw ramp and semantic tokens.
  • Migrate buttons, cards, forms and navigation in that order.
  • Check contrast after each family.
  • Delete old values only after the migration is verified.
PhotoColors Team

We build browser-based color tools and write about the unglamorous parts of color systems: contrast, tokens, handoff and the difference between a pretty palette and a usable one.

About PhotoColors