UI 色彩体系构建:从色板生成到无障碍对比度的工程化实践

📅 2026/6/17 21:49:10
UI 色彩体系构建:从色板生成到无障碍对比度的工程化实践
UI 色彩体系构建从色板生成到无障碍对比度的工程化实践一、色彩不是选个好看的颜色系统化色板的数学基础UI 设计中最常见的色彩问题是色板漂移——项目初期定义了 5 个品牌色三个月后代码中出现了 50 种未定义的色值变体。根本原因是色板缺乏数学基础设计师凭直觉调色开发者凭感觉微调没有统一的生成规则。系统化色板的核心是从一个种子色生成完整色阶。不是手动定义 10 个灰度值而是通过数学函数HSL 空间中的亮度插值自动生成。这样色板有内在的一致性——同一色相的不同明度之间有可预测的关系新增变体只需调整参数而非重新选色。二、色板生成的数学模型色板生成基于 HSL 色彩空间。种子色确定色相H和饱和度S亮度L从 0% 到 100% 等距插值生成色阶。flowchart TB A[种子色br/H:210 S:80% L:50%] -- B[HSL 空间插值] B -- C[色阶生成br/L: 5%→95% 共 11 级] C -- D[语义映射br/primary/secondary/surface...] D -- D1[primary-50: #EFF6FFbr/最浅] D -- D2[primary-100: #DBEAFE] D -- D3[primary-500: #3B82F6br/种子色] D -- D4[primary-900: #1E3A5Fbr/最深] A -- E[对比度校验] E -- F[WCAG AAbr/正文 ≥ 4.5:1] E -- G[WCAG AAbr/大字 ≥ 3:1] F -- H[自动标注合规色对] G -- H style B fill:#e8f5e9 style E fill:#fff3e0关键设计点色阶不是线性插值而是使用感知均匀的插值曲线。人眼对暗部的亮度变化更敏感所以暗部色阶的间距应该更小。使用 OKLCH 色彩空间比 HSL 更接近人眼感知可以得到更均匀的色阶。三、代码实现3.1 色板生成引擎// palette-generator.ts - 色板生成引擎 interface PaletteConfig { seedColor: string; // 种子色hex name: string; // 色板名称 steps: number; // 色阶数量默认 11 lightEnd: number; // 最亮端 L 值默认 95 darkEnd: number; // 最暗端 L 值默认 5 } interface ColorStep { step: number; // 色阶编号50, 100, 200...900 hex: string; // 十六进制色值 hsl: { h: number; s: number; l: number }; oklch: { l: number; c: number; h: number }; } class PaletteGenerator { /** * 从种子色生成完整色阶 */ generate(config: PaletteConfig): ColorStep[] { const seed this.hexToHSL(config.seedColor); const steps: ColorStep[] []; // 色阶编号50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950 const stepValues [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; for (let i 0; i stepValues.length; i) { // 非线性插值暗部间距更小 const t i / (stepValues.length - 1); const curvedT this.perceptualCurve(t); // 亮度从暗到亮 const lightness config.darkEnd curvedT * (config.lightEnd - config.darkEnd); // 饱和度在中间色阶最高两端降低 const saturationCurve Math.sin(t * Math.PI); const saturation seed.s * (0.6 0.4 * saturationCurve); const hsl { h: seed.h, s: saturation, l: lightness }; const hex this.hslToHex(hsl.h, hsl.s, hsl.l); steps.push({ step: stepValues[i], hex, hsl, oklch: this.hslToOklch(hsl), }); } return steps; } /** * 感知曲线暗部间距更小亮部间距更大 * 模拟人眼对亮度变化的非线性感知 */ private perceptualCurve(t: number): number { // 使用 gamma 2.2 的幂函数 return Math.pow(t, 1 / 2.2); } /** * 生成多色板系统 */ generateSystem(seeds: Recordstring, string): Recordstring, ColorStep[] { const palettes: Recordstring, ColorStep[] {}; for (const [name, color] of Object.entries(seeds)) { palettes[name] this.generate({ seedColor: color, name, steps: 11, }); } // 生成中性色板灰色系 palettes.neutral this.generate({ seedColor: #6B7280, // 中灰 name: neutral, steps: 11, }); return palettes; } /** * 语义映射将色阶映射到设计 Token */ mapToSemanticTokens( palettes: Recordstring, ColorStep[] ): Recordstring, string { return { // 主色 --color-primary: palettes.primary[5].hex, // 500 --color-primary-hover: palettes.primary[4].hex, // 400 --color-primary-active: palettes.primary[6].hex, // 600 --color-primary-light: palettes.primary[1].hex, // 100 --color-primary-text: palettes.primary[8].hex, // 800 // 语义色 --color-success: palettes.green[5].hex, --color-warning: palettes.amber[5].hex, --color-error: palettes.red[5].hex, --color-info: palettes.blue[5].hex, // 表面色 --color-surface: palettes.neutral[1].hex, // 100 --color-surface-alt: palettes.neutral[2].hex, // 200 --color-surface-raised: #FFFFFF, // 文本色 --color-text-primary: palettes.neutral[9].hex, // 900 --color-text-secondary: palettes.neutral[6].hex, // 600 --color-text-tertiary: palettes.neutral[4].hex, // 400 --color-text-inverse: #FFFFFF, // 边框色 --color-border: palettes.neutral[3].hex, // 300 --color-border-hover: palettes.neutral[4].hex, // 400 }; } // 色彩空间转换工具 private hexToHSL(hex: string): { h: number; s: number; l: number } { const r parseInt(hex.slice(1, 3), 16) / 255; const g parseInt(hex.slice(3, 5), 16) / 255; const b parseInt(hex.slice(5, 7), 16) / 255; const max Math.max(r, g, b); const min Math.min(r, g, b); const l (max min) / 2; if (max min) return { h: 0, s: 0, l: l * 100 }; const d max - min; const s l 0.5 ? d / (2 - max - min) : d / (max min); let h 0; if (max r) h ((g - b) / d (g b ? 6 : 0)) / 6; else if (max g) h ((b - r) / d 2) / 6; else h ((r - g) / d 4) / 6; return { h: h * 360, s: s * 100, l: l * 100 }; } private hslToHex(h: number, s: number, l: number): string { s / 100; l / 100; const a s * Math.min(l, 1 - l); const f (n: number) { const k (n h / 30) % 12; const color l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); return Math.round(255 * color).toString(16).padStart(2, 0); }; return #${f(0)}${f(8)}${f(4)}; } private hslToOklch(hsl: { h: number; s: number; l: number }): { l: number; c: number; h: number } { // 简化的 HSL → OKLCH 转换 // 生产环境应使用完整的色彩空间转换库 return { l: hsl.l / 100 * 0.75 0.15, c: hsl.s / 100 * 0.15, h: hsl.h, }; } }3.2 对比度校验与合规色对// contrast-checker.ts - WCAG 对比度校验 class ContrastChecker { /** * 检查色板中所有色对的对比度 * 返回符合 WCAG AA 标准的合规色对 */ findCompliantPairs( palettes: Recordstring, ColorStep[], level: AA | AAA AA ): CompliantPair[] { const pairs: CompliantPair[] []; const textThreshold level AAA ? 7 : 4.5; const largeTextThreshold level AAA ? 4.5 : 3; // 检查所有前景-背景组合 const allColors Object.values(palettes).flat(); for (const fg of allColors) { for (const bg of allColors) { if (fg.step bg.step) continue; const ratio this.contrastRatio(fg.hex, bg.hex); if (ratio textThreshold) { pairs.push({ foreground: fg, background: bg, ratio, usage: normal-text, }); } else if (ratio largeTextThreshold) { pairs.push({ foreground: fg, background: bg, ratio, usage: large-text, }); } } } return pairs.sort((a, b) b.ratio - a.ratio); } private contrastRatio(fg: string, bg: string): number { const l1 this.relativeLuminance(fg); const l2 this.relativeLuminance(bg); const lighter Math.max(l1, l2); const darker Math.min(l1, l2); return (lighter 0.05) / (darker 0.05); } private relativeLuminance(hex: string): number { const [r, g, b] this.hexToRgb(hex); const [rs, gs, bs] [r, g, b].map(c { const s c / 255; return s 0.03928 ? s / 12.92 : Math.pow((s 0.055) / 1.055, 2.4); }); return 0.2126 * rs 0.7152 * gs 0.0722 * bs; } private hexToRgb(hex: string): [number, number, number] { const clean hex.replace(#, ); return [ parseInt(clean.substring(0, 2), 16), parseInt(clean.substring(2, 4), 16), parseInt(clean.substring(4, 6), 16), ]; } }四、色彩体系的工程边界OKLCH 的浏览器支持OKLCH 色彩空间在 CSS 中的支持需要color()函数Chrome 111 和 Safari 15.4 已支持但 Firefox 支持较晚。生成色板时建议同时输出 OKLCH 和 HEX 两种格式HEX 作为降级方案。暗色模式的色板反转暗色模式不是简单地将色板反转。暗色背景上色阶的使用方向反转——浅色用于文本深色用于背景。但色相和饱和度也需要调整暗色模式下饱和度应降低 10-20%避免在深色背景上过于刺眼。品牌色的色相偏移种子色可能不适合所有色阶。例如品牌色是蓝色但蓝色色阶的浅色端可能偏紫。需要在生成色阶时对色相做微调——浅色端色相偏暖 5-10 度深色端色相偏冷 5-10 度。色板的命名规范色阶编号50-950是 Tailwind 的标准但团队可能更习惯语义命名primary-light、primary-dark。建议同时维护两种命名数值编号用于设计系统内部语义命名用于业务代码。五、总结系统化色板的核心是从一个种子色数学生成完整色阶。本文的关键实现为HSL 空间非线性插值感知均匀曲线、多色板系统生成、语义 Token 映射、WCAG 对比度校验。色阶生成使用 gamma 2.2 幂函数确保暗部间距更小饱和度使用正弦曲线在中间色阶最高。落地时需确保所有文本-背景色对满足 WCAG AA 标准正文 ≥ 4.5:1暗色模式需独立调整饱和度和色相。补充落地建议围绕“UI 色彩体系构建从色板生成到无障碍对比度的工程化实践”继续推进时应把验证标准写成可执行清单而不是停留在经验判断。性能类方案要给出基准数据架构类方案要给出故障隔离方式AI 类方案要给出输出质量和人工兜底策略。每一次迭代都应回答三个问题收益是否可量化失败是否可回滚维护成本是否被团队接受。如果短期资源有限可以先保留最关键的观测指标包括处理耗时、失败率、资源占用和人工介入次数。等这些指标稳定后再扩展自动化能力。这样的节奏更慢但风险更低也更符合生产级技术文章强调的工程可验证性。