Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 11, 2026

OKLCH Color Scales: How Our Theme Builder Works

How the Theme Builder turns one brand color into an OKLCH color scale: bezier curves for L, C and H, pinned stops, gamut clamping, and why HSL falls short.
CSS
Color
Theming
Quasar

An OKLCH color scale is a set of shades, 50 to 950, where every step is defined by three numbers people can reason about: lightness, chroma and hue. That's how the free Theme Builder builds the 11 shades of each Quasar brand color, and this post walks through the actual code: the curves, the pinned stops, the gamut clamp, and the shortcuts I took. If you only want a ready-made theme, the 12 theme presets are the faster path. This is the part underneath them.

Why HSL palettes look muddy

The obvious way to build a scale is HSL: keep the hue, step the lightness from 95% down to 15%. It falls apart as soon as you compare two hues. HSL's lightness is a formula on RGB channels, not a measure of how bright a color looks.

Pure yellow (#FFFF00) and pure blue (#0000FF) both have an HSL lightness of 50%. Converted to OKLCH with the Theme Builder's own code, they come out as:

ColorHSL lightnessOKLCH lightness
#FFFF0050%96.8%
#0000FF50%45.2%
#00FF0050%86.6%
#FF000050%62.8%

So a "500" yellow and a "500" blue built with HSL are nowhere near the same visual weight. Text that is readable on one fails on the other, and the mid shades of saturated hues turn gray or neon depending on the hue. You end up hand-tuning every scale.

OKLCH (Björn Ottosson's Oklab, in polar form) was designed so that equal steps in L look like equal steps in lightness across hues. C is chroma, how colorful the color is, and H is the hue angle. CSS supports it directly:

:root {
  --qt-color-primary-500: oklch(72.3% 0.219 149.579);
}

That one property is what makes an OKLCH color scale predictable: if two palettes have the same lightness curve, their 500s have roughly the same weight, whatever the hue.

An OKLCH color scale as three curves

The engine in app/utils/theme/palette.ts is ported from Nuxt UI's theme engine (MIT). It doesn't store 11 colors and interpolate between them. It stores three curves, one per channel, and samples them.

Each shade gets a position x on a 0 to 1 axis. Shade 50 is 0, shade 950 is 1, and everything between is shade / 1000, so 500 sits at 0.5:

export function shadeX(shade: Shade): number {
  if (shade === 50) return 0
  if (shade === 950) return 1
  return shade / 1000
}

Each channel is a cubic bezier, defined like a CSS easing curve: a start value y0 at shade 50, an end value y1 at shade 950, and two handles (p1x, p1y) and (p2x, p2y):

interface ChannelCurve {
  y0: number
  y1: number
  p1x: number
  p1y: number
  p2x: number
  p2y: number
}

interface PaletteCurveParams {
  lightness: ChannelCurve
  chroma: ChannelCurve
  hue: ChannelCurve
}

To read the curve at a given x, the code has to find the bezier parameter t whose x-coordinate is x. There's no closed form worth writing, so sampleCurve bisects: 32 rounds of halving the interval, which is far more precision than an 8-bit color needs. Then it evaluates the y-coordinate at that t.

This shape gives you the controls that matter for a scale:

  • Lightness runs from near white to near black, with handles that keep the light end flat (several pale tints) and the dark end steep.
  • Chroma starts low, peaks in the middle and falls off, because very light and very dark colors can't hold much chroma anyway.
  • Hue can drift. Real palettes do this: Tailwind's orange goes from a hue of about 74 at shade 50 to about 36 at shade 950, warmer as it gets darker.

Neutral ramps use the same machinery with darker defaults. Their lightness ends at 0.13 instead of 0.27, so a dark-mode background built from neutral-900 or 950 is actually dark.

Fitting curves to an existing palette

You don't start from a blank curve. When you open the palette editor for a color, the Theme Builder works backwards from the current scale, for example Tailwind's orange, to curves that reproduce it. That's fitPalette:

  1. Parse each shade into OKLCH.
  2. Anchor y0 and y1 to the first and last stops.
  3. Move the four handle values by coordinate descent: nudge one value, keep it if the squared error drops, halve the step size when nothing improves.
  4. Run that from three different starting handle placements and keep the best fit, so one bad starting point can't trap it.

Hue gets two extra steps. Stops with chroma below 0.01 are nearly gray, and their hue is noise, so they borrow the hue of the nearest colorful stop. And hues are unwrapped (359 followed by 2 becomes 359 followed by 362) so the curve never takes the long way around the color wheel.

The fit is close, not exact. For Tailwind's orange, shade 400 comes back as oklch(77% 0.158 55.947) against the original oklch(75% 0.183 55.934). Four handles can't hit 11 points perfectly, and that's fine: the curve is a starting point you then edit.

Turning one brand color into the whole scale

Here's the part the title promises. In the palette editor, click a swatch and type a HEX or OKLCH value. That stop is now pinned: the scale must pass through that exact color, and the shades around it bend to follow.

A pin is a correction on top of the curves, not a new curve. For each channel, the code computes how far the pinned value is from the curve at that stop, then spreads that difference to the neighbors with a Gaussian bump:

function pinKernel(distance: number, sigma: number): number {
  const t = distance / sigma
  return Math.exp(-t * t)
}

With one pin, that's the whole story: the pinned shade moves all the way, its neighbors move part of the way, and shades far away barely move. With several pins, the bumps overlap, so the code solves a small linear system (Gaussian elimination with partial pivoting) for the weight of each bump, so that every pin lands exactly. The bump width sigma starts at 0.2 and narrows to the closest distance between two pins (never below 0.04), because wide bumps on close pins make that system ill-conditioned.

A real run: Tailwind's orange, fitted, then shade 500 pinned to a brand color #E8590C (oklch(64.4% 0.192 41.687)):

ShadeFitted orangeWith the 500 pin
300oklch(83.5% 0.123 66.067)oklch(81.3% 0.117 64.053)
400oklch(77% 0.158 55.947)oklch(72.5% 0.164 51.684)
500oklch(70.3% 0.194 47.161)oklch(64.4% 0.192 41.687)
600oklch(63.3% 0.191 41.87)oklch(58.7% 0.194 37.606)
800oklch(48% 0.16 37.134)oklch(47.4% 0.159 36.557)
950oklch(26.6% 0.079 36.259)oklch(26.6% 0.079 36.248)

The brand color is darker and redder than Tailwind's 500, so 400 and 600 shift the same way, 800 barely moves and 950 stays put. You typed one color and got a scale that still looks like a scale. Pin 50 and 950 too if your brand guide has its own tints and shades, and the curve threads through all three.

One honest detail: the output is written as oklch() strings rounded to three decimals, so the pinned 500 converts back to #E8590B, one step off the #E8590C you typed. Nobody can see that difference, but it's there.

Modifiers that don't compound

Below the curves, the editor has sliders for lightness, contrast, saturation and hue, plus an overall amount. Each one is a transform of the curves' control points:

  • Lightness shifts every lightness value up or down.
  • Contrast stretches lightness values away from their midpoint, which spreads the scale.
  • Saturation multiplies chroma, and a boost also adds a small floor (up to 0.02), because multiplying a near-gray ramp's chroma of zero gives zero.
  • Hue rotates all hue values.

The important rule is in the comment on applyPaletteEffects: it's always derived from the base curves. Moving the saturation slider back and forth 20 times gives the same result as moving it once. If the modifiers transformed the previous result, small rounding and clamping errors would pile up with each drag.

Staying inside sRGB

OKLCH can describe colors no sRGB screen can show. A very light, very saturated green has a valid oklch() value, but no hex code. When a sampled shade falls outside the sRGB gamut, clampToGamut keeps its lightness and hue and lowers its chroma until it fits, again by bisection (24 rounds).

That's the right trade for a UI scale. Lightness is what carries contrast and hierarchy, and hue is what makes it your brand. Chroma is the one channel you can lose a little of without anyone noticing.

What the Theme Builder exports

Each palette's 11 shades become CSS variables in quasar-theme.css, written as oklch():

:root {
  --qt-color-primary-500: oklch(64.4% 0.192 41.687);
  /* ... */
  --q-primary: var(--qt-color-primary-500);
}

body.body--dark {
  --q-primary: var(--qt-color-primary-400);
}

By default, brand colors use shade 500 in light mode and 400 in dark mode, and you can move each token to another shade. Because Quasar's color="primary" and bg-primary read --q-primary, every component follows. The quasar.variables.scss file gets light-mode hex values, for the Sass code that can't read a CSS variable.

The curve state itself (curves, modifiers and pins) is saved with the theme, so the share link from the Export dialog reopens the editor exactly where you left it.

Common questions about OKLCH color scales

Do browsers support oklch()? Yes, all current major browsers support it in CSS. If you have to support older ones, convert to hex at build time.

Is OKLCH the same as LCH? No. CSS lch() is based on CIELAB, which has a known hue shift in blues (they drift toward purple as they lighten). Oklab was made to fix that, which is why scales built on it hold their hue better.

Does an OKLCH color scale guarantee accessible contrast? No. Equal lightness gives you a similar weight across hues, which makes contrast much easier to predict, but you still have to check the pairs you use. WCAG contrast is computed from relative luminance in sRGB, not from OKLCH lightness.

Try it on your brand color

Open the Theme Builder, pick a color section, click the palette icon, and type your brand hex into the 500 swatch. Watch the other ten shades move, switch the preview to dark mode, then export the theme for Quasar CLI or Nuxt.