Skip to content

Relative Colors

@urcolor/relative adds CSS Color 5 relative color syntax to @urcolor/core, deriving one color from another by referencing its channels:

css
rgb(from red r g b)
hsl(from red calc(h + 180) s l)
oklch(from #ff0000 calc(l * 0.8) c h)

It ships as a separate package. @urcolor/core never parses this syntax on its own, so anyone who does not use relative colors pays nothing for them.

Installation

sh
bun add @urcolor/relative
sh
npm install @urcolor/relative
sh
pnpm add @urcolor/relative
sh
yarn add @urcolor/relative

Enabling the plugin

Parsing is opt-in and there is no side-effect import. Call registerRelativeColor() once, before any Color.parse() call that needs it:

ts
import { Color } from "@urcolor/core";
import { registerRelativeColor } from "@urcolor/relative";

registerRelativeColor();

Color.parse("oklch(from #3b82f6 calc(l * 0.8) c h)");

registerRelativeColor() returns a dispose function that removes the parser again. Disposal is idempotent, so calling it twice is safe from a test teardown that does not guard against double cleanup:

ts
const dispose = registerRelativeColor();
dispose();
dispose(); // no-op

Before registration, or after disposal, relative syntax is unparseable: Color.parse() returns null, as it does for any other string core does not recognise.

The from syntax

A relative color names an origin after from, then rewrites its own channels from that origin's values:

<notation>(from <origin> <c1> <c2> <c3> [/ <alpha>])

The origin can be any color string @urcolor/core parses on its own: a named color, a hex string or another functional notation, including one carrying its own alpha.

ts
Color.parse("rgb(from red r g b)");                 // red itself
Color.parse("rgb(from rgb(1 2 3 / 40%) r g b / alpha)"); // origin's own alpha, preserved

Each channel slot takes a literal, a channel keyword or a math expression. A bare keyword passes the origin's value through, and a literal replaces it:

ts
Color.parse("rgb(from red 0 g b)"); // green and blue channels from red, red channel zeroed

Channel keywords per notation

The origin is converted into the target notation's own color space before its channels are read, so oklch(from red l c h) sees red's Oklch lightness rather than its sRGB red channel. Each notation exposes its own three keywords, plus alpha everywhere:

notationchannel keywordsCSS units in expressions
rgb()r g b0–255
hsl()h s lh in degrees; s/l 0–100
hwb()h w bh in degrees; w/b 0–100
lab()l a bl 0–100; a/b −125–125
lch()l c hl 0–100; c 0–150; h in degrees
oklab()l a bl 0–1; a/b −0.4–0.4
oklch()l c hl 0–1; c 0–0.4; h in degrees
color()r g b for RGB-family spaces; x y z for the XYZ spaces0–1
every notationalpha0–1

color()'s keyword set depends on the resolved space, since one function covers both the RGB-family spaces (srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020) and the XYZ spaces (xyz, xyz-d50, xyz-d65):

ts
Color.parse("color(from red display-p3 r g b)"); // r, g, b
Color.parse("color(from red xyz x y z)");         // x, y, z
Color.parse("color(from red xyz r g b)");          // null, wrong keywords for xyz

Every keyword is a <number>, not a <percentage>

CSS Color 5 types every component keyword as a <number>, hsl()'s s/l and hwb()'s w/b included. A saturated color's s reads as 100, not 100%. So calc(s + 10) is valid plain-number arithmetic giving 110, while calc(s + 10%) is invalid: CSS forbids mixing a number and a percentage under + and -. This trips people up because s/l/w/b feel like percentages. The CSS working group considered typing them that way in issue #7114 and decided against it.

Arithmetic

Channel expressions take the full CSS math grammar: calc(), clamp(), min(), max(), arbitrary nesting, parenthesised sub-expressions, and standard precedence, where * and / bind tighter than + and -.

ts
// Complement: rotate hue by 180 degrees
Color.parse("hsl(from red calc(h + 180) s l)");

// Darken by scaling Oklch lightness
Color.parse("oklch(from #3b82f6 calc(l * 0.8) c h)");

// Clamp lightness into a range
Color.parse("oklch(from red clamp(0.2, l, 0.8) c h)");

// Cap a channel with min/max
Color.parse("rgb(from red min(r, 128) g b)");

// Nested: calc() inside clamp() inside min() all compose
Color.parse("oklch(from red min(clamp(0, calc(l * 2), 1), 0.9) c h)");

Angle units (deg, grad, rad, turn) are accepted on hue expressions and normalised to degrees:

ts
Color.parse("hsl(from red calc(h + 0.5turn) s l)"); // same as + 180deg

Percentages resolve against each channel's own reference range, so 50% means something different per channel. lch()'s c has a reference of 150, and oklch()'s c a reference of 0.4:

ts
Color.parse("lch(from red l 50% h)");   // c = 75
Color.parse("oklch(from red l 50% h)"); // c = 0.2

Alpha can be computed the same way:

ts
Color.parse("rgb(from red r g b / calc(alpha * 0.5))"); // half the origin's alpha

Failure modes

@urcolor/relative has no stylesheet, cascade or custom-property registry. These are parse failures rather than silent defaults, so resolve them to a concrete color before passing a string in:

ts
Color.parse("rgb(from var(--brand) r g b)");   // null, var() origin
Color.parse("rgb(from currentcolor r g b)");   // null, currentcolor origin
Color.parse("rgb(from inherit r g b)");        // null, inherit origin

Every other error path returns null too, with no new error type: an unparseable origin, an unknown channel keyword, a number/percentage type mismatch such as calc(s + 10%), division by zero, unbalanced parentheses, or a wrong argument count to clamp().

ts
Color.parse("rgb(from nonsense r g b)");        // null, unparseable origin
Color.parse("rgb(from red r g q)");             // null, unknown channel keyword
Color.parse("hsl(from red h calc(s + 10%) l)"); // null, number/percentage mismatch
Color.parse("rgb(from red calc(r / 0) g b)");   // null, division by zero

none is a deliberate deviation from the spec. CSS Color 5 carries none through a relative color as a genuine missing component that participates in interpolation. This library has no missing-component representation anywhere, so none in a channel slot or in an origin channel collapses to 0, matching the rest of its absolute parsers:

ts
Color.parse("hsl(from hsl(none 50% 50%) h s l)"); // h reads as 0, not "missing"

TIP

Like the rest of @urcolor/core's parsers, @urcolor/relative never mutates or reserializes. A parsed relative color resolves once, to a plain absolute Color, and behaves from then on like one parsed from any other notation.