JavaScript 색상 조작: 라이브러리와 기법
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
웹 애플리케이션에서 색상은 디자인 파일에서 붙여넣고 그대로 두는 정적인 값이 아니다. 색상은 호버 시 밝아지고, 클릭 시 어두워지며, 접근성 대비 요구 사항에 맞게 조정되고, 상태 간을 애니메이션하며, 사용자가 선택한 테마에 적응한다. 이 모든 것에는 프로그래매틱한 색상 조작 — 런타임에 JavaScript로 색상 값을 파싱하고, 변환하고, 생성하는 능력 — 이 필요하다.
이 가이드는 JavaScript가 색상을 네이티브로 처리하는 방법, 일반적인 변환의 수학적 원리, 가장 널리 사용되는 세 가지 라이브러리의 비교, 그리고 의존성 없이 최소한의 색상 유틸리티를 구축하는 방법을 다룬다.
헥스, RGB, HSL을 JavaScript로 파싱하기
JavaScript에는 내장된 색상 타입이 없다. 색상은 문자열로 도착한다 — "#FF5733", "rgb(255, 87, 51)", "hsl(11, 100%, 60%)" — 수학 연산을 하기 전에 직접 파싱해야 한다.
헥스 코드 파싱
헥스 색상은 세 개(또는 알파 포함 네 개)의 1바이트 정수를 16진법으로 압축 인코딩한 것이다. 파싱은 문자열을 슬라이스하고 parseInt를 호출하는 것이다:
function parseHex(hex) {
// 정규화: # 제거, 단축 형식 확장 (#F53 → #FF5533)
const clean = hex.replace('#', '');
const full = clean.length === 3
? clean.split('').map(c => c + c).join('')
: clean;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
};
}
parseHex('#FF5733'); // { r: 255, g: 87, b: 51 }
parseHex('#F53'); // { r: 255, g: 85, b: 51 }
반대로 — 정수를 헥스 문자열로 변환 — 는 toString(16)에 제로 패딩을 사용한다:
function toHex({ r, g, b }) {
return '#' + [r, g, b]
.map(v => Math.round(v).toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
}
toHex({ r: 255, g: 87, b: 51 }); // '#FF5733'
RGB 문자열 파싱
DOM이나 CSS-in-JS에서 오는 RGB 문자열은 종종 "rgb(255, 87, 51)"로 도착한다. 정규식으로 세 값을 추출한다:
function parseRgb(str) {
const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!match) throw new Error(`Invalid RGB: ${str}`);
return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3]),
};
}
parseRgb('rgb(255, 87, 51)'); // { r: 255, g: 87, b: 51 }
parseRgb('rgba(255, 87, 51, 0.5)'); // { r: 255, g: 87, b: 51 }
HSL 문자열 파싱
HSL 문자열 — "hsl(11, 100%, 60%)" — 은 도수와 퍼센트 값을 추출해야 한다:
function parseHsl(str) {
const match = str.match(/hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%/);
if (!match) throw new Error(`Invalid HSL: ${str}`);
return {
h: parseFloat(match[1]),
s: parseFloat(match[2]),
l: parseFloat(match[3]),
};
}
RGB와 HSL 간 변환
대부분의 색상 수학은 RGB(혼합용)나 HSL/HSV(직관적인 조정용)에서 작동한다. 이 둘 사이의 변환이 첫 번째로 필요한 기술이다:
function rgbToHsl({ r, g, b }) {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
case gn: h = ((bn - rn) / d + 2) / 6; break;
default: h = ((rn - gn) / d + 4) / 6;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
rgbToHsl({ r: 255, g: 87, b: 51 }); // { h: 11, s: 100, l: 60 }
색상 수학: 밝게, 어둡게, 채도 낮추기
색상을 HSL로 얻으면 조정이 산술이다. HSL의 세 축은 디자이너들이 가장 자주 요청하는 조정에 직접 매핑된다.
밝게 및 어둡게
l(명도) 채널을 증가 또는 감소시키는 것이 직접적인 접근 방식이다:
function lighten(hex, amount) {
const hsl = rgbToHsl(parseHex(hex));
hsl.l = Math.min(100, hsl.l + amount);
return hslToHex(hsl);
}
function darken(hex, amount) {
const hsl = rgbToHsl(parseHex(hex));
hsl.l = Math.max(0, hsl.l - amount);
return hslToHex(hsl);
}
lighten('#FF5733', 15); // 더 밝은 코랄
darken('#FF5733', 15); // 더 어두운 빨강-주황
이것은 작은 조정에 잘 작동한다. 전체 50–950 척도(Tailwind CSS가 하는 것처럼)를 생성하려면 HSL에서 인식되는 밝기가 선형이 아니기 때문에 수학이 더 복잡해진다 — Shade Generator는 지각적으로 가중된 분포로 이를 처리한다.
채도 낮추기
s(채도) 채널을 0으로 줄이면 어떤 색상도 회색으로 만든다:
function desaturate(hex, amount) {
const hsl = rgbToHsl(parseHex(hex));
hsl.s = Math.max(0, hsl.s - amount);
return hslToHex(hsl);
}
function grayscale(hex) {
return desaturate(hex, 100);
}
desaturate('#FF5733', 50); // 채도가 낮은 주황색
grayscale('#FF5733'); // 같은 밝기의 순수 회색
두 색상 혼합
RGB 공간에서의 선형 보간이 가장 간단한 혼합이다:
function mix(hex1, hex2, weight = 0.5) {
const c1 = parseHex(hex1);
const c2 = parseHex(hex2);
return toHex({
r: Math.round(c1.r * weight + c2.r * (1 - weight)),
g: Math.round(c1.g * weight + c2.g * (1 - weight)),
b: Math.round(c1.b * weight + c2.b * (1 - weight)),
});
}
mix('#FF5733', '#FFFFFF', 0.7); // 코랄 70%, 흰색 30% → 틴트
mix('#FF5733', '#000000', 0.7); // 코랄 70%, 검정 30% → 쉐이드
RGB 혼합은 보색 사이에서 지각적으로 평평한 결과를 만든다. 더 나은 중간 지점을 위해 chroma.js 같은 라이브러리는 OKLCH나 Lab 공간에서의 혼합을 제공한다.
대비 비율 (WCAG)
WCAG 대비 비율은 원시 채널 값이 아닌 상대 휘도에서 계산된다. 휘도는 감마 보정이 필요하다 — sRGB 값이 저장될 때 적용된 인코딩의 역수:
function relativeLuminance({ r, g, b }) {
const linearize = (v) => {
const sRGB = v / 255;
return sRGB <= 0.04045
? sRGB / 12.92
: Math.pow((sRGB + 0.055) / 1.055, 2.4);
};
const R = linearize(r), G = linearize(g), B = linearize(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function contrastRatio(hex1, hex2) {
const L1 = relativeLuminance(parseHex(hex1));
const L2 = relativeLuminance(parseHex(hex2));
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
contrastRatio('#FF5733', '#FFFFFF'); // ~3.0 — 일반 텍스트 WCAG AA 실패
contrastRatio('#000000', '#FFFFFF'); // 21.0 — 최대 대비
WCAG AA는 일반 텍스트에 4.5:1, 큰 텍스트에 3:1을 요구한다. Color Converter로 모든 쌍을 확인하라.
인기 라이브러리 비교: chroma.js, culori, tinycolor2
간단한 조정 이상의 경우, 전용 라이브러리는 상당한 시간을 절약하고 색상 수학의 엣지 케이스 버그를 방지한다.
chroma.js
크기: ~13KB gzipped | 성숙도: 2013년, 활발히 유지보수 | 라이선스: BSD
chroma.js는 가장 널리 알려진 JavaScript 색상 라이브러리다. API가 유창하고 체이닝 가능하다:
import chroma from 'chroma-js';
// 어떤 형식이든 파싱
const color = chroma('#FF5733');
// 조정
color.darken(1).hex(); // '#D93B10'
color.lighten(1).hex(); // '#FF8066'
color.saturate(0.5).hex(); // '#FF4719'
color.desaturate(0.5).hex();// '#F2653F'
// 변환
color.rgb(); // [255, 87, 51]
color.hsl(); // [11, 1, 0.6]
color.lab(); // [52.8, 47.1, 44.7]
color.oklch();// [0.63, 0.19, 27.5]
// 다른 색 공간에서 혼합
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb'); // RGB에서 (평평한 중간점)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // OKLCH에서 (선명한 중간점)
// 척도 생성
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
.mode('oklch')
.colors(9);
// 대비
chroma.contrast('#FF5733', '#FFFFFF'); // 3.0
chroma.js는 OKLCH 혼합을 포함한 광범위한 색 공간 지원과 함께 포괄적이고 잘 문서화된 라이브러리가 필요할 때 올바른 선택이다.
culori
크기: ~6KB gzipped (트리 쉐이킹 가능) | 성숙도: 2019년, 활발히 유지보수 | 라이선스: MIT
culori는 트리 쉐이킹을 위해 설계된 현대적인 함수형 라이브러리다. 모든 연산이 독립적인 함수다 — 사용하는 것만 임포트한다:
import { parse, formatHex, oklch, interpolate, formatCss } from 'culori';
// 파싱
const color = parse('#FF5733'); // { mode: 'rgb', r: 1, g: 0.34, b: 0.2 }
// OKLCH로 변환
const inOklch = oklch(color); // { mode: 'oklch', l: 0.63, c: 0.19, h: 27.5 }
// 명도 조정
const lighter = { ...inOklch, l: inOklch.l + 0.1 };
formatHex(lighter); // '#FF8469'
// 그라디언트/애니메이션을 위한 보간
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // CSS 형식의 중간점 색상
// 척도 생성
import { samples } from 'culori';
const stops = samples(5).map(t => formatHex(gradient(t)));
// ['#FF5733', '#E85E56', '#9B6DE3', '#6186F0', '#3B82F6']
culori는 mode 속성을 가진 일반 객체로 작동하여 색상을 직렬화하고, 상태에 저장하거나, 네트워크를 통해 전송하기 쉽게 만든다. 번들 크기가 중요하고 트리 쉐이킹이 적용되는 현대 TypeScript 프로젝트에 최적의 선택이다.
tinycolor2
크기: ~5KB gzipped | 성숙도: 2012년, 안정적 (덜 활발) | 라이선스: MIT
tinycolor2는 가장 작고 가장 허용적인 파서 — CSS 명명 색상을 포함하여 거의 모든 색상 문자열 형식을 받아들이며, 설정 없이 작동한다:
import tinycolor from 'tinycolor2';
// 거의 모든 것을 파싱
tinycolor('red').toHexString(); // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString(); // '#FF5533'
// 조정
tinycolor('#FF5733').lighten(15).toHexString(); // '#FF8966'
tinycolor('#FF5733').darken(15).toHexString(); // '#C92B04'
tinycolor('#FF5733').desaturate(30).toHexString(); // '#EB6643'
tinycolor('#FF5733').spin(180).toHexString(); // '#33AEff' (보색)
// 가독성 / 대비
tinycolor.readability('#FF5733', '#FFFFFF'); // 3.0
tinycolor.isReadable('#FF5733', '#FFFFFF'); // false (WCAG AA 실패)
tinycolor.isReadable('#FF5733', '#FFFFFF', { level: 'AA', size: 'large' }); // true
// 색상 조화
tinycolor('#FF5733').triad(); // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous(); // 6가지 유사색
tinycolor('#FF5733').complement(); // 단일 보색
tinycolor2는 (어떤 형식이든 될 수 있는) 사용자가 입력한 색상 문자열의 신뢰할 수 있는 파싱과 더 큰 의존성 없이 기본적인 조작이 필요한 프로젝트에 올바른 선택이다.
라이브러리 비교 요약
| 기능 | chroma.js | culori | tinycolor2 |
|---|---|---|---|
| 번들 크기 | ~13KB | ~6KB (트리 쉐이킹 가능) | ~5KB |
| 색 공간 | RGB, HSL, Lab, LCH, OKLCH | OKLCH, P3 포함 20개+ | RGB, HSL, HSV |
| API 스타일 | 유창/체이닝 | 함수형 | 객체 지향 |
| TypeScript | 커뮤니티 타입 | 내장 | 커뮤니티 타입 |
| 색상 혼합 | RGB, HSL, Lab, OKLCH | 모든 색 공간 | RGB만 |
| 최적 용도 | 포괄적 사용, 데이터 시각화 | 현대 TS 번들 | 파싱, 간단한 작업 |
처음부터 색상 유틸리티 구축
헥스 파싱, 밝게, 어둡게, 대비 확인만 필요한 프로덕션 애플리케이션의 경우, 의존성 없는 유틸리티가 종종 더 나은 아키텍처적 선택이다. 완전한 구현이 여기 있다:
// color-utils.js
export function parseHex(hex) {
const clean = hex.replace('#', '');
const full = clean.length === 3
? clean.split('').map(c => c + c).join('')
: clean;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
};
}
export function toHex({ r, g, b }) {
return '#' + [r, g, b]
.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
.join('').toUpperCase();
}
function rgbToHsl({ r, g, b }) {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
case gn: h = ((bn - rn) / d + 2) / 6; break;
default: h = ((rn - gn) / d + 4) / 6;
}
return { h, s, l };
}
function hslToRgb({ h, s, l }) {
if (s === 0) {
const v = Math.round(l * 255);
return { r: v, g: v, b: v };
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hue2rgb = (p, q, 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(p, q, h + 1/3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
};
}
export function lighten(hex, amount) {
const hsl = rgbToHsl(parseHex(hex));
return toHex(hslToRgb({ ...hsl, l: Math.min(1, hsl.l + amount / 100) }));
}
export function darken(hex, amount) {
const hsl = rgbToHsl(parseHex(hex));
return toHex(hslToRgb({ ...hsl, l: Math.max(0, hsl.l - amount / 100) }));
}
export function desaturate(hex, amount) {
const hsl = rgbToHsl(parseHex(hex));
return toHex(hslToRgb({ ...hsl, s: Math.max(0, hsl.s - amount / 100) }));
}
function linearize(v) {
const sRGB = v / 255;
return sRGB <= 0.04045 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
}
export function relativeLuminance(hex) {
const { r, g, b } = parseHex(hex);
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}
export function contrastRatio(hex1, hex2) {
const L1 = relativeLuminance(hex1);
const L2 = relativeLuminance(hex2);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
export function isWcagAA(foreground, background, largeText = false) {
const ratio = contrastRatio(foreground, background);
return ratio >= (largeText ? 3.0 : 4.5);
}
export function bestTextColor(background) {
const L = relativeLuminance(background);
return L > 0.179 ? '#000000' : '#FFFFFF';
}
사용법:
import { lighten, darken, contrastRatio, bestTextColor } from './color-utils.js';
const brand = '#FF5733';
const hover = darken(brand, 10); // :hover를 위한 어두운 색
const light = lighten(brand, 30); // 배경을 위한 밝은 틴트
const text = bestTextColor(brand); // 브랜드 색 위 텍스트로 검정 또는 흰색
console.log(contrastRatio(text, brand)); // ≥ 4.5여야 함
런타임 색상의 성능 고려사항
적극적으로 캐시하기
색상 계산은 참조적으로 투명하다 — 같은 헥스 입력은 항상 같은 출력을 만든다. 간단한 메모이제이션 래퍼는 매 렌더링마다 재계산을 피한다:
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key);
};
}
const lightenCached = memoize(lighten);
const contrastCached = memoize(contrastRatio);
JavaScript 대신 CSS 커스텀 속성 사용
동적 테마를 위해 매 상태 변경마다 JavaScript로 색상을 재계산하지 마라. 한 번에 팔레트를 계산하고, CSS 커스텀 속성에 쓰고, CSS가 모든 컴포넌트를 처리하게 하라:
function applyTheme(brandHex) {
const root = document.documentElement;
root.style.setProperty('--brand', brandHex);
root.style.setProperty('--brand-dark', darken(brandHex, 10));
root.style.setProperty('--brand-light', lighten(brandHex, 30));
root.style.setProperty('--on-brand', bestTextColor(brandHex));
}
// 브랜드 색상이 변경될 때 한 번만 호출, 매 렌더링마다 호출하지 않음
applyTheme('#FF5733');
렌더 루프에서 문자열 파싱 피하기
초당 60번 실행되는 React 렌더 함수 안에서 "rgb(255, 87, 51)"를 파싱하는 것은 낭비적이다. 한 번 파싱하고, 결과를 저장하고, 원시 문자열 대신 구조화된 색상 객체를 컴포넌트 트리를 통해 전달하라.
// 비용이 많이 드는: 매 호출마다 파싱
const color = chroma(colorString).darken(1).hex();
// 더 나은 방법: 한 번 파싱, 여러 번 변환
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();
런타임 색상 생성을 대규모로 하는 경우 — 마우스 이동마다 업데이트하는 색상 피커나 수백 가지 계산된 색상을 가진 시각화 — culori가 최선의 선택이다. 함수형, 무상태 아키텍처와 작은 번들 크기가 호출당 최소한의 오버헤드를 가진다.
핵심 요점
- JavaScript에는 네이티브 색상 타입이 없다 — 수학 연산을 하기 전에 헥스, RGB, HSL 문자열을 구조화된 객체로 파싱하라.
- HSL 산술(명도를 위한
l조정, 채도를 위한s조정)은 대부분의 일반적인 디자인 변환을 직접 처리한다. - WCAG 대비 비율은 감마 보정으로 휘도 계산이 필요하다 — 명도 채널 수학으로 근사하지 마라.
- chroma.js는 OKLCH 혼합을 포함한 탁월한 색 공간 지원을 가진 가장 완전한 라이브러리; culori는 TypeScript 프로젝트를 위한 현대적인 트리 쉐이킹 가능한 선택; tinycolor2는 사용자 입력 시나리오를 위한 가장 허용적인 파서.
- 의존성 없는 유틸리티는 파싱, 밝게/어둡게, 대비만 필요한 애플리케이션에 적합하다 — 완전한 구현은 100줄 미만이다.
- 계산된 색상을 적극적으로 캐시하고 렌더링마다 재계산하는 대신 CSS 커스텀 속성에 결과를 작성하라.
- Color Converter로 색상 변환을 시각적으로 확인하고, Shade Generator로 단일 브랜드 헥스에서 50–950 Tailwind 호환 척도를 생성하라.