How to Build a Custom Tailwind CSS Color Palette (50–950)
Build a custom Tailwind CSS color palette with 50–950 shades. Step-by-step using OKLCH, @theme and design tokens for v3 and v4.

On this page▾
Tailwind's default colors are excellent, but every serious product eventually needs its own brand hues wired into the same 50–950 system. This guide is a complete walkthrough: pick a base, build a perceptually even ramp in OKLCH, wire it into both v3 and v4, layer on semantic tokens, and verify contrast. It is the same work a good tailwind color palette generator does for you, explained so you can do it (and fix it) by hand.
Why a custom Tailwind palette beats the defaults
Tailwind ships 22 carefully tuned color families, so why build your own? Because the defaults are shared. Reach for blue-600 and your product looks like every other Tailwind site. A custom scale tied to your brand hue is the difference between "a Tailwind app" and "your app."
There is a practical reason too. Tailwind custom colors slot into the same utility grammar as the built-ins — bg-brand-50, text-brand-700, ring-brand-500/40. Define a family once and every contributor uses it without hard-coding a hex. The 50–950 system is complete by design: a light tint for hover fills, a mid for buttons, a near-black for dark surfaces.
The common mistake is generating only a 500 and eyeballing the rest, which leaves uneven jumps where shades clump and gap. Build all eleven steps deliberately and the whole UI snaps into rhythm.
How Tailwind's color scale works (the 50–950 system)
Each Tailwind color family is a single hue rendered at eleven lightness levels. The numbers are a lightness index, not opacity: 50 is the lightest tint, 500 is the base, and 950 is the darkest. The 950 step arrived with Tailwind v3, giving every hue its 11th stop and a true near-black for dark mode.
50 100 200 300 400 500 600 700 800 900 950
↑ lightest base darkest ↑
Read the scale by job: each band has a conventional role that keeps your components predictable.
| Step | Typical role |
|---|---|
50 | page tints, subtle hover fills |
100–200 | borders, dividers, muted surfaces |
300–400 | placeholders, disabled states, decorative accents |
500 | the base brand color (swatch you show in the brand guide) |
600 | primary buttons and links — white text passes contrast here |
700 | hover/active states, colored text on light backgrounds |
800–900 | headings on tints, saturated dark UI |
950 | near-black backgrounds, dark mode base |
Notice that 500 is rarely the color you actually use — it is the identity swatch. The real work happens at 600/700 (buttons, foreground) and 50/100 (backgrounds). That asymmetry is why you need the full ramp, not just a brand hex.
Step 1 — choose your base (the 500)
Your 500 is the anchor every other step is derived from, so pick it for identity, not for contrast — the swatch a stakeholder points at and calls "our blue." Contrast is solved later by 600/700.
Start from your existing brand hex, or sample one from a reference image with the Image Color Extractor. For this guide we will build a custom blue with a base of:
--color-brand-500: oklch(0.62 0.19 256); /* #2584f5 */
Two sanity checks before you commit:
- Lightness around 0.60–0.65. That keeps
500in the middle of the ramp, with room above and below. A500that is already dark leaves no headroom for600–950. - Don't max out chroma. A neon
500looks punchy alone but forces light tints toward grayish pastels and pushes dark shades out of the sRGB gamut. Pull chroma back a touch and the family stays coherent.
New to the color space? The OKLCH explainer is worth ten minutes — the short version is that it changes lightness without the hue drifting, which is exactly what a shade ramp needs. For why we left HEX behind for the math, see HEX vs RGB vs HSL.
Step 2 — generate the 50–950 ramp (perceptual spacing with OKLCH)
Here is where most homemade palettes fall apart, and where OKLCH earns its keep. The job is to step lightness evenly while holding the hue, and to taper chroma at both ends so the tints don't go muddy and the shades don't clip.
Why not HSL? Its lightness is not perceptual: hsl(60 100% 50%) (yellow) looks far brighter than hsl(240 100% 50%) (blue) despite both claiming 50% lightness. A ramp built on HSL lightness feels uneven no matter how even the numbers are. OKLCH's L is tuned to human perception, so equal L deltas read as equal visual jumps.
The recipe:
- Lightness (
L) runs from ~0.97 at50down to ~0.27 at950. Use slightly smaller steps at the light end (the eye is more sensitive to bright tints) and a bigger drop into950. - Chroma (
C) peaks around500–600and tapers toward both ends — light tints cannot hold high chroma, and very dark saturated colors fall outside sRGB. - Hue (
H) stays roughly fixed. A small shift (here 256 → 266 as it darkens) makes dark shades feel richer — keep it subtle.
Applying that to our #2584f5 base gives a complete, in-gamut ramp:
| Step | OKLCH | HEX | vs white | vs black |
|---|---|---|---|---|
50 | oklch(0.971 0.013 256) | #f0f6fe | 1.09 | 19.32 |
100 | oklch(0.932 0.033 256) | #dbeaff | 1.22 | 17.23 |
200 | oklch(0.874 0.060 255) | #bcd8fd | 1.46 | 14.38 |
300 | oklch(0.793 0.100 254) | #8ebffb | 1.91 | 11.00 |
400 | oklch(0.706 0.150 255) | #5ba2fb | 2.62 | 8.01 |
500 | oklch(0.620 0.190 256) | #2584f5 | 3.69 | 5.70 |
600 | oklch(0.548 0.210 258) | #0069e8 | 5.02 | 4.19 |
700 | oklch(0.488 0.200 260) | #0055ce | 6.58 | 3.19 |
800 | oklch(0.424 0.165 262) | #1445a6 | 8.64 | 2.43 |
900 | oklch(0.378 0.130 263) | #1a3c86 | 10.32 | 2.03 |
950 | oklch(0.270 0.082 266) | #13234e | 15.24 | 1.38 |
You do not have to crank these by hand. Paste your 500 into the Shade & Tint Generator — it works as a tailwind color palette generator, producing the full 50–950 ramp to copy into your theme. Nudge the OKLCH lightness values if you want tighter spacing. To move between formats while tuning, the Color Format Converter turns any hex into OKLCH and back, and if blue is your start, the blue color page is a good reference for sibling hues.
Step 3 — wire it in (v3 tailwind.config vs v4 @theme)
The values are identical; only the plumbing differs between major versions.
Tailwind v4 — CSS-first with @theme
v4 moved configuration into your CSS. You declare colors as --color-* variables inside an @theme block, and Tailwind generates the matching utilities automatically. This is also how Tailwind v4 theme colors are defined internally — the whole default palette ships as OKLCH variables.
@import "tailwindcss";
@theme {
--color-brand-50: oklch(0.971 0.013 256);
--color-brand-100: oklch(0.932 0.033 256);
--color-brand-200: oklch(0.874 0.060 255);
--color-brand-300: oklch(0.793 0.100 254);
--color-brand-400: oklch(0.706 0.150 255);
--color-brand-500: oklch(0.620 0.190 256);
--color-brand-600: oklch(0.548 0.210 258);
--color-brand-700: oklch(0.488 0.200 260);
--color-brand-800: oklch(0.424 0.165 262);
--color-brand-900: oklch(0.378 0.130 263);
--color-brand-950: oklch(0.270 0.082 266);
}
That single block unlocks bg-brand-600, text-brand-50, ring-brand-500, and every other variant — and exposes each value as a real CSS variable (var(--color-brand-600)) usable anywhere, even outside Tailwind.
Tailwind v3 — theme.extend.colors
v3 uses a JavaScript config. Add a nested object and Tailwind builds the same utilities:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
brand: {
50: "#f0f6fe",
100: "#dbeaff",
200: "#bcd8fd",
300: "#8ebffb",
400: "#5ba2fb",
500: "#2584f5",
600: "#0069e8",
700: "#0055ce",
800: "#1445a6",
900: "#1a3c86",
950: "#13234e",
},
},
},
},
};
Use extend rather than replacing theme.colors so you keep Tailwind's defaults (gray, red, etc.) alongside your brand. v3 happily accepts oklch(...) strings too if your browser targets support it.
Add semantic tokens (primary, surface, danger) — not raw hues
The biggest upgrade to your system is a second layer. The eleven steps above are primitives — raw paint. On top of them, define semantic tokens for roles: primary, surface, danger, muted. Components reference only the roles, so re-theming or shipping dark mode is a one-line change instead of a codebase-wide find-and-replace.
In v4 you can point one --color-* token at another, because they are all CSS custom properties:
@theme {
/* primitives (the ramp from Step 2) */
--color-brand-600: oklch(0.548 0.210 258);
--color-brand-700: oklch(0.488 0.200 260);
/* …plus red-600, green-700, etc. */
/* semantic tokens — roles, not hues */
--color-primary: var(--color-brand-600);
--color-primary-hover: var(--color-brand-700);
--color-surface: var(--color-brand-50);
--color-border: var(--color-brand-200);
--color-danger: var(--color-red-600);
--color-success: var(--color-green-700);
}
Now a button is bg-primary hover:bg-primary-hover, not bg-brand-600 hover:bg-brand-700. When marketing rebrands from blue to teal, you swap two primitive lines and the whole UI follows. Dark mode is the same move — redefine the tokens under a .dark selector and leave components untouched. That pattern is the backbone of the dark mode color palette guide.
If you would rather generate a coherent set of roles, ramps, and neutrals from a single brand color in one pass, the Brand Color Kit does exactly that and hands you tokens ready to paste.
Check contrast across the ramp (AA on 600/700)
A palette is not done until it is accessible. WCAG AA requires a contrast ratio of 4.5:1 for normal text and 3:1 for large text (or UI components). The practical question is: which step can carry white text, and which can be text on white?
Run the numbers and a clear rule emerges for this ramp:
| Pairing | Ratio | AA normal (4.5) | AA large (3.0) |
|---|---|---|---|
white text on brand-500 #2584f5 | 3.69 | ✗ | ✓ |
white text on brand-600 #0069e8 | 5.02 | ✓ | ✓ |
white text on brand-700 #0055ce | 6.58 | ✓ | ✓ |
brand-600 text on white | 5.02 | ✓ | ✓ |
brand-700 text on brand-50 #f0f6fe | 6.05 | ✓ | ✓ |
This is the single most useful fact about the scale: 500 is usually not safe for white text (3.69:1 here passes only the large-text bar), but 600 and 700 are. So your default solid button should map bg-primary to 600, not 500, and colored text or icons on white should use 600 or darker.
One trap that bites everyone: greens and yellows sit higher on the lightness curve, so their 600 is often too light for white text. A typical green-600 lands near 3.3:1 with white (large-text only) while green-700 clears 5:1. Yellows and ambers should carry black text instead (amber-400 hits ~12.6:1 on black). Don't assume the 600 rule transfers across hues — verify each accent.
Drop any pairing into the Color Contrast Checker before you ship. Test realistic combinations: body text on tinted cards, white on your primary button, and muted 400/500 text (which frequently fails — it is decorative, not readable).
Export & reuse (CSS variables, design tokens)
Defining colors as variables buys portability: your palette lives in one place and feeds everything — Tailwind utilities, raw CSS, inline styles, canvas, even native apps.
v4 gives you this for free. Every --color-* in @theme is a standard CSS custom property, so var(--color-brand-600) works in any stylesheet or style={{ color: "var(--color-primary)" }} in a component — no extra config.
In v3, wire variables yourself when you need runtime theming or alpha control. Define channel values as variables and reference them in the config with the <alpha-value> placeholder:
:root { --brand-600: 0 105 232; } /* #0069e8 as RGB channels */
// tailwind.config.js
colors: {
brand: { 600: "rgb(var(--brand-600) / <alpha-value>)" },
}
Now bg-brand-600/50 produces 50% opacity, and you can re-theme at runtime by reassigning --brand-600 — handy for white-label products.
For cross-platform work, export the ramp as W3C design tokens (JSON) and feed a tool like Style Dictionary, which fans one source out to CSS, iOS, Android, and Figma variables. The Brand Color Kit produces token-ready output, and the Color Format Converter handles any last-mile format.
Common mistakes (uneven steps, over-saturated 500, missing 950)
A quick checklist of what separates a polished palette from a rough one:
- Uneven steps from eyeballing hex. Hand-picking shades produces clumps and gaps. Step OKLCH lightness evenly and let math space the ramp.
- An over-saturated
500. Max chroma at the base forces washed-out tints and clipped darks. Pull chroma back and taper it at both ends of the scale. - No
950(or a900that isn't dark enough). Skip the near-black and dark mode surfaces look flat and gray. The950makes dark UI feel intentional —brand-200onbrand-950here reads at a comfortable 10.4:1. - Holding hue perfectly constant. Optional, but a subtle hue shift toward
260+ as the color darkens adds richness. Overdo it and the family looks like two different colors. - Using raw steps in markup everywhere.
bg-brand-600sprinkled across 200 components is a re-theming nightmare. Reference semantic tokens (bg-primary) instead. - Shipping without a contrast pass. "It looks fine" is not 4.5:1. Check
600/700for foreground use, and re-check every accent hue individually. - Forgetting out-of-gamut clipping. An OKLCH value outside sRGB is silently clamped, flattening your darkest shades. Verify the rendered hex in a generator rather than trusting the numbers blind.
Get those right and your brand scale drops cleanly into Tailwind, reads well in light and dark, and passes contrast where it counts.
Frequently asked questions
How do I add custom colors to Tailwind?
In Tailwind v4, add them inside @theme in your CSS as --color-name-500 variables, and Tailwind turns each one into utilities like bg-name-500. In v3, add a nested object under theme.extend.colors in tailwind.config.js.
What do the 50–950 numbers mean?
They are lightness stops, not opacity. 50 is the lightest tint, 500 is the base brand color, and 950 is the darkest shade. A higher number always means a darker color.
Should I use OKLCH or HEX in Tailwind?
Use OKLCH for the scale because it spaces shades by perceived lightness and it is the format Tailwind v4 ships. HEX is fine for pasting one-off values. Every modern browser supports the oklch() function.
How many shades do I need?
Generate the full 50–950 (11 steps) for your primary brand color and for your neutral gray. Accent colors used only for buttons or alerts can ship just three or four steps: a light background, a 500/600, and a dark.
How do I make a full scale from one hex?
Convert the hex to OKLCH, hold the hue and chroma, then step the lightness evenly from about 0.97 down to about 0.27. A generator does this instantly — paste your base into the Shade & Tint Generator and copy the ramp.
