当暗色不再是简单反转:暗色模式设计系统的色彩映射算法与实操细节
一、深度引言与场景痛点
暗色模式不是把亮色页面的颜色值简单反转——把白色变黑色、把蓝色变深蓝色,那得到的不是暗色模式,而是噩梦模式。文字在纯黑背景上刺眼,主色在深底上失去辨识度,间距和阴影在暗色下变得不像回事。暗色模式的色彩设计需要一套全新的映射算法:亮度反转但不简单取反,饱和度适度降低以减少视觉疲劳,对比度重新校准以确保可读性。
这套映射算法不是艺术直觉,而是数学公式——OKLCH 色彩空间中的亮度调整、sRGB 中的对比度计算、Display P3 中的色彩保真度维护。设计师在 Figma 中定义亮色 Token,映射算法自动生成暗色 Token,两者之间的关系不是"颜色反转",而是"色彩空间变换"。
这篇文章解决的核心问题:如何构建一套从亮色到暗色的色彩映射算法,覆盖亮度调整、饱和度控制、对比度校准三个维度,并把算法实现为可自动执行的 Token 转换管道。
二、底层机制与原理深度剖析
暗色模式色彩映射的三个核心步骤:亮度映射、饱和度调整、对比度校准。每个步骤解决一类特定的暗色设计问题。
flowchart TD A[亮色模式 Token] --> B[亮度映射层] B --> B1[OKLCH 色彩空间转换] B --> B2[亮度反转公式:L_dark = 1 - L_light × factor] B --> B3[保留色相和饱和度不变] B1 & B2 & B3 --> C[饱和度调整层] C --> C1[降低低亮度色彩的饱和度] C --> C2[保留高亮度主色的饱和度] C --> C3[避免暗色高饱和度导致的视觉刺痛] C1 & C2 & C3 --> D[对比度校准层] D --> D1[计算前景色与背景色的对比度比率] D --> D2[对比度低于 4.5:1 时自动调亮前景色] D --> D3[对比度高于 15:1 时适度降低以减少刺眼感] D1 & D2 & D3 --> E[暗色模式 Token 输出] E --> E1[生成 --color-*-dark 变量] E --> E2[写入 tokens-dark.css] E --> E3[WCAG AA 合规标记]亮度映射层是基础。简单反转(rgb(255,255,255) → rgb(0,0,0))在 sRGB 空间中是灾难性的,因为 sRGB 的亮度分布不均匀——中间亮度值在视觉感知上偏暗。OKLCH 色彩空间解决了这个问题:它的 L 通道是感知均匀的,L=0.5 在视觉上真的是"中间亮度"。映射公式L_dark = 1 - L_light × factor中,factor 是亮度压缩系数——0.7 表示暗色最高亮度不超过 0.3,避免纯白文字在暗底上刺眼。
饱和度调整层处理暗色下的色彩感知问题。低亮度色彩如果保持高饱和度,视觉上会产生刺痛感——暗红、暗蓝的高饱和度版本像一道激光射入眼睛。算法的规则是:亮度低于 0.3 的色彩,饱和度按比例降低(C_dark = C_light × (L_dark / 0.3));亮度高于 0.3 的色彩,饱和度保持不变或略微降低,维持品牌色辨识度。
对比度校准层确保暗色模式下的可读性。WCAG AA 标准要求正常文字的对比度不低于 4.5:1。亮度映射后的前景色可能对比度不足,需要自动调亮前景色直到满足 4.5:1。但对比度也不能过高——超过 15:1 的对比度(纯白文字在纯黑背景上)会产生刺眼感,算法在 4.5:1 到 15:1 之间寻找平衡点。
三、生产级代码实现与最佳实践
OKLCH 色彩空间转换与映射算法:
// utils/color-mapping.ts // 暗色模式色彩映射算法核心实现 interface OKLCHColor { L: number; // 亮度 0-1(感知均匀) C: number; // 饱和度 0-0.4 H: number; // 色相 0-360 } interface sRGBColor { r: number; // 0-1 g: number; b: number; } // sRGB → OKLCH 转换 function sRGBToOKLCH(rgb: sRGBColor): OKLCHColor { // 步骤 1: sRGB → 线性 RGB const linearize = (v: number) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); const lr = linearize(rgb.r); const lg = linearize(rgb.g); const lb = linearize(rgb.b); // 步骤 2: 线性 RGB → OKLab(简化计算) // 使用近似的转换矩阵 const L_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; const M_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1075657908 * lb; const S_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6302616586 * lb; const lc_ = Math.cbrt(L_); const mc_ = Math.cbrt(M_); const sc_ = Math.cbrt(S_); const L = 0.2104542553 * lc_ + 0.7886176654 * mc_ - 0.0090723057 * sc_; const a = 1.0 * lc_ - 0.8034542553 * mc_ + 0.0833452553 * sc_; const b = 0.0 * lc_ + 0.3852652553 * mc_ - 0.3852652553 * sc_; // 步骤 3: OKLab → OKLCH const C = Math.sqrt(a * a + b * b); const H = Math.atan2(b, a) * 180 / Math.PI; return { L, C, H: H < 0 ? H + 360 : H }; } // OKLCH → sRGB 转换 function oklchToSRGB(oklch: OKLCHColor): sRGBColor { // OKLCH → OKLab const a = oklch.C * Math.cos(oklch.H * Math.PI / 180); const b = oklch.C * Math.sin(oklch.H * Math.PI / 180); // OKLab → 线性 RGB(逆矩阵) const lc_ = L_ + 0.3963377774 * a + 0.2158037573 * b; // 简化 const mc_ = 1.0 - 0.1055613458 * a - 0.0638541728 * b; const sc_ = 0.0 + 0.0894841775 * a - 1.2914855480 * b; // 这些近似值仅用于示意,实际实现应使用精确矩阵 const L_ = lc_ * lc_ * lc_; const M_ = mc_ * mc_ * mc_; const S_ = sc_ * sc_ * sc_; const lr = 1.7053 * L_ - 1.7813 * M_ + 0.0760 * S_; const lg = -0.6145 * L_ + 1.8277 * M_ - 0.2132 * S_; const lb = 0.0 * L_ - 0.0742 * M_ + 1.0742 * S_; // 线性 RGB → sRGB const gamma = (v: number) => v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return { r: Math.max(0, Math.min(1, gamma(lr))), g: Math.max(0, Math.min(1, gamma(lg))), b: Math.max(0, Math.min(1, gamma(lb))) }; } // 暗色模式映射算法 function mapToDarkMode( lightColor: sRGBColor, backgroundDark: sRGBColor = { r: 0.11, g: 0.11, b: 0.13 }, // 暗色背景默认值 factor: number = 0.7 // 亮度压缩系数 ): sRGBColor { const oklch = sRGBToOKLCH(lightColor); // 亮度映射:反转并压缩 let L_dark = 1 - oklch.L * factor; // 饱和度调整:低亮度色彩降低饱和度 let C_dark = oklch.C; if (L_dark < 0.3) { // 低亮度时饱和度按比例降低,避免暗色高饱和度的刺痛感 C_dark = oklch.C * (L_dark / 0.3); } else if (L_dark > 0.7) { // 高亮度时饱和度略微降低,减少刺眼感 C_dark = oklch.C * 0.85; } // 色相保持不变 const H_dark = oklch.H; // 转换回 sRGB let darkColor = oklchToSRGB({ L: L_dark, C: C_dark, H: H_dark }); // 对比度校准:确保前景色与背景色的对比度在 4.5:1 到 15:1 之间 const bgOKLCH = sRGBToOKLCH(backgroundDark); let contrastRatio = calculateContrastRatio(darkColor, backgroundDark); // 对比度不足时逐步调亮前景色 while (contrastRatio < 4.5 && L_dark < 1.0) { L_dark += 0.02; darkColor = oklchToSRGB({ L: L_dark, C: C_dark, H: H_dark }); contrastRatio = calculateContrastRatio(darkColor, backgroundDark); } // 对比度过高时适度降低 while (contrastRatio > 15 && L_dark > 0.3) { L_dark -= 0.01; darkColor = oklchToSRGB({ L: L_dark, C: C_dark, H: H_dark }); contrastRatio = calculateContrastRatio(darkColor, backgroundDark); } return darkColor; } // 对比度比率计算(WCAG 公式) function calculateContrastRatio(fg: sRGBColor, bg: sRGBColor): number { const fgLum = relativeLuminance(fg); const bgLum = relativeLuminance(bg); const lighter = Math.max(fgLum, bgLum); const darker = Math.min(fgLum, bgLum); return (lighter + 0.05) / (darker + 0.05); } // 相对亮度计算 function relativeLuminance(rgb: sRGBColor): number { const linearize = (v: number) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); return 0.2126 * linearize(rgb.r) + 0.7152 * linearize(rgb.g) + 0.0722 * linearize(rgb.b); }Token 转换管道:
// scripts/dark-mode-token-generator.ts import fs from 'fs'; interface LightToken { name: string; // CSS Variable 名称 value: string; // sRGB 颜色值,如 #3B82F6 } function generateDarkTokens(lightTokensPath: string, outputPath: string): void { // 读取亮色模式 Token 文件 const lightTokens = parseTokenFile(lightTokensPath); const darkTokens: LightToken[] = []; for (const token of lightTokens) { // 将 hex 值转为 sRGB 对象 const rgb = hexToSRGB(token.value); // 执行暗色映射算法 const darkRGB = mapToDarkMode(rgb); // 将 sRGB 值转回 hex const darkHex = sRGBToHex(darkRGB); // 暗色 Token 命名规则:在原名称后添加语义标记 // 不使用 -dark 后缀,而是使用语义化的命名 darkTokens.push({ name: token.name, // 同名覆盖(在>