Appearance
实现 颜色格式转换器 工具
分类:developer | 标签:颜色、HEX、RGB、HSL、转换 HEX / RGB / HSL / HSV 互相转换,支持点击取色与复制
2.1 功能说明
HEX / RGB / HSL / HSV 互相转换,支持点击取色与复制
核心能力
- HEX
- RGB
- HSL
- HSV
- CMYK
- LAB
- OKLCH
- 两色混合
- 反色 / 补色
- WCAG 对比度检测
- 场景
- 要求
- 结果
2.1.1 使用指南
颜色格式转换器使用指南
支持的格式
- HEX:
#RRGGBB,前端最常用。 - RGB:
rgb(r, g, b),取值范围 0-255。 - HSL:
hsl(h, s%, l%),色相/饱和度/亮度,便于调色。 - HSV:
hsv(h, s%, v%),设计软件常用。 - CMYK:
cmyk(c%, m%, y%, k%),印刷四分色。此处使用不含 ICC 色彩管理的简易换算,仅供参考。 - LAB:
lab(L, a, b),CIE 1976 L*a*b*,D65 白点。感知均匀,适合做色差计算。 - OKLCH:
oklch(L C H),现代感知均匀色彩空间,CSS Color 4 已支持,调明度时色相更稳定。
使用说明
点击左侧色块或用取色器选择颜色,或直接输入 HEX 值(支持简写 #abc),右侧自动换算全部格式并可一键复制。
两色混合
在 sRGB 空间对两个颜色做线性插值,滑块控制颜色 B 的占比(50% 即取中间色), 同时给出 0% / 25% / 50% / 75% / 100% 五档色卡,点击任意色卡可将其设为上方主色。
反色 / 补色
反相按 255 - 通道值 取反;色相补色在 HSL 中把色相旋转 180°, 保持明度与饱和度;另外给出三分色(±120°)和分裂补色(±150°),可直接复制为一组 CSS 变量。
WCAG 对比度检测
按 WCAG 2.1 计算相对亮度并得出对比度比值 (L1 + 0.05) / (L2 + 0.05),比值范围 1:1 ~ 21:1。判定标准:
- 普通文本(小于 18pt 或小于 14pt 加粗):AA 需 ≥ 4.5,AAA 需 ≥ 7。
- 大字文本(≥ 18pt 或 ≥ 14pt 加粗):AA 需 ≥ 3,AAA 需 ≥ 4.5。
- 图形与界面组件(图标、输入框边框等):至少需 ≥ 3。
典型场景
前端配色、设计稿还原、CSS 变量生成、无障碍(a11y)可读性走查、印刷稿色值参考。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
isValidHex()normalizeHex()hexToRgb()rgbToHsl()rgbToHsv()rgbToCmyk()srgbToLinear()rgbToLab()rgbToOklab()rgbToOklch()rgbToHex()hslToRgb()safeRgb()mixHex()swapMix()applyToMain()relLuminance()swapContrast()onHexInput()onHexText()copy()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'
const hex = ref('#409eff')
const hexText = ref('#409eff')
const error = ref('')
const rgb = computed(() => hexToRgb(normalizeHex(hex.value)))
const hsl = computed(() => rgbToHsl(rgb.value))
const hsv = computed(() => rgbToHsv(rgb.value))
const displayColor = computed(() => (isValidHex(hex.value) ? hex.value : '#cccccc'))
const rgbText = computed(() => (rgb.value ? `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})` : ''))
const hslText = computed(() => (hsl.value ? `hsl(${Math.round(hsl.value.h)}, ${Math.round(hsl.value.s)}%, ${Math.round(hsl.value.l)}%)` : ''))
const hsvText = computed(() => (hsv.value ? `hsv(${Math.round(hsv.value.h)}, ${Math.round(hsv.value.s)}%, ${Math.round(hsv.value.v)}%)` : ''))
const cmykText = computed(() => {
const c = rgbToCmyk(rgb.value)
return `cmyk(${round1(c.c)}%, ${round1(c.m)}%, ${round1(c.y)}%, ${round1(c.k)}%)`
})
const labText = computed(() => {
const l = rgbToLab(rgb.value)
return `lab(${round2(l.l)}, ${round2(l.a)}, ${round2(l.b)})`
})
const oklchText = computed(() => {
const o = rgbToOklch(rgb.value)
return `oklch(${round1(o.l * 100)}% ${round4(o.c)} ${round2(o.h)})`
})
function isValidHex(v) {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)
}
function normalizeHex(v) {
if (!isValidHex(v)) return '#000000'
let h = v.replace('#', '')
if (h.length === 3) h = h.split('').map(c => c + c).join('')
return '#' + h
}
function hexToRgb(v) {
const h = normalizeHex(v).replace('#', '')
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16)
}
}
function rgbToHsl({ r, g, b }) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h = 0, s = 0
const l = (max + min) / 2
const d = max - min
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
default: h = (r - g) / d + 4
}
h *= 60
}
return { h, s: s * 100, l: l * 100 }
}
function rgbToHsv({ r, g, b }) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
const d = max - min
let h = 0
const s = max === 0 ? 0 : d / max
const v = max
if (d !== 0) {
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
default: h = (r - g) / d + 4
}
h *= 60
}
return { h, s: s * 100, v: v * 100 }
}
/* ---------------- 新增色彩空间:CMYK / LAB / OKLCH ---------------- */
const round1 = n => Math.round(n * 10) / 10
const round2 = n => Math.round(n * 100) / 100
const round4 = n => Math.round(n * 10000) / 10000
function rgbToCmyk({ r, g, b }) {
const rr = r / 255, gg = g / 255, bb = b / 255
const k = 1 - Math.max(rr, gg, bb)
if (k >= 1) return { c: 0, m: 0, y: 0, k: 100 }
return {
c: ((1 - rr - k) / (1 - k)) * 100,
m: ((1 - gg - k) / (1 - k)) * 100,
y: ((1 - bb - k) / (1 - k)) * 100,
k: k * 100
}
}
// sRGB 分量 -> 线性光
function srgbToLinear(c) {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
// sRGB -> XYZ(D65) -> CIE LAB
function rgbToLab({ r, g, b }) {
const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255)
const X = R * 0.4124564 + G * 0.3575761 + B * 0.1804375
const Y = R * 0.2126729 + G * 0.7151522 + B * 0.0721750
const Z = R * 0.0193339 + G * 0.1191920 + B * 0.9503041
// D65 白点
const Xn = 0.95047, Yn = 1.0, Zn = 1.08883
const f = t => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29)
const fx = f(X / Xn), fy = f(Y / Yn), fz = f(Z / Zn)
return { l: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) }
}
// sRGB -> 线性 sRGB -> OKLab
function rgbToOklab({ r, g, b }) {
const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255)
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B)
const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B)
const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B)
return {
L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
}
}
// OKLab -> OKLCH(极坐标)
function rgbToOklch(rgbVal) {
const { L, a, b } = rgbToOklab(rgbVal)
const c = Math.sqrt(a * a + b * b)
let h = (Math.atan2(b, a) * 180) / Math.PI
if (h < 0) h += 360
return { l: L, c, h: c < 1e-7 ? 0 : h }
}
function rgbToHex({ r, g, b }) {
const to = v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
return '#' + to(r) + to(g) + to(b)
}
function hslToRgb({ h, s, l }) {
const hh = ((h % 360) + 360) % 360 / 360
const ss = s / 100
const ll = l / 100
if (ss === 0) {
const v = Math.round(ll * 255)
return { r: v, g: v, b: v }
}
const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss
const p = 2 * ll - q
const hue2rgb = t => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
return {
r: Math.round(hue2rgb(hh + 1 / 3) * 255),
g: Math.round(hue2rgb(hh) * 255),
b: Math.round(hue2rgb(hh - 1 / 3) * 255)
}
}
/* ---------------- 两色混合 ---------------- */
const advTab = ref('mix')
const mixA = ref('#409eff')
const mixB = ref('#f56c6c')
const mixRatio = ref(50)
function safeRgb(v) {
return hexToRgb(normalizeHex(isValidHex(v) ? v : '#000000'))
}
// 在 sRGB 空间按比例 t(0~1)线性插值
function mixHex(a, b, t) {
const ca = safeRgb(a), cb = safeRgb(b)
return rgbToHex({
r: ca.r + (cb.r - ca.r) * t,
g: ca.g + (cb.g - ca.g) * t,
b: ca.b + (cb.b - ca.b) * t
})
}
const mixResultHex = computed(() => mixHex(mixA.value, mixB.value, mixRatio.value / 100))
const mixResultRgb = computed(() => {
const c = hexToRgb(mixResultHex.value)
return `rgb(${c.r}, ${c.g}, ${c.b})`
})
const mixSteps = computed(() =>
[0, 25, 50, 75, 100].map(p => ({ label: p + '%', hex: mixHex(mixA.value, mixB.value, p / 100) }))
)
function swapMix() {
const t = mixA.value
mixA.value = mixB.value
mixB.value = t
mixRatio.value = 100 - mixRatio.value
}
/* ---------------- 反色 / 补色 ---------------- */
const complementList = computed(() => {
const c = rgb.value
const base = hsl.value
const rotate = deg => rgbToHex(hslToRgb({ h: base.h + deg, s: base.s, l: base.l }))
return [
{ label: '原色', hex: normalizeHex(hex.value) },
{ label: '反相 (255-x)', hex: rgbToHex({ r: 255 - c.r, g: 255 - c.g, b: 255 - c.b }) },
{ label: '色相补色 (+180°)', hex: rotate(180) },
{ label: '三分色 (+120°)', hex: rotate(120) },
{ label: '三分色 (-120°)', hex: rotate(-120) },
{ label: '分裂补色 (+150°)', hex: rotate(150) },
{ label: '分裂补色 (-150°)', hex: rotate(-150) }
]
})
const CSS_VAR_NAMES = [
'--color-base',
'--color-invert',
'--color-complement',
'--color-triad-1',
'--color-triad-2',
'--color-split-1',
'--color-split-2'
]
const complementCss = computed(() =>
':root {\n' +
complementList.value.map((s, i) => ` ${CSS_VAR_NAMES[i]}: ${s.hex}; /* ${s.label} */`).join('\n') +
'\n}'
)
function applyToMain(v) {
hex.value = normalizeHex(v)
hexText.value = hex.value
error.value = ''
ElMessage.success('已设为主色 ' + hex.value)
}
/* ---------------- WCAG 对比度 ---------------- */
const fgColor = ref('#409eff')
const bgColor = ref('#ffffff')
// WCAG 2.1 相对亮度
function relLuminance({ r, g, b }) {
const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255)
return 0.2126 * R + 0.7152 * G + 0.0722 * B
}
const contrastRatio = computed(() => {
const l1 = relLuminance(safeRgb(fgColor.value))
const l2 = relLuminance(safeRgb(bgColor.value))
const hi = Math.max(l1, l2), lo = Math.min(l1, l2)
return (hi + 0.05) / (lo + 0.05)
})
const contrastRatioText = computed(() => contrastRatio.value.toFixed(2))
const wcagRows = computed(() => {
const r = contrastRatio.value
return [
{ scene: '普通文本(< 18pt)', require: 'AA ≥ 4.5', pass: r >= 4.5, desc: '正文、说明文字等常规字号' },
{ scene: '普通文本(< 18pt)', require: 'AAA ≥ 7', pass: r >= 7, desc: '面向低视力用户的增强级要求' },
{ scene: '大字文本(≥ 18pt 或 14pt 粗体)', require: 'AA ≥ 3', pass: r >= 3, desc: '标题、大号数字等' },
{ scene: '大字文本(≥ 18pt 或 14pt 粗体)', require: 'AAA ≥ 4.5', pass: r >= 4.5, desc: '大字号的增强级要求' },
{ scene: '图形与界面组件', require: '≥ 3', pass: r >= 3, desc: '图标、边框、焦点框等非文本元素' }
]
})
const contrastLevel = computed(() => {
const r = contrastRatio.value
if (r >= 7) return 'AAA(普通文本达标)'
if (r >= 4.5) return 'AA(普通文本达标)'
if (r >= 3) return '仅大字文本达标'
return '不达标'
})
const contrastLevelTagType = computed(() => {
const r = contrastRatio.value
if (r >= 7) return 'success'
if (r >= 4.5) return 'success'
if (r >= 3) return 'warning'
return 'danger'
})
function swapContrast() {
const t = fgColor.value
fgColor.value = bgColor.value
bgColor.value = t
}
function onHexInput() {
hexText.value = hex.value
error.value = ''
}
function onHexText() {
const v = hexText.value.startsWith('#') ? hexText.value : '#' + hexText.value
if (isValidHex(v)) {
hex.value = normalizeHex(v)
error.value = ''
} else {
error.value = 'HEX 格式不正确,应为 #RGB 或 #RRGGBB'
}
}
async function copy(text) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动复制')
}
}2.3 效果截图
