Web Developmentguide12 min read

Relative Colors in CSS: The from Keyword

Derive hovers, borders, and semantic variants from one token without a preprocessor.

You had twelve SCSS variables for one brand blue. Hover, active, muted, border, focus ring, disabled, ghost, outline, link, visited, selected, and a darker variant someone invented for dark theme. Each started as a hand-tuned hex. Each drifted out of sync within two sprints. When the brand team updated the primary from cobalt to something slightly greener, you ran a find-and-replace that missed three files and broke contrast on a settings page nobody had opened since launch.

Preprocessors solved this for a decade by letting you write darken($brand, 8%) and $brand-hover: color.mix(black, $brand, 85%). That worked until runtime themes, user-toggle dark mode, and design tokens in CSS custom properties made build-time color math insufficient. You needed to derive a hover background from var(--brand) at computed-value time, in the cascade, without a Sass compiler in the loop. Sass variables compile away. Custom properties survive in the output and respond to data-theme, prefers-color-scheme, and CMS-driven accent overrides.

CSS Color Module Level 5 adds relative color syntax: a from keyword that takes an origin color, exposes its channels, and lets you transform them with calc() in the same declaration. Adam Argyle documented the feature as it landed in Chromium. MDN’s relative color syntax guide is the reference for channel names per color function. The math underneath traces to the same Oklab/OKLCH space Björn Ottosson defined, because perceptually uniform channels make calc(l - 0.06) mean approximately the same perceived darkening across hues. HSL relative syntax exists for legacy compatibility, but calc(l - 10%) in HSL still carries HSL’s uneven lightness across the spectrum.

This article covers the from keyword, channel arithmetic on lightness, chroma, and hue, origins in multiple formats, a production migration story, and fallback strategies for browsers that parse OKLCH without parsing relative syntax.

Syntax: origin, channels, and calc

Relative color syntax extends any CSS color function. The general shape is a color function name, the from keyword, an origin color, and channel expressions that either pass through a channel literally or transform it with calc(). The origin can be hex, a named color, var(), currentColor, or another color function. The browser resolves the origin to a computed color, converts it to the target color space, applies your channel math, and renders the result.

For design systems that have migrated tokens to OKLCH, oklch(from …) is the derivation function you want. Each color function exposes differently named channels. OKLCH uses l, c, h, and alpha. HSL uses h, s, l, and alpha. RGB uses r, g, b, and alpha. Lab and LCH use space-specific opponent or cylindrical channels. The channel names are not interchangeable across functions. Subtracting from l in OKLCH is perceptually meaningful in a way that subtracting from HSL lightness is not, because OKLCH lightness was fit to human perception data while HSL lightness is a geometric remapping of the RGB cube.

The practical payoff is single-source derivation. Change --accent once and every oklch(from var(--accent) …) expression tracks automatically, including when --accent itself changes under [data-theme="dark"] without a rebuild. That is the capability Sass darken() never had when the base color was a custom property resolved at runtime.

:root {
  --accent: oklch(0.55 0.18 264);
}

.btn {
  background: var(--accent);
}

.btn:hover {
  background: oklch(from var(--accent) calc(l - 0.06) c h);
}

The browser resolves var(--accent) to a computed color, converts it to OKLCH, subtracts 0.06 from lightness, preserves chroma and hue, and renders the result. Disabled states can halve effective chroma and alpha in one expression instead of maintaining a separate gray token. Focus rings can darken perceptually without a hand-tuned hex that drifted from the brand six months ago.

When twelve hover hex values became one expression

The drift problem surfaced at a B2B analytics company whose design system had officially migrated to OKLCH tokens in January but had never audited component-level color math. The token JSON was clean. Storybook swatches matched Figma. Leadership considered the migration complete. Then a customer white-labeling pilot required runtime accent overrides from a CMS, and the engineering team discovered that forty-seven component files still referenced static hover hex values generated during the original Sass era.

The pilot tenant chose a teal accent. Engineering set --accent on the document root from JavaScript. Primary buttons updated correctly because they used var(--accent) for backgrounds. Hover states did not update because they still pointed at #1d4ed8, a cobalt hover baked in when the product was blue. QA filed a single ticket describing buttons that looked correct at rest and wrong on interaction. Engineering assumed a caching bug until a senior developer searched the codebase for hard-coded hex in :hover rules and found the pattern repeated across cards, tabs, pagination, and a data table that had been copy-pasted into three products.

The deeper failure was in shared utilities that had been partially migrated. A popular button mixin set background: var(--btn-bg) but set hover with a preprocessor function at build time when $btn-bg was still a Sass variable. After the token migration, the mixin was updated to accept a custom property name, but hover was still computed as color-mix(in srgb, var(--btn-bg), black 15%) because nobody had verified the mix space. On teal, the sRGB mix produced a muddy hover. On the original cobalt, it looked fine. The inconsistency was space-dependent, not tenant-dependent, which is why the bug had survived internal QA.

The fix sprint replaced every static hover hex and every sRGB mix with OKLCH relative syntax. Hover backgrounds used calc(l - 0.06) on the same chroma and hue. Active states used calc(l - 0.10). Disabled states multiplied chroma by 0.3 and alpha by 0.5 while preserving hue so the control still read as branded but non-interactive. Focus rings used calc(l - 0.12) for a visible edge without introducing a second accent family. Border colors on filled buttons derived from the same base with calc(l - 0.08) so borders stayed coherent with fills.

Dark mode required a polarity flip that relative syntax handled elegantly. On light surfaces, hover darkens by subtracting lightness. On dark surfaces near black, hover lightens by adding lightness. The team used two rules rather than two token sets:

.btn:hover {
  background: oklch(from var(--btn-bg) calc(l - 0.06) c h);
}

[data-theme="dark"] .btn:hover {
  background: oklch(from var(--btn-bg) calc(l + 0.06) c h);
}

Base tokens changed per theme. Derived states followed without duplicating hex. The white-label pilot shipped two weeks later with zero additional hover tokens per tenant. Design-ops updated the component checklist to require relative syntax for any derived state, and CI added a grep rule blocking new hex in pseudo-class rules. The migration took one sprint because the token layer was already honest. The component layer had been lying by omission.

Patterns that replace Sass color functions

Semantic muted variants multiply chroma by a factor rather than inventing a second palette step. A badge background at forty percent chroma of the accent reads as a tint without wandering hue. Alert families can rotate hue while holding lightness and chroma steady, which keeps success and error variants visually related to the brand accent in a way HSL hue rotation never guaranteed.

Alpha derivation avoids maintaining parallel RGBA tokens. An overlay at fifteen percent opacity of the accent, a scrim that forces lightness to a specific value while preserving hue—these are single expressions, not four custom properties per component state.

Origins are not limited to custom properties on :root. Hex origins work for one-off conversions. currentColor lets SVG icons inherit parent text color and then desaturate with calc(c * 0.5) without a separate icon token. Nested color functions accept wide-gamut origins: oklch(from color(display-p3 0.2 0.4 0.9) l c h) preserves the resolved color through a transform. Theme-scoped rules can derive border colors from accents that themselves change under data-theme.

The mapping from common Sass patterns to relative OKLCH is direct enough to put on an internal wiki. darken($c, 8%) becomes oklch(from $c calc(l - 0.08) c h) when $c is available at cascade time as a custom property. desaturate($c, 30%) becomes oklch(from $c l calc(c * 0.7) h). adjust-hue($c, 40deg) becomes oklch(from $c l c calc(h + 40)). Transparency without a separate function is oklch(from $c l c h / 0.5).

:root {
  --surface: oklch(0.98 0.01 260);
  --text: oklch(0.22 0.02 260);
  --accent: oklch(0.55 0.18 264);
}

.btn:focus-visible {
  outline: 2px solid oklch(from var(--accent) calc(l - 0.12) c h);
  outline-offset: 2px;
}

.btn:disabled {
  background: oklch(from var(--accent) l calc(c * 0.3) h / 0.5);
}

.badge-muted {
  background: oklch(from var(--accent) l calc(c * 0.4) h);
  color: oklch(from var(--accent) calc(l - 0.25) c h);
}

Evil Martians note that teams migrating off Sass should treat relative syntax as the replacement for color functions, not as an addition to them. One source token, many derived states, zero drift.

Fallbacks, support, and where preprocessors still belong

Relative syntax requires @supports fallbacks in production until your analytics justify dropping legacy browsers. Ship a precomputed static color first, then override inside a support query that probes from specifically, not merely oklch() without from. Some browsers parsed absolute OKLCH before they parsed relative syntax. Testing only oklch(0.5 0.2 0) is insufficient.

.btn:hover {
  background: #1d4ed8;
}

@supports (background: oklch(from #000 l c h)) {
  .btn:hover {
    background: oklch(from var(--accent) calc(l - 0.06) c h);
  }
}

Where @supports passes but you want a simpler fallback for specific properties, color-mix(in oklch, var(--accent) 85%, black) covers darken and lighten approximations without channel-level precision. Mix cannot rotate hue or multiply chroma cleanly. Pair mix for broad support with from for precision on modern browsers.

As of 2026, relative color syntax ships in Chromium 119 and later, Safari 16.4 and later, and Firefox 128 and later. Enterprise locked builds and older WebViews remain in analytics tails. Always provide non-relative fallbacks for hover and focus states that affect usability, not only aesthetics.

Relative syntax does not replace every build-time color workflow. Design token generation from JSON, cross-platform export to iOS and Android, and complex APCA-compliant scale generation across ten hues remain easier in TypeScript or Sass at build time. Relative syntax wins where the cascade already owns the source of truth: runtime themes, user preferences, prefers-color-scheme, component-level currentColor, and keeping hover math adjacent to the base token in the same stylesheet.

Pitfalls that look like browser bugs

OKLCH lightness is typically expressed on a zero-to-one scale in authoring tools, not as a percentage. calc(l - 10%) does not behave like calc(l - 0.10) unless you explicitly want percentage semantics in the lightness channel. If --accent is missing, the entire relative color becomes invalid. Provide defaults: oklch(from var(--accent, oklch(0.55 0.18 264)) calc(l - 0.06) c h).

Subtracting lightness from an already-dark color can push below zero; browsers clamp. Guard with max(0, calc(l - 0.10)) if you compose multiple transforms. Hue wrapping at the zero and three-hundred-sixty boundary is handled per spec, but extreme offsets deserve visual testing on gradients and conic fills where arc direction matters.

Channel arithmetic on hue deserves explicit testing when you build semantic families from one accent. Rotating hue to twenty-five degrees for error states or one hundred forty-five for success while holding lightness and chroma steady is readable in code and maintainable in review. Extreme offsets near the zero and three-hundred-sixty boundary should be checked on real components because conic fills and gradient stops behave differently than solid buttons. MDN documents per-function channel names; using l in an hsl(from …) expression is valid for legacy tokens but perpetuates uneven darkening if your palette has already moved to OKLCH.

Nesting relative syntax inside other functions remains risky where support is not universal. Keep derivations at the property level until your analytics justify composition inside color-mix() or filter chains. Component-level currentColor derivation is underused: icons that should track muted parent text without a separate --icon-muted token can use oklch(from currentColor l calc(c * 0.5) h) on fill or stroke with no additional custom properties.

Testing strategy should include @supports probes that match production fallback blocks, visual regression on hover and focus for every interactive component, and dark mode screenshots where hover polarity inverts. Relative syntax failures often manifest as missing styles rather than wrong styles when a custom property is undefined, so default values in var() are not optional polish.

Documentation for design systems should show derivation recipes adjacent to base tokens in the same table designers review. When --accent-hover is listed as a separate hex, designers will request hand tweaks every rebrand. When the doc says hover equals calc(l - 0.06) on accent, the conversation shifts to whether zero point zero six is the correct perceptual step, which is a soluble design decision. Relative syntax makes the relationship visible instead of hiding it in a preprocessor file engineers no longer read.

CSS Color 4’s OKLCH gives you the perceptual coordinates. Color 5’s from keyword gives you the derivation language. Together they replace the Sass color function layer for modern browsers. Relative colors close the drift gap between brand tokens and the dozen variants each token spawned. One --accent, six derived states, zero duplicate hex. The preprocessor color library was a bridge; from is the road.