Migrating Colors from HSL to OKLCH

Dialtone color tokens and CSS have moved from HSL to OKLCH. Per-channel breakout variables are removed, cutting CSS bundle over 30%, and removing over 3,200 CSS Variables. Migration scripts included.

TLDR

  • All Dialtone color tokens are now stored and output as OKLCH instead of hex/HSL.
  • Per-channel breakout CSS variables (--dt-color-*-h, -s, -l, -a, -hsl, -hsla) are removed.
  • Theme CSS bundle reduced by ~31% (385 KB minified) from removing ~3,200 CSS variable declarations.
  • Use the HSL-to-OKLCH Migration Tool to automate consumer migration.
  • LESS ~"" string escaping around color functions is no longer needed.

Quick checklist

  1. Run the HSL-to-OKLCH migration tool on your project.
  2. Search your codebase for --dt-color-*-h, -s, -l, -hsl, -hsla. These variables no longer exist.
  3. Visually verify key surfaces, gradients, and brand colors after migration.

Why OKLCH?

OKLCH (oklch(L C H)) is a perceptually uniform color space where L = lightness (0–1), C = chroma (0+), and H = hue (0–360). It replaces hsl()/hsla() across Dialtone for three reasons:

  1. Perceptual uniformity. Equal numeric changes produce equal visual changes. In HSL, hsl(60, 100%, 50%) (yellow) and hsl(240, 100%, 50%) (blue) have the same "lightness" but vastly different perceived brightness. OKLCH corrects this, making lightness adjustments predictable across all hues.

  2. Better gradients. Gradients interpolated in OKLCH avoid the muddy grays that appear in HSL transitions between distant hues. Colors stay vivid across the gradient.

  3. Modern CSS integration. OKLCH enables relative color syntax (oklch(from var(--token) l c h / alpha)), which can decompose any color into its channels at render time. This eliminates the need for pre-computed breakout variables, directly enabling the bundle size reduction below.

Performance Impact

Faster CSS parsing at runtime

This looks to be the single most impactful performance benefit. Every CSS custom property declaration the browser loads must be:

  1. Parsed and validated during stylesheet processing
  2. Stored in the CSSOM (CSS Object Model) in memory
  3. Resolved during style recalculation on every DOM mutation, resize, and interaction
  4. Cascaded through the inheritance chain at every element

Custom properties are especially expensive in the cascade because they participate in inheritance at every node. Removing ~3,200 declarations per theme speeds up style recalculation on every interaction, not just the initial page load.

Smaller CSS bundles

Removing the per-channel breakout variables eliminated ~3,200 CSS custom property declarations per theme. These variables (-h, -s, -l, -a, -hsl, -hsla for each color token) were the single largest contributor to Dialtone's CSS bundle size.

The file that matters most is dialtone-default-theme.min.css, which every Dialpad product loads:

File Before After Reduction
dialtone-default-theme.min.css 1,238 KB 853 KB -31.1%
dialtone-default-theme.min.css (gzip) 132 KB 95 KB -27.7%
tokens-dp-light.css 370 KB 105 KB -71.5%
tokens-base-light.css 99 KB 31 KB -68.8%

That's 385 KB fewer on the theme file that every user loads. The token CSS files, dominated by breakout declarations, saw 68–72% reductions. Products that load token files independently (theming scenarios) benefit the most.

Developer experience

Beyond performance, the migration simplifies how developers work with Dialtone colors:

  • Predictable lightness adjustments. In HSL, calc(lightness - 5%) darkens yellow and purple by different perceived amounts. OKLCH lightness is perceptually uniform, so calc(l - 0.05) produces a consistent visual shift across all hues.
  • One token, one variable. As always, favor using semantic tokens instead of channel use base colors or manipulating with HSL breakout variables.
  • Clearer intent. oklch(from var(--token) l c h / 0.5) reads as "this token at 50% opacity." Compare that to hsla(var(--token-h), var(--token-s), var(--token-l), 0.5).
  • No more LESS ~"" escaping. LESS parses hsl() as a built-in function, which breaks when CSS custom properties are used inside it. oklch() passes through LESS untouched, eliminating an entire class of compilation bugs.

What Changed

For Consumers of Dialtone

If your code references any HSL channel breakout variables, it needs to be updated. Refer to the HSL to OKLCH Migration Tool for help. Here are some common patterns and their replacements:

Before: composite HSL var + alpha

color: hsla(var(--dt-color-foreground-primary-hsl) / 0.5);

After: relative color syntax

color: oklch(from var(--dt-color-foreground-primary) l c h / 0.5);

Before: separate H,S,L vars + alpha

color: hsla(
  var(--dt-color-foreground-primary-h),
  var(--dt-color-foreground-primary-s),
  var(--dt-color-foreground-primary-l),
  0.25
);

After: single token reference

color: oklch(from var(--dt-color-foreground-primary) l c h / 0.25);

Before: HSL var without alpha

color: hsl(var(--dt-color-foreground-primary-hsl));

After: direct token reference

color: var(--dt-color-foreground-primary);

Before: LESS ~"" escape

color: ~"hsla(var(--dt-color-black-900-hsl) / 0.5)";

After: no escaping necessary

color: oklch(from var(--dt-color-black-900) l c h / 0.5);

LESS required ~"" string escaping because it tried to parse hsl() as a built-in function. LESS does not recognize oklch(), so the wrapper is no longer necessary.

For Contributors to Dialtone

All internal Dialtone CSS has already been converted. When writing new styles:

  • Use the base token directlyvar(--dt-color-foreground-primary), never channel suffixes.
  • For alpha — use relative color syntax: oklch(from var(--token) l c h / 0.5).
  • For lightness adjustmentoklch(from var(--token) calc(l - 0.05) c h). OKLCH lightness is 0–1 (not 0–100% like HSL), so -5% becomes -0.05.
  • For desaturation — set chroma to 0: oklch(from var(--token) l 0 h).
  • For combined adjustments — combine freely: oklch(from var(--token) calc(l - 0.05) c h / 0.5).

Adjusting Opacity

Dialtone's opacity utilities (d-fco*, d-bgo*, d-bco*, d-dco*) continue to work exactly as before. The underlying mechanism uses CSS relative color syntax with an alpha keyword fallback:

/* Generated utility — alpha keyword preserves the token's original opacity */
.d-fc-primary {
  color: oklch(from var(--dt-color-foreground-primary) l c h / var(--fco, alpha));
}

/* Opacity utility overrides the alpha channel */
.d-fco50 { --fco: 50%; }

When no opacity utility is applied, var(--fco, alpha) falls back to the alpha keyword, preserving the source color's original alpha (e.g. 0.65 for muted colors). When an opacity utility like .d-fco50 is applied, it sets --fco to 50%, overriding the alpha channel.

For custom CSS, apply alpha using relative color syntax:

/* Hardcoded alpha */
background: oklch(from var(--dt-color-surface-primary) l c h / 0.1);

/* Dynamic alpha via CSS variable */
background: oklch(from var(--dt-color-surface-primary) l c h / var(--my-alpha, 1));

Adjusting Color Channels

Relative color syntax lets you adjust any channel of a color token — lightness, chroma (saturation), and hue:

/* Darken by reducing lightness */
color: oklch(from var(--dt-color-foreground-critical) calc(l - 0.05) c h);

/* Lighten */
color: oklch(from var(--dt-color-foreground-critical) calc(l + 0.1) c h);

/* Desaturate (remove chroma) */
color: oklch(from var(--dt-color-foreground-critical) l 0 h);

/* Shift hue */
color: oklch(from var(--dt-color-foreground-critical) l c calc(h + 30));

/* Combine: darken + reduce opacity */
color: oklch(from var(--dt-color-foreground-critical) calc(l - 0.05) c h / 0.5);

Migration Tool: HSL to OKLCH

dialtone-migration-helper includes an hsl-to-oklch config that automates ~90% of consumer HSL breakout usage conversions.

The tool migrates:

  • Composite -hsl/-hsla var patterns (with and without alpha)
  • Separate -h, -s, -l channel var patterns (comma and space syntax)
  • calc() on lightness channel
  • Desaturation patterns (saturation set to 0)
  • LESS ~"" string escaping (stripped automatically)

Requires manual review:

  • LESS compile-time variables (@step0, @step1) — app-internal, outside Dialtone's scope
  • Hardcoded HSL values (hsla(137, 100%, 27%, 0.05)) — should adopt Dialtone tokens
  • calc() on lightness: OKLCH lightness is perceptually different from HSL, so visually verify adjusted values

Usage

npx dialtone-migration-helper --cwd ./src
# Select "hsl-to-oklch" from the config list

Apply All Changes

npx dialtone-migration-helper --cwd ./src --force
# Select "hsl-to-oklch" from the config list

File Types Processed

  • Stylesheets: CSS, LESS, SCSS, SASS, Stylus
  • Templates: HTML, Vue, Markdown
  • Scripts: JavaScript, TypeScript, JSX, TSX

Migrating Colors from HSL to OKLCH documentation last updated Thursday, June 18, 2026