Skip to main content
← Back to list

· Working ·

Radix-style colour scales: number steps by distance and dark mode follows

A Radix-style scale numbers each step by its distance from the background rather than by lightness. Here is how that differs from Tailwind's palette, the real CSS behind this site's grey ramp, and why its semantic tokens need no dark-mode overrides.

A Radix-style colour scale numbers each step by how far it sits from the background. The lowest step is always the page background and the highest is always the strongest text, in light and dark mode alike. Components reference a step name, the real value swaps per theme in one place, and dark mode mostly takes care of itself.

I'm Hai Le, a UI/UX designer who writes code. The grey ramp on this site follows that convention, and every snippet below is copied from its app/globals.css.

How is a Radix colour scale different from Tailwind's?

The difference is what the number on a step means.

Tailwind's docs say every colour in the default palette has 11 steps, "with 50 being the lightest, and 950 being the darkest." The number describes lightness. gray-50 is near-white in light mode and is still near-white when the user switches to dark. To change colour per theme you add a second class, as in the dark mode page's own example: bg-white dark:bg-gray-800.

Radix Colors has 12 steps per scale, and its docs assign each group of steps a job:

Radix stepIntended use
1–2App background, subtle component background
3–5Component backgrounds: normal, hover, pressed or selected
6–8Borders: subtle, interactive, strong and focus rings
9–10Solid colour, then its hover state
11–12Text: low contrast and high contrast

Light scales are applied to :root and the .light / .light-theme classes. Dark scales are applied to .dark / .dark-theme. The variable names stay the same and the values change, so gray-1 in dark mode is still the background. It just happens to be dark.

What does "numbered by distance from the background" mean in practice?

Read the number as steps walked away from the background towards text. 50 means standing on the background. 950 is the furthest you can go. On a white page, walking away means getting darker. On a dark page, it means getting lighter.

This site kept the 50–950 names because they are familiar to anyone who has used Tailwind, and gave them Radix's meaning. The real grey ramp, both modes:

StepLightDarkRole
50#ffffff#111111Page and card background
100#f8f8f8#191919Grey band, hover fill
200#efefef#242424Hairline border
300#e0e0e0#2e2e2eInput border
400#b8b8b8#4a4a4aGraphics, disabled states
500#9a9a9a#5e5e5eDecoration
600#717171#868686Third text tier
700#595959#a8a8a8Second text tier
950#000000#edededPrimary text

Each row keeps its role while the value flips. Steps 800 and 900 are left out of the table for space and follow the same pattern.

What does the CSS look like?

The grey ramp lives in layer 0 (raw values) and is the only ramp that changes under .dark. Trimmed from app/globals.css:

:root {
  --gray-50: #ffffff;
  --gray-100: #f8f8f8;
  --gray-600: #717171; /* subtle-foreground */
  --gray-700: #595959; /* muted-foreground */
  --gray-950: #000000;
}

.dark {
  --gray-50: #111111;
  --gray-100: #191919;
  --gray-600: #868686;
  --gray-700: #a8a8a8;
  --gray-950: #ededed;
}

Layers 1 (aliases) and 2 (semantic, the shadcn set) point at step names and have no idea which theme is active:

:root,
.dark {
  --neutral-50: var(--gray-50);
  --neutral-950: var(--gray-950);

  --background: var(--neutral-50);
  --foreground: var(--neutral-950);
  --muted: var(--neutral-100);
  --muted-foreground: var(--neutral-700);
  --subtle-foreground: var(--neutral-600);
  --border: var(--neutral-200);
}

The :root, .dark selector matters. A custom property's var() references are resolved on the element that declares it, and descendants inherit the resolved value. Declare the chain on :root only, and a nested .dark block inherits values already computed from the light greys. Declaring it on both selectors makes .dark recompute the chain from its own ramp.

Components write bg-background text-foreground or text-muted-foreground. None of the components I wrote use a dark: class to change a grey. The few dark: classes left sit inside shadcn primitives copied as-is, and in the callout box, which uses Tailwind-convention blue and amber.

If the three layers are new to you, What is a design token covers raw, alias and semantic first.

Why apply the Radix convention to the grey ramp only?

Grey is the one ramp that has to invert completely. White background becomes dark background, black text becomes light text, and every surface and text tier is built on it. Letting it flip at layer 0 removes the most lines.

The magenta brand ramp has a different need. #ff009d should not turn into a dark colour in dark mode. It needs to get slightly lighter to stay legible on a dark surface. So the brand ramp keeps fixed Tailwind-style values, and dark mode picks a different step at layer 2:

.dark {
  --primary: var(--primary-400);       /* #ff3fb2 */
  --primary-text: var(--primary-400);
  --primary-foreground: #ffffff;
}

All of this site's dark-mode overrides at layer 2 fit in one block of about twenty lines: magenta, the error colour, the footer and the chart accent.

How do you place text steps so they pass WCAG?

Set the text steps by contrast threshold first, then judge them by eye. WCAG 2.2 success criterion 1.4.3 asks for at least 4.5:1 for normal-size text. On this site the binding constraint is text on the grey muted band; white is the easy case.

I recomputed these with the WCAG relative luminance formula:

PairModeRatioNormal text (4.5:1)
#595959 on #ffffffLight7.00:1Pass
#717171 on #ffffffLight4.88:1Pass
#717171 on #f8f8f8Light4.60:1Pass, close to the line
#9a9a9a on #ffffffLight2.81:1Fail
#a8a8a8 on #111111Dark7.94:1Pass
#868686 on #111111Dark5.19:1Pass
#868686 on #191919Dark4.83:1Pass
#5e5e5e on #111111Dark2.91:1Fail

Step 500 fails in both modes, so the rule is written once: neutral-500 and neutral-400 are not text colours. Because the roles hold across themes, one sentence covers both.

Radix makes its own promise about text steps: steps 11 and 12 reach Lc 60 and Lc 90 on the APCA scale against step 2 of the same scale. APCA is a different method from WCAG 2.2, so don't compare an Lc value with a 4.5:1 ratio.

What goes wrong when teams build scales for dark mode?

Hand-inverting colours in every component. text-gray-500 dark:text-gray-400 repeated across a codebase gets missed somewhere, and the miss only shows when someone switches themes. If the ramp flips at layer 0, components have nothing to forget.

Hard-coding a light value. A component that writes #717171 instead of a token ends up on #191919 in dark mode at 3.60:1. Light grey text on a grey band slips through easily because it still looks readable.

Inverting the brand ramp too. People recognise a product by its brand colour, so keep its values fixed. On this site dark mode only moves from step 500 to 400, from #ff009d to #ff3fb2.

Leaving the convention unwritten. Anyone used to Tailwind will assume neutral-50 is the lightest grey, and so will an AI agent reading your files. This site states the rule in the CSS comments and in DESIGN.md; What is DESIGN.md explains why it needed a section of its own.

Where should a team on Tailwind-style scales start?

Start with the grey ramp and leave the others alone.

  1. List every grey by role: background, band, hairline, input border, the text tiers.
  2. Assign each role a step, numbered by distance from the background.
  3. Write the light value, then choose the dark value for the same step so the text pairs still clear 4.5:1.
  4. Point the semantic layer at steps, then delete the grey dark: classes from components.

On Joyme the palette I built had a purple ramp, an orange ramp and a grey ramp. The system work is in the Joyme case study. Quick answers on theming are in the dark mode FAQ, and naming questions in the design token FAQ. If your team needs someone to carry a design system from tokens to code, more about me here.

Sources

Frequently asked questions

How many steps does a Radix colour scale have?
Twelve. Steps 1–2 are backgrounds, 3–5 component backgrounds, 6–8 borders, 9–10 solid colours and 11–12 text. This site keeps Tailwind-style names (50 to 950, eleven steps) but borrows Radix's meaning for its grey ramp.
What is the difference between Radix and Tailwind colour numbering?
In Tailwind's default palette, 50 is the lightest and 950 the darkest in every theme. In Radix, the same variable name takes a different value per theme, so step 1 is always the background and step 12 always the highest-contrast text.
Should every scale in my system follow the Radix convention?
I only converted the grey ramp. The brand and status ramps keep Tailwind numbering, and dark mode picks a different step for them at the semantic layer. That is far fewer lines than inverting the whole palette.
Does a Radix-style scale guarantee accessible contrast?
No. You still measure every text and background pair in both modes. On this site the two secondary text steps were set to clear 4.5:1 on both the white page and the grey band before the dark values were chosen.