colorizr
Version:
Manipulate colors like a boss
645 lines (565 loc) • 19.4 kB
text/typescript
import formatCSS from '~/format-css';
import { MESSAGES } from '~/modules/constants';
import { invariant } from '~/modules/invariant';
import { resolveColor } from '~/modules/parsed-color';
import { clamp, getScaleStepKeys, warn } from '~/modules/utils';
import { isNumber, isNumberInRange, isPlainObject, isString } from '~/modules/validators';
import { getP3MaxChroma } from '~/p3';
import type { ColorType, LCH } from '~/types';
/** Normalized chromaCurve: the parabola family or relative endpoints. */
type ChromaCurveConfig =
| { amount: number; peak: number; type: 'parabola' }
| { high: number; low: number; mid: number; type: 'endpoints' };
interface GeneratePaletteOptions extends Required<
Pick<ScaleOptions, 'maxLightness' | 'minLightness'>
> {
baseChroma: number;
chromaCurve: ChromaCurveConfig;
hue: number;
hueShift: ScaleRange;
inputLightness: number | undefined;
keys: number[];
lightnessCurve: ScaleRange;
lock: number | undefined;
mode: Exclude<ScaleMode, 'reversed'>;
}
export type ScaleMode = 'light' | 'dark' | 'reversed';
export type ScaleVariant = 'deep' | 'neutral' | 'pastel' | 'subtle' | 'vibrant';
/**
* Parabolic chroma curve with a movable center.
*/
export interface ScaleChromaPeak {
/** Blend between constant chroma (0) and the full parabola (1). */
amount: number;
/**
* Lightness (exclusive 0-1) where chroma stays at full base.
* @default 0.5
*/
peak?: number;
}
/**
* Options for generating a color scale.
*
* **Option Precedence:**
* - `saturation` overrides `variant` if both are set
* - `lock` affects palette calculation: steps are distributed relative to the locked position
* - `mode` affects lightness direction: 'light' has lightest at low keys, 'dark' reverses this
*/
export interface ScaleOptions {
/**
* Controls how chroma flows across the scale. Three forms:
*
* Scalar (0-1) — parabolic blend centered at mid-lightness:
* - 0: Constant chroma across all steps (default).
* - 1: Full parabola — peak chroma at mid-lightness, reduced toward extremes.
* Shorthand for `{ amount: x }`.
*
* `{ amount, peak }` — the same parabola with a movable center.
* `peak` is the lightness (exclusive 0-1) that keeps full base chroma;
* sides away from it are reduced. Defaults to 0.5.
*
* `{ low, high }` — fractions (0-1) of the maximum P3 chroma at each step,
* blended from `low` (50-end) through the input color's own fraction at the
* lock/middle step to `high` (950-end). NOT equivalent to the scalar form —
* it is a different model: values set chroma relative to the gamut ceiling
* instead of bending the input's chroma. Being gamut-relative, the input's
* chroma matters only at the anchor step; an achromatic input collapses the
* anchor fraction to 0, yielding a V-shaped, colored scale (full at the ends,
* gray at the anchor).
*
* @default 0
*/
chromaCurve?: number | ScaleChromaPeak | ScaleRange;
/**
* Output color format.
*
* Determines the format of the generated colors (e.g., HEX, RGB, OKLCH, etc.).
*
* If not specified, the output will match the format of the input color.
*/
format?: ColorType;
/**
* Hue rotation across the scale, in degrees.
*
* A scalar `x` is shorthand for `{ low: -x, high: x }`.
* `low` shifts the low-key end (50), `high` the high-key end (950); shifts
* blend to 0° at the lock step (or the middle step when not locked), so the
* input hue is preserved there. The per-step gamut clamp follows the
* shifted hue.
*
* Values must be within [-180, 180].
*
* @default 0
*/
hueShift?: number | ScaleRange;
/**
* The lightness tuning factor for the scale.
* - 1: Linear lightness distribution.
* - >1: Lighter tones are emphasized.
* - <1: Darker tones are emphasized.
*
* A scalar `x` is shorthand for `{ low: x, high: x }`. With `{ low, high }`
* each half of the scale gets its own exponent: `low` shapes the low-key
* half (toward 50), `high` the high-key half (toward 950), split at the
* lock step when locked or blended continuously when not.
* Reference palettes typically fit `{ low: 1.5, high: 1 }`.
*
* Values must be greater than 0.
* @default 1.5
*/
lightnessCurve?: number | ScaleRange;
/**
* Lock input color's lightness at specific step position.
*
* The input color's lightness is anchored at this step, and other steps
* are distributed relative to it. Chroma is also preserved unless
* `chromaCurve` or `saturation` are set, which recalculate chroma independently.
*
* Must be a valid step key for the current step count.
* Default step keys (11 steps): 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950.
* Step keys vary based on the `steps` option (3-20 steps supported).
*
* When locked at the first or last step, only one side of the scale remains,
* so the unused-side value (`low`/`high`) of asymmetric options
* (`hueShift`, `lightnessCurve`, endpoint `chromaCurve`) has no effect and
* emits a warning.
*/
lock?: number;
/**
* The maximum lightness value for the scale.
*
* Defines the upper bound for the lightest color in the palette.
*
* A number between 0 and 1.
* @default 0.97
*/
maxLightness?: number;
/**
* The minimum lightness value for the scale.
*
* Defines the lower bound for the darkest color in the palette.
*
* A number between 0 and 1.
*
* @default 0.2
*/
minLightness?: number;
/**
* Theme-aware lightness direction.
*
* - 'light': Low keys (50) are lightest, high keys (950) are darkest
* - 'dark': Low keys (50) are darkest, high keys (950) are lightest, re-derived
* with the lightness ramp running in the dark direction
* - 'reversed': the 'light' palette with its keys reversed — identical colors in
* mirrored order (50↔950, 100↔900, …)
*
* `dark` re-derives the scale, so its intermediate steps are not a reversal of
* `light` (they coincide only when `lightnessCurve` is linear). `reversed`
* re-labels the `light` palette, so it mirrors exactly at any curve.
*
* @default 'light'
*/
mode?: ScaleMode;
/**
* Global saturation override (0-100).
*
* When set, overrides the chroma for all generated shades.
* Maps to chroma in OKLCH space.
*
* Overrides `variant` if both are set.
*/
saturation?: number;
/**
* Number of steps in the scale (3-20).
*
* Controls how many color shades are generated.
*
* @default 11
*/
steps?: number;
/**
* The variant of the scale.
* - 'deep': Generates rich and bold tones with significantly reduced lightness.
* - 'neutral': Generates muted tones by reducing chroma.
* - 'pastel': Produces soft and airy tones with significant chroma reduction.
* - 'subtle': Creates extremely desaturated tones, close to grayscale.
* - 'vibrant': Enhances chroma for bold and striking tones.
*/
variant?: ScaleVariant;
}
/**
* Endpoint values for an asymmetric scale option.
*
* `low` applies at the low-key end (50), `high` at the high-key end (950),
* regardless of `mode` — keys, not lightness, define the ends.
*/
export interface ScaleRange {
high: number;
low: number;
}
const chromaScale: Record<string, number> = {
deep: 0.8,
neutral: 0.5,
pastel: 0.3,
subtle: 0.2,
vibrant: 1.25,
};
/**
* Guard a non-scalar scale option: a consumer passing a non-object (e.g. `null`)
* gets a colorizr invariant instead of a raw `TypeError` from later key access.
*/
function assertScaleObject(
value: unknown,
label: string,
): asserts value is Record<string, unknown> {
invariant(isPlainObject(value), `${label} must be a number or an object.`);
}
/**
* Lightness map for a locked scale: the anchor holds the input lightness and
* each half eases toward its endpoint with its own exponent.
*/
function computeLightnessMapWithLock(
lock: number,
inputLightness: number,
keys: number[],
lightnessCurve: ScaleRange,
maxLightness: number,
minLightness: number,
mode: ScaleMode,
): Record<number, number> {
const lightnessMap: Record<number, number> = {};
const lockIndex = keys.indexOf(lock);
lightnessMap[lock] = inputLightness;
// Keys before lock
if (lockIndex > 0) {
const target = mode === 'light' ? maxLightness : minLightness;
for (let index = 0; index < lockIndex; index++) {
const t = index / lockIndex;
lightnessMap[keys[index]] = target + (inputLightness - target) * t ** lightnessCurve.low;
}
}
// Keys after lock
const remaining = keys.length - 1 - lockIndex;
if (remaining > 0) {
const target = mode === 'light' ? minLightness : maxLightness;
for (let index = lockIndex + 1; index < keys.length; index++) {
const t = (index - lockIndex) / remaining;
lightnessMap[keys[index]] =
inputLightness + (target - inputLightness) * t ** lightnessCurve.high;
}
}
return lightnessMap;
}
/**
* Lightness map for an unlocked scale: a single eased ramp from one endpoint to
* the other, with the exponent interpolated continuously across the steps.
*/
function computeLightnessMapWithoutLock(
keys: number[],
lightnessCurve: ScaleRange,
maxLightness: number,
minLightness: number,
mode: ScaleMode,
): Record<number, number> {
const lightnessMap: Record<number, number> = {};
for (let index = 0; index < keys.length; index++) {
const tBase = index / (keys.length - 1);
// continuous exponent interpolation keeps { x, x } identical to scalar x
const exponent = lightnessCurve.low + (lightnessCurve.high - lightnessCurve.low) * tBase;
const t = tBase ** exponent;
lightnessMap[keys[index]] =
mode === 'light'
? maxLightness - (maxLightness - minLightness) * t
: minLightness + (maxLightness - minLightness) * t;
}
return lightnessMap;
}
/**
* Hue for a single step: the per-side shift blends to 0° at the anchor, so the
* input hue is preserved there.
*/
function computeStepHue(
hue: number,
hueShift: ScaleRange,
index: number,
anchorIndex: number,
keysLength: number,
): number {
if ((hueShift.low === 0 && hueShift.high === 0) || index === anchorIndex) {
return hue;
}
const shift =
index < anchorIndex
? hueShift.low * (1 - index / anchorIndex)
: hueShift.high * ((index - anchorIndex) / (keysLength - 1 - anchorIndex));
return (hue + shift + 360) % 360;
}
/**
* Generate the color palette for the scale.
*/
function generatePalette(options: GeneratePaletteOptions): Record<number, LCH> {
const {
baseChroma,
chromaCurve,
hue,
hueShift,
inputLightness,
keys,
lightnessCurve,
lock,
maxLightness,
minLightness,
mode,
} = options;
const palette: Record<number, LCH> = {};
const lightnessMap =
lock !== undefined && inputLightness !== undefined
? computeLightnessMapWithLock(
lock,
inputLightness,
keys,
lightnessCurve,
maxLightness,
minLightness,
mode,
)
: computeLightnessMapWithoutLock(keys, lightnessCurve, maxLightness, minLightness, mode);
// Generate LCH colors
const anchorIndex = lock !== undefined ? keys.indexOf(lock) : Math.floor((keys.length - 1) / 2);
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
const lightness = lightnessMap[key];
const stepHue = computeStepHue(hue, hueShift, index, anchorIndex, keys.length);
const maxChroma = getP3MaxChroma({ l: lightness, c: 0, h: stepHue });
const chroma =
chromaCurve.type === 'endpoints'
? getStepRelativeChroma(chromaCurve, index, anchorIndex, keys.length) * maxChroma
: getStepChroma(lightness, baseChroma, chromaCurve.amount, chromaCurve.peak);
palette[key] = { l: lightness, c: Math.min(chroma, maxChroma), h: stepHue };
}
return palette;
}
/**
* Calculate chroma for a step from the parabola family.
*/
function getStepChroma(
lightness: number,
baseChroma: number,
amount: number,
peak: number,
): number {
if (amount === 0) {
return baseChroma;
}
// peak 0.5 keeps the legacy expression so scalar output stays bit-identical
const parabolic =
peak === 0.5
? 4 * lightness * (1 - lightness)
: Math.max(0, 1 - ((lightness - peak) / Math.max(peak, 1 - peak)) ** 2);
const curveScale = 1 - amount * (1 - parabolic);
return Math.max(0, baseChroma * curveScale);
}
/**
* Fraction of the gamut ceiling for a step: low → mid (anchor) → high.
*/
function getStepRelativeChroma(
curve: Extract<ChromaCurveConfig, { type: 'endpoints' }>,
index: number,
anchorIndex: number,
total: number,
): number {
const { high, low, mid } = curve;
if (index === anchorIndex) {
return mid;
}
if (index < anchorIndex) {
return low + (mid - low) * (index / anchorIndex);
}
return mid + (high - mid) * ((index - anchorIndex) / (total - 1 - anchorIndex));
}
/**
* Normalize the chromaCurve option into its internal config: scalar → parabola
* at 0.5; objects discriminate by key (`amount` → movable peak, else endpoints).
*/
function normalizeChromaCurve(
chromaCurve: number | ScaleChromaPeak | ScaleRange,
lch: LCH,
baseChroma: number,
): ChromaCurveConfig {
if (isNumber(chromaCurve)) {
invariant(isNumberInRange(chromaCurve, 0, 1), 'chromaCurve must be within the range [0, 1].');
return { amount: chromaCurve, peak: 0.5, type: 'parabola' };
}
assertScaleObject(chromaCurve, 'chromaCurve');
if ('amount' in chromaCurve) {
const { amount, peak = 0.5 } = chromaCurve;
invariant(isNumberInRange(amount, 0, 1), 'chromaCurve amount must be within the range [0, 1].');
invariant(
isNumber(peak) && peak > 0 && peak < 1,
'chromaCurve peak must be within the range (0, 1).',
);
return { amount, peak, type: 'parabola' };
}
const { high, low } = chromaCurve;
invariant(
isNumberInRange(low, 0, 1) && isNumberInRange(high, 0, 1),
'chromaCurve low/high must be within the range [0, 1].',
);
// guard near-black/white inputs where the ceiling collapses to 0
const maxChromaAtInput = getP3MaxChroma(lch);
return {
high,
low,
mid: maxChromaAtInput > 0 ? clamp(baseChroma / maxChromaAtInput, 0, 1) : 0,
type: 'endpoints',
};
}
/**
* Resolve the base chroma: `saturation` (% of gamut ceiling) wins, then a
* `variant` multiplier, otherwise the input's own chroma.
*/
function resolveBaseChroma(
saturation: number | undefined,
variant: ScaleVariant | undefined,
lch: LCH,
): number {
if (saturation !== undefined) {
// saturation overrides: % of max P3 chroma at input's lightness
return (clamp(saturation, 0, 100) / 100) * getP3MaxChroma(lch);
}
if (variant && chromaScale[variant]) {
return lch.c * chromaScale[variant];
}
if (variant) {
warn(`variant: ${variant} is not valid, ignoring`);
}
return lch.c;
}
/**
* Warn when an endpoint lock makes one side of an asymmetric option unreachable.
*/
function warnIgnoredLockEndpoints(
lock: number | undefined,
keys: number[],
options: {
chromaCurve: ChromaCurveConfig;
hueShift: ScaleRange;
lightnessCurve: ScaleRange;
},
): void {
if (lock === undefined) {
return;
}
const lockIndex = keys.indexOf(lock);
if (lockIndex !== 0 && lockIndex !== keys.length - 1) {
return;
}
const { chromaCurve, hueShift, lightnessCurve } = options;
const ignored = lockIndex === 0 ? 'low' : 'high';
const ranges: Array<[string, ScaleRange]> = [
['hueShift', hueShift],
['lightnessCurve', lightnessCurve],
];
if (chromaCurve.type === 'endpoints') {
ranges.push(['chromaCurve', { high: chromaCurve.high, low: chromaCurve.low }]);
}
for (const [name, range] of ranges) {
if (range.low !== range.high) {
warn(`lock at ${lock} ignores ${name}.${ignored} (${range[ignored]})`);
}
}
}
/**
* Generate a scale of colors based on the input color.
*
* This utility is ideal for designers and developers who need dynamic color
* palettes for UI themes, design systems, or data visualization. Supports
* multiple modes, scales, and variants for flexibility.
*
* @param input - The base color string.
* @param options - Scale generation options.
* @returns A record of step keys to color strings.
*/
export default function scale(input: string, options: ScaleOptions = {}): Record<number, string> {
invariant(isString(input), MESSAGES.inputString);
const {
chromaCurve = 0,
format,
hueShift = 0,
lightnessCurve = 1.5,
lock: lockOption,
maxLightness = 0.97,
minLightness = 0.2,
mode = 'light',
saturation,
steps: stepsOption,
variant,
} = options;
invariant(
maxLightness > minLightness && maxLightness <= 1 && minLightness >= 0,
'maxLightness must be greater than minLightness and within the range [0, 1].',
);
if (!isNumber(hueShift)) {
assertScaleObject(hueShift, 'hueShift');
}
const hueShiftRange: ScaleRange = isNumber(hueShift)
? { low: -hueShift, high: hueShift }
: hueShift;
invariant(
isNumberInRange(hueShiftRange.low, -180, 180) && isNumberInRange(hueShiftRange.high, -180, 180),
'hueShift values must be within the range [-180, 180].',
);
if (!isNumber(lightnessCurve)) {
assertScaleObject(lightnessCurve, 'lightnessCurve');
}
const lightnessCurveRange: ScaleRange = isNumber(lightnessCurve)
? { low: lightnessCurve, high: lightnessCurve }
: lightnessCurve;
invariant(
isNumber(lightnessCurveRange.low) &&
lightnessCurveRange.low > 0 &&
isNumber(lightnessCurveRange.high) &&
lightnessCurveRange.high > 0,
'lightnessCurve values must be greater than 0.',
);
const steps = stepsOption !== undefined ? Math.round(stepsOption) : 11;
const keys = getScaleStepKeys(steps);
// Validate lock option
let lock = lockOption;
if (lock !== undefined && (!isNumber(lock) || !keys.includes(lock))) {
warn(`lock: ${lock} is not valid for steps: ${steps}, ignoring`);
lock = undefined;
}
const parsed = resolveColor(input);
const lch = parsed.oklch;
const baseChroma = resolveBaseChroma(saturation, variant, lch);
const colorFormat = format ?? parsed.type;
const chromaCurveConfig = normalizeChromaCurve(chromaCurve, lch, baseChroma);
warnIgnoredLockEndpoints(lock, keys, {
chromaCurve: chromaCurveConfig,
hueShift: hueShiftRange,
lightnessCurve: lightnessCurveRange,
});
// Generate the color palette
const palette = generatePalette({
baseChroma,
chromaCurve: chromaCurveConfig,
hue: lch.h,
hueShift: hueShiftRange,
inputLightness: lock !== undefined ? lch.l : undefined,
keys,
lightnessCurve: lightnessCurveRange,
lock,
maxLightness,
minLightness,
mode: mode === 'reversed' ? 'light' : mode,
});
// `reversed` re-labels the light palette: same colors, mirrored keys.
const ordered =
mode === 'reversed'
? Object.fromEntries(keys.map((key, index) => [key, palette[keys[keys.length - 1 - index]]]))
: palette;
return Object.fromEntries(
Object.entries(ordered).map(([key, value]) => [key, formatCSS(value, { format: colorFormat })]),
);
}