Design Theory
Ditch Hex for HSL (or OKLCH)
- HSL = Hue, Saturation, Lightness
- Much easier to manipulate colors
- Easy to create lighter/darker versions
- Easy to shift hue while keeping saturation/lightness
- OKLCH is even better for perceptual uniformity
You Need More Colors Than You Think
Don't limit yourself to primary and accent:
- Need 8-10 shades of each color for flexibility
Color categories needed:
- Greys (8-10 shades)
- Primary color(s) (8-10 shades each)
- Accent colors (8-10 shades each)
- Semantic colors:
- Red for errors/destructive (8-10 shades)
- Yellow for warnings (8-10 shades)
- Green for success/positive (8-10 shades)
Define Your Shades Up Front
- Choose darkest and lightest shades first
- Fill in the middle
- You'll need more options in the middle than at the extremes
Recommended approach:
- 50, 100, 200, 300, 400, 500, 600, 700, 800, 900
- 9 shades gives good flexibility
Hold Vividness Across the Ramp, Don't Chase Absolute Chroma
A ramp looks wrong when its ends go pale and lifeless while the middle stays rich. The fix is to keep each step at the same share of the chroma available to it, not at the same chroma number.
In oklch the gamut caps chroma at both ends. Near white and near black there is very little room for chroma at any hue, so the lightest and darkest steps carry a lower absolute chroma than the base by necessity. A ramp that holds one C value from 50 to 950 clips at both ends, and the clipped steps come back as whatever the browser could render.
So set chroma as a percentage of each step's own maximum, which holds perceived vividness steady while the absolute number rises to a peak in the middle and falls away at both ends. Algorithmic Palette Ramp below does exactly this, and Match Chroma Percent Across Hues applies the same rule sideways, across hues rather than along one ramp.
Two failures this prevents:
- Fully saturated light colors look fluorescent
- Fully saturated dark colors look muddy
This is where HSL advice misleads. In HSL, saturation loses its effect as lightness approaches 0% or 100%, so HSL guidance says to raise S at both ends to compensate. That is a correction for a coordinate system whose S does not mean what it says. Do not carry the instruction into oklch, where C is already perceptual and the gamut, not the maths, sets the ceiling.
Greys Don't Have to Be Grey
- Pure grey (no hue) can feel lifeless
- Add a slight tint of your primary color
- Or add blue for a cooler look
- Makes greys feel more cohesive with your design
Accessible Doesn't Have to Mean Ugly
- WCAG recommends 4.5:1 contrast ratio for normal text
- Don't just use pure black (#000) for text - too harsh
- Use very dark grey or colored dark (maintains contrast while softer)
- For colored backgrounds, hand-pick dark colors that pass contrast
Tips:
- Flip background and text in high-contrast areas
- Rotate hue for colored text on colored background
Watch Contrast Traps
- Ghost text: placeholder the same color as the typed value, so users can't tell a filled field from an empty one. Make the placeholder lighter than the value.
- Disabled too loud: a disabled control with near-active contrast still looks clickable. Drop it to ~30% opacity or a clearly-muted grey so it reads as inactive.
- Subtle links: links barely different from body text are invisible. Add an underline or raise the contrast.
Don't Rely on Color Alone
- Use color + another indicator (icon, label, position)
- Important for accessibility
- Makes interface clearer for everyone
- Examples:
- Errors: red + icon + error message
- Success: green + checkmark + message
Keep Tinted Chips from Washing Out on Coloured Rows
- A chip or badge with a light or semi-transparent fill (a muted pill, an outline badge) sitting on a row that gets a coloured hover highlight will bleach out. Its translucent fill alpha-composites toward the hover colour and loses its edge.
- Fix it with
mix-blend-mode: multiplyon the chip. Multiply darkens the fill into the row colour instead of lightening it, so the chip stays legible and adopts the row tint. On a white or neutral surface multiply is a no-op (white x colour = colour), so plain contexts are unaffected. - Only apply it to light or translucent fills. An opaque coloured fill (a solid primary or destructive badge) multiplied against a coloured background produces a compound colour shift, a blue badge over an amber row reads brown-green, so leave those on normal blending.
- Guard dark themes. Multiply on a dark surface crushes the chip toward black, so pair it with
dark:mix-blend-normaland let the blend run in light mode only.
// ✅ Good – light/translucent chip that sits on coloured hover rows
<span className="rounded-full border border-border bg-muted/50 px-2.5 py-1 mix-blend-multiply dark:mix-blend-normal">
Styled like Linear
</span>
// ✅ Good – shared Badge: scope the blend to the transparent outline variant only
outline: "text-foreground mix-blend-multiply dark:mix-blend-normal",
// ❌ Bad – blanket multiply on the shared Badge base also hits the opaque
// default / secondary / destructive fills and muddies them
OKLCH Implementation
What is OKLCH?
OKLCH is a perceptually uniform color space where:
- L (Lightness): 0 (black) to 1 (white)
- C (Chroma): 0 (grayscale) to ~0.4 (highly saturated)
- H (Hue): 0-360 degrees (color wheel)
Key advantage: Equal lightness steps look equally different to human eyes, unlike RGB/HSL.
Color Variables
Dark Theme:
:root {
--primary: oklch(0.9 0.17 100); /* Vibrant accent - high L, high C, yellow-orange H */
--bg-dark: oklch(0.1 0 264); /* Nearly black - low L, no C, blue H */
--bg: oklch(0.2 0 264); /* Dark gray - slightly higher L */
--bg-light: oklch(0.3 0 264); /* Lighter gray - continues progression */
--text: oklch(0.96 0 264); /* Near white - very high L */
--text-muted: oklch(0.76 0 264); /* Muted gray - mid-high L */
}
Light Theme:
body.light {
--primary: oklch(0.65 0.15 264); /* Purple accent - mid L, lower C */
--bg-dark: oklch(0.92 0 264); /* Light gray - inverted hierarchy */
--bg: oklch(0.96 0 264); /* Lighter gray */
--bg-light: oklch(1 0 264); /* Pure white */
--text: oklch(0.15 0 264); /* Near black */
--text-muted: oklch(0.4 0 264); /* Mid gray */
}
OKLCH Usage Rules
Creating new colors:
- Keep hue consistent: Use 264 (blue-purple) for neutral colors
- Use chroma=0 for grays: Keeps them perfectly neutral
- Lightness hierarchy:
- Dark theme: 0.1 → 0.2 → 0.3 (backgrounds), 0.76 → 0.96 (text)
- Light theme: 0.92 → 0.96 → 1.0 (backgrounds), 0.4 → 0.15 (text)
- Accent colors: Higher chroma (0.15-0.2) and different hue (100 for warm, 264 for cool)
Color contrast rules:
- Minimum lightness difference: 0.5 for readability
- Dark theme: 0.2 bg with 0.76+ text
- Light theme: 0.96 bg with 0.4 text
Example - Creating a success color:
--success-dark: oklch(0.8 0.15 145); /* Green hue, high lightness for dark theme */
--success-light: oklch(0.5 0.12 145); /* Lower lightness for light theme */
Example - Creating hover states:
/* Move up the lightness hierarchy */
.card {
background: var(--bg); /* oklch(0.2 0 264) */
}
.card:hover {
background: var(--bg-light); /* oklch(0.3 0 264) */
}
Color Code Rules
When writing color code:
- ALWAYS use oklch() format, never hex or rgb
- Keep chroma at 0 for neutral grays
- Maintain consistent hue (264) across theme
- Test lightness contrast (minimum 0.5 difference)
- Use CSS variables, don't hardcode values
Token Roles
A primitive is the raw value. A token is how that value gets used. Components read tokens only, never primitives: color: var(--color-text-secondary), never color: var(--blue-500). The primitive layer is where you tune the palette; the token layer is where the product states what each colour is for. Skip the token layer and every component becomes a place the redesign has to visit.
Name a token for its role, never for its appearance or its first use. --color-accent-solid survives a rebrand. --color-blue-button lies the moment the brand turns green, and --color-sidebar-gray lies the moment a second surface needs the same value. Ask what job the colour does, then name the job: error, muted, accent-solid.
Reserve accent for the brand colour. Left alone, primary drifts into meaning both the brand and the main body text, so a single rename breaks one of the two. Give the brand accent and leave primary for the dominant foreground, and each name keeps one meaning.
Never reuse a token from another role because it happens to be the right colour. A border token that matches the divider you need today is still a border token. When the border role changes, your divider changes with it, for a reason nobody can find. Add a token for the new role instead, even when the two values start out identical.
Every step in a palette needs a purpose. Give each step a job before you ship it: page background, component hover, border, solid fill, body text. The Algorithmic Palette Ramp below generates 50 through 950 mechanically, which is the right starting point and the wrong finishing point. Steps nothing uses still cost review time and still tempt someone into picking by eye. Drop them.
Opacity Scale
Fix a small set of opacity values the way you fix spacing and type, then reuse them. Every disabled control, overlay, scrim, and hover tint reads from that set, so the same state looks the same everywhere.
@theme {
--opacity-subtle: 0.05; /* hover tint on a neutral surface */
--opacity-faint: 0.1; /* hairline fills, low-contrast dividers */
--opacity-soft: 0.2; /* pressed tint, selection wash */
--opacity-disabled: 0.4; /* every disabled control */
--opacity-scrim: 0.6; /* modal scrim over content */
--opacity-heavy: 0.8; /* near-solid overlay */
}
Opacity is not one of Tailwind v4's built-in namespaces, so these names emit the custom properties but generate no utilities of their own. Read them with the custom-property shorthand:
<button class="opacity-(--opacity-disabled)" disabled>Save</button>
To type opacity-disabled instead, register the utility once and it resolves the same tokens:
@utility opacity-* {
opacity: --value([percentage]);
opacity: calc(--value(integer) * 1%);
opacity: --value(--opacity-*);
}
Without the set, opacity gets dragged on a slider per component, and a screen ends up with disabled buttons at 0.63, 0.42, and 0.78. Nothing is individually wrong, and the row still reads as sloppy, because three values claim three different states where only one exists.
Two limits on the scale. A disabled control still has to be perceivable, so check the faded result against its background rather than trusting the token, and never fade text below the point where it disappears (see the APCA table: Lc 30 is the floor for disabled and placeholder text). And do not reach for opacity when a token would say it better: a muted foreground color beats body text at 0.6, because the faded text composites against whatever sits behind it while the token does not.
Theme Switching
Pick one mechanism and use it for every token. Either prefers-color-scheme reads the system setting, or a .dark class on the root reads an in-app choice. Mixing them leaves a subset of tokens answering to the operating system while the rest answer to the toggle, so the theme comes out half-flipped in exactly the case nobody tests: the user whose system says dark and whose toggle says light. Choose the class when the product offers its own switch, and the media query when it does not.
Disable all transitions while the theme changes. Every colour token changes at once, so any element carrying a colour transition animates its own private cross-fade, and the page dissolves in pieces over several hundred milliseconds instead of switching. Suppress transitions for the duration of the swap, then restore them.
/* Set while the theme flips, removed on the next frame. */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
function setTheme(next) {
document.documentElement.classList.add("theme-switching");
document.documentElement.dataset.theme = next;
// Two frames: one for the class to land, one for the paint that uses it.
requestAnimationFrame(() =>
requestAnimationFrame(() =>
document.documentElement.classList.remove("theme-switching"),
),
);
}
Color Details
Color relationships are easier to control when lightness, chroma, and hue can be reasoned about independently. oklch() makes those dimensions explicit and keeps perceived changes more even than channel-based mixing.
Build a palette around a consistent temperature. Darker shades may need slightly more chroma or a small hue shift to avoid looking dull. Translucent foregrounds inherit some of the surface beneath them, which can make a family of layers feel related. Always confirm contrast in the actual composited result.
For gradients, name the interpolation space and shape the stops to match perception. The space is a real choice with three answers, so make it deliberately rather than taking the default:
| Space | Write | Use it for |
|---|---|---|
| OKLab | in oklab | Even brightness across the ramp. The safe default for a fade between two hues. |
| OKLCH | in oklch | More vivid middle tones, because hue travels the colour wheel instead of cutting through the middle. |
| sRGB | no keyword | The classic muted midpoint. Pick it on purpose, not by forgetting the keyword. |
Evenly spaced opacity stops can reveal bands or a visible “horizon”; eased stops distribute the transition more naturally.
.ambient-fade {
background: linear-gradient(
in oklch to bottom,
oklch(62% 0.18 265 / 0) 0%,
oklch(62% 0.18 265 / 0.06) 18%,
oklch(62% 0.18 265 / 0.22) 42%,
oklch(62% 0.18 265 / 0.56) 70%,
oklch(62% 0.18 265 / 0.92) 100%
);
}
.tinted-surface {
background: oklch(97% 0.012 255 / 0.82);
border: 1px solid oklch(20% 0.02 255 / 0.1);
backdrop-filter: blur(18px) saturate(1.15);
}
Blend modes can add material richness, but isolate them inside a known layer stack and test light, dark, and high-contrast contexts.
Checklist
- Work in HSL or OKLCH, not hex
- Hand-pick colors for colored backgrounds (don't use opacity)
- Add accent colors for interest
- Ensure 4.5:1 contrast ratio for text
- Use color + another indicator (not color alone)
- Define 8-10 shades per color
- Chroma set as a share of each step's gamut maximum, so vividness holds and no step clips
- Opacity values come from the fixed scale, never a slider
- Badges, tags, and status pills default to a light tint with dark text, with a solid fill kept for the one that has to lead
- Multiply-blend light/translucent chips that sit over coloured rows (guard dark with
mix-blend-normal); never blanket-blend opaque badges
APCA Contrast (complements WCAG AA)
WCAG 2's 4.5:1 luminance ratio is still the legal bar for a formal conformance claim, so keep passing it. But its ratio is both too strict and too lenient depending on the pair, and it ignores polarity. APCA (Accessible Perceptual Contrast Algorithm) is more perceptually accurate and pairs naturally with oklch, since both are built on perceptual lightness. Use APCA to make the call, WCAG AA to satisfy compliance. They are complementary, not a swap.
APCA measures Lc (Lightness Contrast) between a foreground and the background it actually renders against. Identify that background first (usually the nearest parent's fill), then compare against the threshold for the content type:
| Content | Minimum | Preferred |
|---|---|---|
| Body text (columns/blocks) | Lc 75 | Lc 90 |
| Non-body text (labels, headlines) | Lc 60 | Lc 75 |
| Large text (≥ 36px) | Lc 45 | Lc 60 |
| UI components, disabled/placeholder text | Lc 30 | n/a |
Lc 15 is the absolute floor for a non-text element to be discernible at all. Lc 30 doubles as the minimum for disabled and placeholder text.
Polarity is signed. A positive Lc means dark text on a light background, negative means light text on a dark background. Mirrored pairs do NOT score identically, so take the absolute value only when comparing against a threshold.
Light vs dark text crossover: L 0.73. On a neutral background, that oklch lightness is where black and white text swap which one wins:
if L > 0.73 → dark text on this background
if L <= 0.73 → light text on this background
The crossover sits higher than intuition suggests: in the 0.6 to 0.73 band the surface already looks light, yet white text still scores meaningfully higher than black.
Mid-lightness backgrounds cap contrast. On a background near L 0.75, even pure black text tops out around Lc 60, so body text always needs a background near the light or dark extreme. As a lightness-gap starting point for body text (targeting |Lc| ≥ 75): on a light background (L > 0.9) put the foreground below L 0.35; on a dark background (L < 0.25) put it above L 0.9. Adjust L first, keep C and H, then remeasure the rendered pair.
Gamut and Display P3
Every sRGB color fits inside Display P3, but not the reverse: P3 covers roughly 50% more colors. A color whose chroma exceeds the maximum for its lightness, hue, and space clips.
Max chroma varies by lightness AND hue. The gamut boundary is irregular. At L 0.5 in sRGB, purple (H ≈ 285) reaches C ≈ 0.29, red-orange (H ≈ 0 to 30) about C ≈ 0.20, and cyan (H ≈ 195) only C ≈ 0.09. The peak hue also moves with lightness: magenta peaks near L 0.7, green near L 0.9, and cyan stays lowest throughout. So there is no single safe max chroma, it depends on both L and H.
Clamp chroma to stay in sRGB. When a value clips, reduce C while holding L and H constant:
/* Out of sRGB gamut */
oklch(0.7 0.35 150)
/* Clamped to max chroma at this L and H */
oklch(0.7 0.22 150)
Progressive enhancement. Ship an in-gamut sRGB value as the base, then let wide-gamut displays opt into higher chroma:
/* sRGB-safe base, renders everywhere */
.accent {
color: oklch(0.7 0.2 150);
}
/* Richer chroma on P3 displays */
@media (color-gamut: p3) {
.accent {
color: oklch(0.7 0.3 150);
}
}
Both branches are oklch: color-gamut selects the wider palette, it does not test syntax support. Do not wrap this in an @supports (color: oklch()) gate whose fallback is also oklch, an engine without oklch support would drop every branch and render no color at all. A real syntax fallback needs a non-oklch value (sRGB hex or rgb), which this repo's oklch-only rule forbids, so target Baseline 2023 browsers (oklch shipped in all of them) and skip the syntax gate.
Rotate the Hue as a Ramp Darkens
A ramp built on yellow goes muddy on the way down. The cause is the gamut, not your lightness steps: how much chroma a hue can carry changes with lightness, and it changes differently for every hue. Yellow carries its chroma at the light end and loses it as it darkens, so a dark yellow has almost nothing left to be yellow with, and lands on olive.
Measured against sRGB, maximum chroma at a few lightnesses:
| oklch L | H 95 (yellow) | H 72 (amber) | H 48 (orange) | H 25 (red) |
|---|---|---|---|---|
| 0.90 | 0.159 | 0.078 | 0.057 | 0.052 |
| 0.68 | 0.140 | 0.146 | 0.185 | 0.208 |
| 0.51 | 0.105 | 0.109 | 0.139 | 0.207 |
Yellow starts richest and ends poorest; red is the reverse. So rotate a warm hue toward orange and red as its lightness drops, and the darker steps land where chroma is still available:
:root {
/* Lightness only: chroma runs out at H 95, and the ramp goes olive. */
--olive-400: oklch(0.66 0.115 95);
--olive-600: oklch(0.46 0.080 95);
/* Hue rotates toward orange as lightness drops: warm the whole way down. */
--amber-200: oklch(0.88 0.154 95);
--amber-400: oklch(0.68 0.124 72);
--amber-600: oklch(0.51 0.118 48);
}
Rules for using the rotation:
- Rotate toward the hue that holds chroma at the lightness you are heading for. Warm hues rotate toward red as they darken. Cool hues need far less of this, because blue and purple already carry their chroma low; a very light blue is the case that wants rotating, toward cyan, where chroma survives at high lightness.
- Cap a single derivation at 20 to 30 degrees. Rotating one color to make a darker version of that color stops reading as the same color past about 30 degrees.
- A whole ramp travels further, and should. The steps accumulate, so judge each step rather than the span: Tailwind's own amber runs from hue 95 at
50to 46 at950, about 50 degrees in total, while no adjacent pair moves more than about 21. Keep each step inside the single-derivation cap and check that the two ends still read as one family. The example above moves 23 degrees and then 24. - Stay inside the gamut at every step. Each value above sits near 85% of the maximum chroma for its own lightness and hue, which is the same rule as
Match Chroma Percent Across Huesbelow, applied along one ramp instead of across several.
This is not a perceived-brightness correction. In oklch, L is already perceptual lightness, so two hues at the same L do read as equally light, and Match Chroma Percent Across Hues below relies on exactly that. HSL guidance says otherwise, and it is right about HSL, whose L does not track perception. Do not import the HSL version of this rule: rotate to keep chroma available, not to fix a brightness mismatch that oklch does not have.
The same headroom argument is the escape hatch for text on a dark colored panel. Raising L alone drives the text toward white before it clears the contrast ratio; rotating toward a hue that still carries chroma at that lightness lets you raise L and keep the text visibly colored.
Flip the Contrast for Badges, Tags, and Status Pills
White text on a colored fill needs that fill to be dark to clear 4.5:1, and a row of dark saturated pills grabs attention those elements have not earned. A status badge is metadata; it should be readable at a glance and quiet at rest.
Invert it: dark text in the same hue on a light tint of it. The 100 step for the tint with 800 for the text is the usual starting pair, and it keeps the semantic color while sitting quietly in the hierarchy.
Those labels are a starting point, not a contrast guarantee. A step number says nothing about the value behind it, and the same pill can sit on a card, a tinted hover row, or a translucent surface, each of which changes what the text renders against. Measure the rendered pair and move a step in either direction until it clears 4.5:1.
// ✅ Quiet, legible, still unmistakably a status
<span className="rounded-full bg-red-100 px-2.5 py-1 text-xs font-semibold text-red-800">
Overdue
</span>
// ❌ Solid saturated fill: shouts louder than the content it labels
<span className="rounded-full bg-red-600 px-2.5 py-1 text-xs font-semibold text-white">
Overdue
</span>
Make this the default for badges, tags, and status pills. Keep the solid fill for the cases that genuinely lead: a primary button, or a single count badge that must be seen before anything else on the screen. When several pills sit in one row or column, the tinted treatment is what stops the row reading as an alert.
Two things this pairs with. A tinted pill over a colored hover row needs the multiply blend from Keep Tinted Chips from Washing Out on Coloured Rows above. And in dark mode the same flip runs in reverse, a dark tint carrying light colored text, which needs its own contrast check rather than a mirrored value.
Match Chroma Percent Across Hues, Not Absolute Chroma
When a palette spans several hues (blue, green, red), give every hue the same lightness and the same chroma percentage (its share of that hue's own gamut maximum), not the same absolute C. Equal L guarantees equal perceived brightness. Equal C percent guarantees equal vividness relative to each hue's ceiling. Equal absolute C does not: because max chroma differs by hue, a fixed C value makes some hues look washed out and others fluorescent.
:root {
/* Same L, same C% (80% of each hue's max): different absolute C */
--blue-500: oklch(0.623 0.141 250); /* 80% of max 0.176 */
--green-500: oklch(0.623 0.157 145); /* 80% of max 0.196 */
--red-500: oklch(0.623 0.202 25); /* 80% of max 0.253 */
}
Algorithmic Palette Ramp
To generate a full scale from one base color, hold hue fixed and vary lightness and chroma per step. Given a base L, a chroma percentage, and a hue H:
- Bound lightness. With
delta = 0.4, setminL = max(0.05, baseL - delta)andmaxL = min(0.95, baseL + delta). The clamp to about [0.05, 0.95] avoids pure black/white, which carry zero chroma. - Distribute L evenly from
maxL(lightest, label 50) down tominL(darkest, label 950). - Clamp chroma per step to that step's gamut max:
step.C = (chromaPercentage / 100) * findMaxChroma(step.L, H, colorSpace). Every step lands in gamut, and a high-chroma base correctly loses chroma at the lightest and darkest ends.
:root {
--color-50: oklch(0.971 0.012 250);
--color-100: oklch(0.932 0.028 250);
--color-200: oklch(0.882 0.048 250);
--color-300: oklch(0.812 0.078 250);
--color-500: oklch(0.623 0.188 250);
--color-700: oklch(0.445 0.138 250);
--color-800: oklch(0.362 0.108 250);
--color-900: oklch(0.289 0.078 250);
--color-950: oklch(0.215 0.048 250);
}
Do not mechanically mirror the scale for dark mode. Swap the semantic roles first, then re-tune: equal oklch steps do not preserve every foreground/background contrast pair, so recheck each pair and adjust the dark tokens independently.
Tailwind v4 @theme oklch Scale
Tailwind v4 defines its default palette in oklch, so custom themes follow suit. Declare the ramp in an @theme block and every step becomes a utility (bg-brand-500, text-brand-200, border-brand-700):
@theme {
--color-brand-50: oklch(0.971 0.012 250);
--color-brand-100: oklch(0.932 0.028 250);
--color-brand-200: oklch(0.882 0.048 250);
--color-brand-300: oklch(0.812 0.078 250);
--color-brand-400: oklch(0.722 0.148 250);
--color-brand-500: oklch(0.623 0.188 250);
--color-brand-600: oklch(0.535 0.168 250);
--color-brand-700: oklch(0.445 0.138 250);
--color-brand-800: oklch(0.362 0.108 250);
--color-brand-900: oklch(0.289 0.078 250);
--color-brand-950: oklch(0.215 0.048 250);
}
The slash-opacity modifier works directly on these tokens, no extra config:
<div class="bg-brand-500/50"></div>
<!-- compiles to oklch(0.623 0.188 250 / 0.5) -->
Alpha lands after the slash, matching the repo's oklch alpha convention. When migrating an existing theme, convert every @theme hex to oklch, replace theme() references that used hex, convert hardcoded hex in component code, and re-test dark mode since perceptual values shift slightly.