UNPKG

colorizr

Version:
645 lines (565 loc) 19.4 kB
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 })]), ); }