컬러 피커 직접 만들기 CSS+JS 9종 — 라이브러리 없이
컬러 피커는 화면에서 색을 골라 HEX 코드로 돌려주는 선택 창입니다. 이 페이지에는 그 부품 아홉 벌이 라이브러리 없이 색 공간 계산까지 직접 짠 코드로 돌아가고 있고, 코드는 통째로 복사해 쓰면 됩니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 채도·명도 사각형을 끌어 고르는 색
- 02 각도를 재서 색상을 고르는 색상환
- 03 HEX·RGB·HSL 세 칸이 서로 따라가는 입력
- 04 칸무늬 위에서 투명도를 미는 알파 슬라이더
- 05 최근 고른 색 여덟 칸이 앞으로 밀리는 기록
- 06 색상값을 돌려 만드는 보색·유사색·삼각 배색
- 07 두 색의 명암비를 재서 AA·AAA 를 붙이는 검사기
- 08 정지점을 끌어 만드는 그라디언트 막대
- 09 그림 위 픽셀을 집어 오는 스포이드
아홉 개는 예쁜 순서가 아니라 색이 피커에서 일하는 순서로 놓았습니다. 색을 직접 찍어 고르는 두 축(01·02)에서 시작해, 고른 색을 표기로 주고받고(03), 투명도를 다루고(04), 그 색이 기록으로 쌓이고(05), 하나의 색이 배색으로 번지고(06), 두 색이 만나는 기준을 재고(07), 색이 그라디언트로 이어지고(08), 마지막에야 화면에서 색을 집어 옵니다(09). 아홉 벌이 같은 토큰을 나눠 쓰므로 한 페이지에 몇 개를 섞어도 톤이 어긋나지 않습니다.
01채도·명도 사각형을 끌어 고르는 색
색 고르기의 기본 축입니다. 사각형의 가로가 채도, 세로가 명도로 읽히고, 드래그하면 손잡이가 선 자리의 HSV 가 RGB 로 바뀌어 칩과 HEX 글자가 같은 순간에 따라옵니다. 이 데모의 누적 변화 면적은 화면의 2.738% 로 아홉 벌 중 가장 조용하고, 디자인 도구의 테마 편집기에 그대로 넣을 수 있습니다.
// 등록 속성 세 개가 색의 유일한 출처다. 칩 배경도 HEX 글자도 전부 여기서 나온다
@property --r { syntax: "<integer>"; inherits: true; initial-value: 204; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 228; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 255; }
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0"; // 0F 처럼 앞자리를 채운다
}
.pk__hex { counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b)); }
.pk__hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
// HSV(0~360, 0~1, 0~1) → RGB(0~255). 여섯 구간을 표로 펴 분기 없이 고른다
function hsvToRgb(h, s, v) {
var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
var face = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
return face.map(function (n) { return Math.round((n + m) * 255); });
}
// 칸 밖으로 끌어도 이 요소가 계속 포인터를 받는다 — 드래그가 끊기지 않는 핵심 한 줄
el.setPointerCapture(e.pointerId);
02각도를 재서 색상을 고르는 색상환
색상(hue)만 고를 때는 사각형보다 원이 빠릅니다. 원 안을 누른 자리의 각도가 곧 0~360 색상값이 되고, 손잡이는 링에서 자기 색 위에 섭니다. 움직이는 값은 --h 하나뿐입니다.
// 중심에서 포인터까지의 각도를 잰다. Math.atan2 는 3시 방향이 0 이고 시계방향으로 커지는데,
// conic-gradient(from 90deg) 도 빨강(색상 0)을 3시에 놓는다 — 두 기준이 같아 보정 없이 그대로 색상값이다
var deg = Math.atan2(e.clientY - box.top - box.height / 2,
e.clientX - box.left - box.width / 2) * 180 / Math.PI;
hue = Math.round((deg + 360) % 360) % 360;
// 손잡이는 링에 붙어 도는 요소다 — 회전 중심이 자기 중심이 아니라 링 중심이라 링 크기 레이어째 돌린다.
// +90 은 conic 의 from 90deg 보정이다. 색상 0 이 3시에 있으니 손잡이도 3시에서 출발해야 색 위에 선다
.hw__arm { transform: rotate(calc((var(--h) + 90) * 1deg)); }
.hw__arm::after {
transform: translate(-50%, -50%) translateY(-51px); // 링 두께 한가운데(반지름 51px)
}
.hw__core { background: hsl(var(--h) 92% 52%); }
03HEX·RGB·HSL 세 칸이 서로 따라가는 입력
같은 색을 표기 셋으로 주고받는 입력입니다. 한 칸에 값을 넣으면 나머지 두 칸이 같은 색으로 다시 계산되고, 형식이 어긋난 칸만 aria-invalid 로 붉게 남습니다. 개발자 도구 색 입력이나 디자인 토큰 편집기에 쓰는 벌로, 누적 변화 면적 6.699% · 움직인 프레임 20/23 입니다.
// 미리보기 글자 — 값은 --r --g --b --hh --ss --ll 여섯 개뿐이고 계수기가 표기 셋으로 나눠 찍는다.
// 루프의 근거: 명도 l ≤ 0.5 구간에서 hsl(212, 90%, l) 의 RGB 는 (0.1l, 0.94l, 1.9l) × 255 라
// 명도에 완전히 비례한다 — 색상 212·채도 90% 고정이라 두 표기가 어느 프레임에서도 같은 색을 가리킨다
.sy.is-demo .sy__input { color: transparent; } // 진짜 입력칸은 공간을 유지한 채 감춘다
.sy.is-demo .sy__ghost { opacity: 1; }
.sy__ghost--hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.sy__ghost--rgb::after { content: "rgb(" counter(cr) ", " counter(cg) ", " counter(cb) ")"; }
.sy__ghost--hsl::after { content: "hsl(" counter(chh) ", " counter(css) "%, " counter(cll) "%)"; }
04칸무늬 위에서 투명도를 미는 알파 슬라이더
투명한 색은 흰 배경 위에서 보이지 않으므로, 알파 슬라이더는 칸무늬를 깔고 시작합니다. 손잡이를 왼쪽으로 되밀수록 색 덮개가 옅어져 뒤의 칸무늬가 드러나고, 무늬를 가린 만큼이 곧 알파 값입니다. 반투명 배경 토큰이나 오버레이 색을 정할 때 눈으로 맞추는 벌로, 아홉 개 중 가장 넓게 움직여 누적 변화 면적 13.06% · 강도 87.5 · 움직인 프레임 18/23 입니다.
// 위층은 투명 → 불투명 색 덮개, 아래층은 12px 타일 칸무늬(타일 안이 네 칸이라 한 칸은 6px)
.al__track {
background-image:
linear-gradient(to right, rgba($subject-blue, 0), $subject-blue),
repeating-conic-gradient(#d8d4cc 0 25%, $stage-paper 0 50%);
background-size: auto, 12px 12px;
}
// 손잡이와 숫자가 --a 하나에서 나온다 — 264px 트랙의 1% 가 2.64px 이다
.al__knob { transform: translateX(calc(var(--a) * 2.64px)) translate(-50%, -50%); }
.al__val::after { content: counter(ca) "%"; }
05최근 고른 색 여덟 칸이 앞으로 밀리는 기록
고른 색을 다시 쓰게 해 주는 기록입니다. 담기를 누를 때마다 맨 앞에 칸이 하나 끼어들고 아홉 번째는 뒤로 밀려 사라지며, 목록은 localStorage 에 남아 페이지를 다시 열어도 그대로입니다. 여덟 칸이 0.25 초씩 위상이 어긋나며 색이 옆으로 흐르는 미리보기는 움직인 프레임 23/23 을 채웠습니다.
// 목록은 localStorage 에 남는다. 파일로 열면 저장소가 막힐 수 있어 실패해도 기본 팔레트로 이어간다
function read() {
try {
var list = JSON.parse(window.localStorage.getItem(storeKey));
if (list && list.length) { return list.slice(0, 8); }
} catch (err) { /* 저장소를 못 쓰는 환경 */ }
return palette.slice();
}
recent.unshift(nowColor);
recent = recent.slice(0, 8);
// 칸마다 animation-delay 를 -0.25s 씩 다르게. 여덟 색이 0.25초마다 한 칸씩 옮겨 가는 것이
// 곧 "앞에 하나 끼어들고 뒤가 밀린다" 이고, 2초 뒤 정확히 제자리라 텔레포트가 없다
.rc.is-demo .rc__cell:nth-child(2) { animation-delay: -1.75s; }
@keyframes histRoll {
0% { --c: #{$subject-blue}; }
12.5% { --c: #{$subject-sky}; }
25% { --c: #{$subject-mint}; }
50% { --c: #{$stage-orange}; }
100% { --c: #{$subject-blue}; }
}
06색상값을 돌려 만드는 보색·유사색·삼각 배색
팔레트 초안 뽑기입니다. 기준 색상값에 보색은 180 도, 유사색은 ±30·±60 도, 삼각은 ±120 도를 더해 다섯 칩을 만듭니다. 색을 다섯 번 계산하지 않고 칩마다 「기준에 더할 각도」 하나만 두어 hsl() 이 그 자리에서 만들게 하는 것이 핵심입니다.
// 칩 다섯 개 — 색도 각도 글자도 같은 --oN 하나에서 나온다
.hm__chip:nth-child(1) .hm__sw { background: hsl(calc(var(--base) + var(--o1)) 68% 70%); }
.hm__chip:nth-child(1) .hm__deg { counter-set: cd calc(var(--o1)); }
.hm__deg::after { content: counter(cd) "°"; }
// 보색(0 0 0 180 180) → 유사색(-60 -30 0 30 60) → 삼각(-120 -120 0 120 120).
// 배색 교체는 steps(1, end) 로 한 프레임에 컷한다 — 크로스페이드는 반투명 유령을 남긴다
@keyframes hmSet {
0% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
33% { --o1: -60; --o2: -30; --o3: 0; --o4: 30; --o5: 60; }
66% { --o1: -120; --o2: -120; --o3: 0; --o4: 120; --o5: 120; }
90%, 100% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
}
07두 색의 명암비를 재서 AA·AAA 를 붙이는 검사기
이 사이트가 매 편 접근성 표를 채우는 바로 그 공식을 부품으로 꺼냈습니다. 글자색과 배경색의 상대 휘도를 각각 구해 비율을 내고, 4.5 와 7 을 넘는 순간 AA·AAA 배지가 켜집니다. 다섯 단계 배경을 흰 글자로 재는 미리보기는 누적 변화 면적 12.05% 로 아홉 벌 중 두 번째로 넓게 움직입니다.
// WCAG 2.x 상대 휘도 — 채널마다 감마를 되돌린 뒤 사람 눈의 민감도로 가중한다
function channel(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function luminance(hex) {
var n = parseInt(hex.slice(1), 16);
return 0.2126 * channel(((n >> 16) & 255) / 255)
+ 0.7152 * channel(((n >> 8) & 255) / 255)
+ 0.0722 * channel((n & 255) / 255);
}
// 글자는 흰색 고정이므로 밝은 쪽 휘도는 언제나 1 이다
function ratioToWhite(hex) { return 1.05 / (luminance(hex) + 0.05); }
// 비율 숫자 — 정수부와 소수 두 자리를 따로 찍어 4.53 꼴을 만든다
@counter-style dd { system: numeric; symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9"; pad: 2 "0"; }
.cc__ratio { counter-set: cw calc(var(--rw)) cf calc(var(--rf)); }
.cc__ratio::after { content: counter(cw) "." counter(cf, dd) " : 1"; }
08정지점을 끌어 만드는 그라디언트 막대
히어로 배경에 쓸 그라디언트를 눈으로 맞추는 빌더입니다. 막대 위 정지점을 끌면 위치가 퍼센트로 읽히고, 그 아래에 linear-gradient 문자열이 그대로 나와 복사해 쓰면 됩니다. 색 분포와 손잡이 자리와 문자열이 --p 하나에서 함께 움직입니다.
// 아래 CSS 문자열 — 퍼센트 자리만 계수기가 채운다
.gs__bar {
background-image: linear-gradient(90deg, $stage-orange 0%, $stage-yellow calc(var(--p) * 1%), $subject-blue 100%);
}
.gs__stop--m { transform: translateX(calc(var(--p) * 2.64px)) translate(-50%, -50%); }
.gs__code { counter-set: cp calc(var(--p)); }
.gs__code::after {
content: "linear-gradient(90deg, #{$stage-orange} 0%, #{$stage-yellow} " counter(cp) "%, #{$subject-blue} 100%)";
}
09그림 위 픽셀을 집어 오는 스포이드
사진이나 스크린샷에서 색을 뽑는 축입니다. 캔버스에 그린 그림 위를 지나가면 그 자리 픽셀 한 개를 getImageData 로 읽어 확대경과 HEX 에 그대로 붙입니다. 집기 버튼은 EyeDropper API 가 있으면 화면 전체에서 집고 없으면 캔버스 위 집기로 내려갑니다.
// 위쪽 56px 은 가로 3색 띠 — 채널마다 1차 보간이라 x 좌표와 색이 정확히 대응한다
var band = ctx.createLinearGradient(0, 0, 264, 0);
band.addColorStop(0, '#ff4d1f');
band.addColorStop(0.5, '#ffd23f');
band.addColorStop(1, '#2f6df6');
var px = ctx.getImageData(sx, sy, 1, 1).data; // 그 한 점의 RGBA
// 크롬 계열은 화면 어디서나 집는다. 없는 브라우저는 캔버스 위 집기로 조용히 내려간다
if ('EyeDropper' in window) {
new window.EyeDropper().open().then(function (res) {
var n = parseInt(res.sRGBHex.slice(1), 16);
setColor((n >> 16) & 255, (n >> 8) & 255, n & 255);
}).catch(function () { sample(); });
} else {
sample();
}
어디서 깨지는가 — 함정
가장 먼저 부딪힌 것은 색과 글자가 따로 놀았다는 것입니다. 첫 판의 01 에서는 칩 배경은 CSS 가, HEX 글자는 JS textContent 가 따로 쓰게 만들었더니 미리보기 루프에서 색은 이미 바뀌었는데 글자가 한 박자 늦는 프레임이 생겼습니다 — 두 경로가 갈라지는 순간 피커는 예쁘든 아니든 믿을 수 없어집니다. 아홉 벌 전부 색과 숫자를 등록 속성 하나에만 담고 counter-set 이 값을 계수기로 옮겨 찍게 고쳤습니다. 글자를 쓰는 JS 코드가 사라지니 어긋날 경로 자체가 없어졌습니다.
두 번째는 보간의 기준이었습니다. 03 의 명도 루프를 처음엔 $ease-spring 으로 돌렸더니, 이징 곡선이 위치와 색에 같은 비율로 걸리지 않아 RGB 줄과 HSL 줄이 같은 색을 가리키지 않는 프레임이 나왔습니다. 색 공간과 이징은 모두 선형 보간 위에서만 정확히 대응합니다 — 그래서 03 은 색상 212·채도 90% 를 고정해 RGB 가 명도에 완전히 비례하는 구간만 지나게 하고 이징을 linear 로 통일했습니다. 02 의 손잡이도 마찬가지입니다. conic-gradient(from 90deg) 는 빨강을 3시에 두는데 Math.atan2 도 0 을 3시에 둡니다 — 두 기준이 같다는 것을 확인한 뒤에야 보정 없이 각도를 그대로 색상값으로 쓸 수 있었습니다.
세 번째는 버튼 라벨이 아니라 버튼의 폭이었습니다. 06 의 배색 탭은 이미 grid-template-columns: repeat(3, 1fr) 로 세 칸을 같은 폭으로 나눠 놓았는데도 눌린 탭에 배경색이 켜지는 순간 폭이 흔들렸습니다 — <button> 은 격자 칸 안에 있어도 스스로는 글자 폭만큼만 커지는 성질이 있어, 칸을 채우는 width: 100% 를 명시해야 했습니다. 상태 교체는 steps(1, end) 로 한 프레임에 컷했고 컷만 남기면 움직인 프레임이 두세 장으로 떨어져 측정 하한을 못 넘어, 칩마다 40 밀리초씩 밀린 scale 팝을 덧붙여 프레임을 채웠습니다. 눌린 탭의 표시도 같은 프레임에서 함께 컷했습니다. 미리보기는 삼각 배색을 보여주는데 눌린 탭이 아직 보색을 가리키고 있으면, 코드가 틀린 게 아니라 화면이 거짓말을 하는 셈이 됩니다.
05 의 기록도 같은 함정을 다른 모양으로 만났습니다. 칸을 실제로 옆으로 미는 방식으로 짜면 여덟 번째 칸이 사라지는 순간 트랙이 한 번 튀고, 루프가 제자리로 돌아올 때 텔레포트가 생깁니다. 그래서 칸은 한 픽셀도 움직이지 않게 두고 색만 steps(1, end) 로 갈아끼우되, 칸마다 위상을 0.25 초씩 어긋나게 했습니다. 여덟 색이 0.25 초마다 한 칸씩 옮겨 가는 것이 곧 「앞에 하나 끼어들고 뒤가 밀린다」 이고, 2 초 뒤에는 정확히 처음 색으로 돌아옵니다.
마지막은 아홉 벌이 공유하는 기하입니다. 카드 폭 288px 에 안쪽 264px — 트랙을 짓는 네 벌(01·04·08·09)이 전부 이 264px 위에서 손잡이 자리를 계산하고, 04 와 08 은 「264px 의 1% 는 2.64px」 한 줄로 값을 그대로 픽셀로 옮깁니다. 이 한 줄이 틀어지면 손잡이가 선 자리와 화면에 찍히는 값이 갈라져, 표시기는 그 자리 색을 말하는데 숫자는 딴 자리를 말하게 됩니다. 같은 이유로 09 의 확대경은 자리와 세 색 값을 같은 퍼센트·같은 linear 이징으로 움직입니다 — 캔버스 그라디언트가 채널마다 1차 보간이라 위치를 선형으로 옮기면 색도 선형으로 따라와, 어느 프레임에서도 확대경 색이 그 자리 픽셀과 같습니다. 아홉 벌의 소스와 이 글이 잰 측정값이 통째로 zip 으로 들어 있고, 여는 비밀번호는 zg928jh3 입니다.
접근성
움직임을 줄이는 설정(prefers-reduced-motion: reduce)에서 아홉 벌은 전부 같은 원칙을 따릅니다 — 스스로 도는 미리보기 루프는 끄고, 피커 자체는 완성된 상태로 남습니다. 정적 규칙을 먼저 쓰고 그 위에 .is-demo 루프를 덧씌우는 구조라 애니메이션을 꺼도 01 의 손잡이는 자리에 있고 07 의 AA 배지는 켜진 채로(AAA 는 꺼진 채 「미달」을 단 채로) 남고 09 의 확대경은 그림 위에 서 있습니다. reduce 블록의 선언에는 전부 !important 가 붙어 있는데, 루프 쪽이 클래스를 겹쳐 쌓은 모양이라 특이도에서 밀리지 않게 하려는 것입니다.
키보드는 여섯 벌의 슬라이더(01·02·04·06·08·09)가 role="slider" 와 aria-valuetext 를 가집니다 — 값뿐 아니라 「채도 94%, 명도 55%, #08468C」 처럼 숫자와 색을 같은 문장으로 읽어 줍니다. 화살표 키는 ±소폭, Shift 를 누르면 큰 폭입니다. 색만으로 상태를 전달하지 않는다는 원칙은 05 와 07 이 지킵니다 — 고른 칸은 테두리 고리에 HEX 라벨을 함께, 배지는 「통과」·「미달」 글자를 함께 붙입니다. 07 의 비율 출력은 aria-live="polite" 로 바뀔 때마다 다시 읽힙니다.
피커 안 글자의 대비는 이 페이지가 쓰는 공식으로 직접 계산한 값입니다.
| 자리 | 앞·뒤 색 | 실측 대비 | 기준 |
|---|---|---|---|
| 01 카드 글자 | 잉크 / 크림 | 17.11:1 | AA · AAA 통과 |
| 02 카드 글자 | 크림 / 잉크 | 17.11:1 | AA · AAA 통과 |
| 03 카드 글자 | 잉크 / 크림 | 17.11:1 | AA · AAA 통과 |
| 07 견본 글자(흰) | 흰 / 파랑 #2F6DF6 | 4.53:1 | AA 통과 |
| 09 무대 글자 | 잉크 / 오렌지 | 5.5:1 | AA 통과 |
| 08 코드 글자(잉크 74%) | 잉크 / 크림 | 7.57:1 | AA · AAA 통과 |
07 의 4.53:1 은 데모 자체가 재어 주는 값입니다 — 07 이 움직이는 다섯 배경 중 #2F6DF6 단계가 흰 글자에 대해 정확히 4.53 을 내어 AA 문턱(4.5)을 넘는 순간을 그대로 보여 줍니다. 이 표의 계산 근거는 MDN 의 WCAG 명암비 문서와 W3C WCAG 2.x 이해 문서를 따랐습니다.
색을 고르는 다른 축은 세그먼트 컨트롤 선택 UI 9종에, 값이 아니라 날짜를 고르는 피커는 달력 선택 UI 9종에 모여 있고, 이 사이트가 데모를 어떻게 렌더하고 잰 뒤에 글을 쓰는지는 소개에 적어 두었습니다.
FAQ
라이브러리 없이 만들면 브라우저 호환성은 어떻게 되나요?
이 편이 쓴 기술의 지원 범위는 넓습니다 — 2026-09-12 caniuse 실측으로 @property 는 전역 95.01%(Chrome 85+·Edge 85+·Firefox 128+·Safari 16.4+), counter-set 은 95.27%, @counter-style 은 95.29% 입니다. 예외는 09 의 EyeDropper API 로 전역 30.8%(Chrome·Edge 95+, Firefox·Safari 지원 없음)라서, 지원을 확인한 뒤 없으면 캔버스 위 집기로 내려가는 폴백을 같이 넣었습니다.
<input type="color"> 를 쓰면 되지 않나요?
간단한 자리에서는 그쪽을 쓰는 게 맞습니다. 다만 네이티브 색 입력은 브라우저마다 창이 다르고 표기 동기·알파·배색·명암비 같은 기능은 아예 없습니다. 이 편의 부품은 그 기능이 필요해지는 순간에 꺼내 쓰는 쪽입니다 — 01 의 격자 하나만 쓰더라도 표기와 손잡이가 어긋나지 않는 구조가 이미 들어 있습니다.
고른 색을 사이트에 저장하고 싶으면 어디를 고치나요?
05 가 그 뼈대입니다. localStorage 키 이름(godrich-recent-colors)과 slice(0, 8) 의 8 이 목록 길이입니다. 서버에 남기고 싶으면 save() 함수 안의 한 줄만 바꾸면 됩니다 — 읽고 쓰는 모양이 이미 한 곳에 모여 있기 때문입니다.