06색상값을 돌려 만드는 보색·유사색·삼각 배색
기준 색의 색상값에 180도, ±30도, ±120도를 더해 세 가지 배색을 만들고 칩 다섯 개로 펼칩니다.
쓰임브랜드 팔레트 초안, 차트 색 배정
hslsteps(1, end)scale

<main class="stage">
<div class="hm is-demo">
<div class="hm__top">
<div class="hm__base" id="hmBase" role="slider" tabindex="0"
aria-label="기준 색상값"
aria-valuemin="0" aria-valuemax="360" aria-valuenow="262"
aria-valuetext="기준 색상값 262도"></div>
<span class="hm__basev" aria-hidden="true"></span>
<span class="hm__names" aria-hidden="true">
<b class="hm__name hm__name--1 is-on">보색 배색</b>
<b class="hm__name hm__name--2">유사색 배색</b>
<b class="hm__name hm__name--3">삼각 배색</b>
</span>
</div>
<div class="hm__tabs" role="group" aria-label="배색 고르기">
<button type="button" class="hm__tab" data-set="comp" aria-pressed="true">보색</button>
<button type="button" class="hm__tab" data-set="ana" aria-pressed="false">유사색</button>
<button type="button" class="hm__tab" data-set="tri" aria-pressed="false">삼각</button>
</div>
<ul class="hm__chips">
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 배색 생성기 — 기준 색상값 하나에서 보색·유사색·삼각 배색을 만든다.
// 색을 다섯 번 계산하지 않는다. 칩마다 "기준에 더할 각도" 하나(--o1 ~ --o5)만 두고
// hsl(calc(var(--base) + var(--o1)) 68% 70%) 로 그 자리에서 색을 만든다. 각도 글자도 같은 값에서
// 계수기가 찍으므로 칩 색과 각도 숫자가 어긋날 수가 없다.
// 배색을 바꾸는 것 = 각도 다섯 개를 한 프레임에 통째로 갈아끼우는 것이라 steps(1, end) 로 컷한다.
// 컷만 있으면 움직인 프레임이 두세 장으로 떨어지니, 칩마다 40ms 씩 밀린 scale 팝을 덧붙여 프레임을 채운다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// inherits: true 로 등록해야 칩(자손)이 물려받아 hsl()·counter-set 에 쓸 수 있다.
// inherits: false 로 등록하면 값이 .hm 자기 자신에만 남아 칩이 못 읽는다
@property --base { syntax: "<integer>"; inherits: true; initial-value: 262; }
@property --o1 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --o2 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --o3 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --o4 { syntax: "<integer>"; inherits: true; initial-value: 180; }
@property --o5 { syntax: "<integer>"; inherits: true; initial-value: 180; }
.hm {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.hm * { box-sizing: border-box; }
.hm__top {
display: flex;
align-items: center;
gap: $sp-2;
}
.hm__base {
width: 24px;
height: 24px;
border-radius: $r-xs;
cursor: ew-resize;
background: hsl(var(--base) 68% 52%);
}
.hm__base:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 기준 색상값 숫자 — 색을 만든 그 값을 그대로 찍는다
.hm__basev {
counter-set: cbs calc(var(--base));
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__basev::after { content: counter(cbs) "°"; }
// 배색 이름 셋을 격자 한 칸에 겹쳐 두고 opacity 를 컷한다 (position: absolute 아님 — 칸 폭이 살아 있다)
.hm__names {
margin-left: auto;
display: grid;
justify-items: end;
}
.hm__name {
grid-area: 1 / 1;
opacity: 0;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.hm__name.is-on { opacity: 1; }
.hm__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
margin-top: $sp-2;
}
.hm__tab {
width: 100%;
padding: $sp-1 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__tab[aria-pressed="true"] {
border-color: $subject-ink;
background: $subject-ink;
color: $subject-cream;
}
.hm__tab:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.hm__chips {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-2;
margin: $sp-2 0 0;
padding: 0;
list-style: none;
}
.hm__chip {
display: grid;
gap: $sp-1;
justify-items: center;
}
.hm__sw {
width: 100%;
height: 28px;
border-radius: $r-xs;
}
.hm__deg {
font-size: 10px;
font-weight: 600;
line-height: 1.2;
color: rgba(23, 20, 26, .7);
}
.hm__deg::after { content: counter(cd) "°"; }
// 칩 다섯 개 — 색도 각도 글자도 같은 --oN 하나에서 나온다
.hm__chip:nth-child(1) .hm__sw { background: hsl(calc(var(--base) + var(--o1)) 68% 70%); }
.hm__chip:nth-child(2) .hm__sw { background: hsl(calc(var(--base) + var(--o2)) 68% 58%); }
.hm__chip:nth-child(3) .hm__sw { background: hsl(calc(var(--base) + var(--o3)) 68% 48%); }
.hm__chip:nth-child(4) .hm__sw { background: hsl(calc(var(--base) + var(--o4)) 68% 58%); }
.hm__chip:nth-child(5) .hm__sw { background: hsl(calc(var(--base) + var(--o5)) 68% 70%); }
.hm__chip:nth-child(1) .hm__deg { counter-set: cd calc(var(--o1)); }
.hm__chip:nth-child(2) .hm__deg { counter-set: cd calc(var(--o2)); }
.hm__chip:nth-child(3) .hm__deg { counter-set: cd calc(var(--o3)); }
.hm__chip:nth-child(4) .hm__deg { counter-set: cd calc(var(--o4)); }
.hm__chip:nth-child(5) .hm__deg { counter-set: cd calc(var(--o5)); }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
// 각도 다섯 개와 이름 셋을 같은 퍼센트(0 · 33 · 66 · 90)에서 한 프레임에 컷한다.
// 크로스페이드를 쓰면 그 중간 프레임이 반투명 유령으로 poster 에 뽑힌다
.hm.is-demo {
animation-name: hmSet;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__name--1 { animation-name: hmName1; }
.hm.is-demo .hm__name--2 { animation-name: hmName2; }
.hm.is-demo .hm__name--3 { animation-name: hmName3; }
.hm.is-demo .hm__name {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 눌린 탭도 같은 순간에 옮겨 붙는다 — 루프가 보여주는 배색과 눌린 탭이 어긋나면 화면이 거짓말을 한다.
// 애니메이션은 [aria-pressed] 정적 규칙을 이기고, 루프를 떼면 다시 정적 규칙이 보인다
.hm.is-demo .hm__tab:nth-child(1) { animation-name: hmTab1; }
.hm.is-demo .hm__tab:nth-child(2) { animation-name: hmTab2; }
.hm.is-demo .hm__tab:nth-child(3) { animation-name: hmTab3; }
.hm.is-demo .hm__tab {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 칩 팝 — 컷 직후 다섯 개가 차례로(0·40·80·120·160ms) 튀어올라 컷 사이 프레임을 채운다.
// 지연이 걸린 루프라 animation-fill-mode: backwards 가 없으면 0프레임에 작성 상태가 한 번 번쩍인다
.hm.is-demo .hm__chip {
animation-name: hmPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__chip:nth-child(2) { animation-delay: 40ms; }
.hm.is-demo .hm__chip:nth-child(3) { animation-delay: 80ms; }
.hm.is-demo .hm__chip:nth-child(4) { animation-delay: 120ms; }
.hm.is-demo .hm__chip:nth-child(5) { animation-delay: 160ms; }
// 보색(0 0 0 180 180) → 유사색(-60 -30 0 30 60) → 삼각(-120 -120 0 120 120) → 90% 에 보색으로 복귀.
// 24프레임의 마지막 장이 95.8% 지점이라 복귀는 90% 안에 끝내야 촬영된다
@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; }
}
@keyframes hmName1 {
0% { opacity: 1; }
33% { opacity: 0; }
90%, 100% { opacity: 1; }
}
@keyframes hmName2 {
0% { opacity: 0; }
33% { opacity: 1; }
66% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes hmName3 {
0% { opacity: 0; }
66% { opacity: 1; }
90% { opacity: 0; }
100% { opacity: 0; }
}
// 탭 셋의 눌림 상태도 같은 퍼센트에서 컷한다. 세 벌을 한 묶음으로 적어 두면 값이 갈릴 일이 없다
@keyframes hmTab1 {
0% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
33% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
90%, 100% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
}
@keyframes hmTab2 {
0% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
33% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
66% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
100% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
}
@keyframes hmTab3 {
0% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
66% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
90% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
100% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
}
// 칩은 자기 중심에서 커진다 — transform-origin 기본값(50% 50%) 그대로 둔다
@keyframes hmPop {
0% { transform: scale(.86); }
7% { transform: scale(1.06); }
13%, 31% { transform: scale(1); }
33% { transform: scale(.86); }
40% { transform: scale(1.06); }
46%, 64% { transform: scale(1); }
66% { transform: scale(.86); }
73% { transform: scale(1.06); }
79%, 88% { transform: scale(1); }
90% { transform: scale(.86); }
96% { transform: scale(1.04); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo,
.hm.is-demo .hm__name,
.hm.is-demo .hm__tab,
.hm.is-demo .hm__chip {
animation: none !important;
}
.hm.is-demo .hm__name { opacity: 0 !important; }
.hm.is-demo .hm__name.is-on { opacity: 1 !important; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --base {
syntax: "<integer>";
inherits: true;
initial-value: 262;
}
@property --o1 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --o2 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --o3 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --o4 {
syntax: "<integer>";
inherits: true;
initial-value: 180;
}
@property --o5 {
syntax: "<integer>";
inherits: true;
initial-value: 180;
}
.hm {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.hm * {
box-sizing: border-box;
}
.hm__top {
display: flex;
align-items: center;
gap: 8px;
}
.hm__base {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: ew-resize;
background: hsl(var(--base), 68%, 52%);
}
.hm__base:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.hm__basev {
counter-set: cbs calc(var(--base));
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__basev::after {
content: counter(cbs) "°";
}
.hm__names {
margin-left: auto;
display: grid;
justify-items: end;
}
.hm__name {
grid-area: 1/1;
opacity: 0;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.hm__name.is-on {
opacity: 1;
}
.hm__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 8px;
}
.hm__tab {
width: 100%;
padding: 4px 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__tab[aria-pressed=true] {
border-color: #17141a;
background: #17141a;
color: #fff7e6;
}
.hm__tab:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.hm__chips {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.hm__chip {
display: grid;
gap: 4px;
justify-items: center;
}
.hm__sw {
width: 100%;
height: 28px;
border-radius: 4px;
}
.hm__deg {
font-size: 10px;
font-weight: 600;
line-height: 1.2;
color: rgba(23, 20, 26, 0.7);
}
.hm__deg::after {
content: counter(cd) "°";
}
.hm__chip:nth-child(1) .hm__sw {
background: hsl(calc(var(--base) + var(--o1)), 68%, 70%);
}
.hm__chip:nth-child(2) .hm__sw {
background: hsl(calc(var(--base) + var(--o2)), 68%, 58%);
}
.hm__chip:nth-child(3) .hm__sw {
background: hsl(calc(var(--base) + var(--o3)), 68%, 48%);
}
.hm__chip:nth-child(4) .hm__sw {
background: hsl(calc(var(--base) + var(--o4)), 68%, 58%);
}
.hm__chip:nth-child(5) .hm__sw {
background: hsl(calc(var(--base) + var(--o5)), 68%, 70%);
}
.hm__chip:nth-child(1) .hm__deg {
counter-set: cd calc(var(--o1));
}
.hm__chip:nth-child(2) .hm__deg {
counter-set: cd calc(var(--o2));
}
.hm__chip:nth-child(3) .hm__deg {
counter-set: cd calc(var(--o3));
}
.hm__chip:nth-child(4) .hm__deg {
counter-set: cd calc(var(--o4));
}
.hm__chip:nth-child(5) .hm__deg {
counter-set: cd calc(var(--o5));
}
.hm.is-demo {
animation-name: hmSet;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__name--1 {
animation-name: hmName1;
}
.hm.is-demo .hm__name--2 {
animation-name: hmName2;
}
.hm.is-demo .hm__name--3 {
animation-name: hmName3;
}
.hm.is-demo .hm__name {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__tab:nth-child(1) {
animation-name: hmTab1;
}
.hm.is-demo .hm__tab:nth-child(2) {
animation-name: hmTab2;
}
.hm.is-demo .hm__tab:nth-child(3) {
animation-name: hmTab3;
}
.hm.is-demo .hm__tab {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__chip {
animation-name: hmPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__chip:nth-child(2) {
animation-delay: 40ms;
}
.hm.is-demo .hm__chip:nth-child(3) {
animation-delay: 80ms;
}
.hm.is-demo .hm__chip:nth-child(4) {
animation-delay: 120ms;
}
.hm.is-demo .hm__chip:nth-child(5) {
animation-delay: 160ms;
}
@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;
}
}
@keyframes hmName1 {
0% {
opacity: 1;
}
33% {
opacity: 0;
}
90%, 100% {
opacity: 1;
}
}
@keyframes hmName2 {
0% {
opacity: 0;
}
33% {
opacity: 1;
}
66% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes hmName3 {
0% {
opacity: 0;
}
66% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes hmTab1 {
0% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
33% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
90%, 100% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
}
@keyframes hmTab2 {
0% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
33% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
66% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
100% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
}
@keyframes hmTab3 {
0% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
66% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
90% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
100% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
}
@keyframes hmPop {
0% {
transform: scale(0.86);
}
7% {
transform: scale(1.06);
}
13%, 31% {
transform: scale(1);
}
33% {
transform: scale(0.86);
}
40% {
transform: scale(1.06);
}
46%, 64% {
transform: scale(1);
}
66% {
transform: scale(0.86);
}
73% {
transform: scale(1.06);
}
79%, 88% {
transform: scale(1);
}
90% {
transform: scale(0.86);
}
96% {
transform: scale(1.04);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo,
.hm.is-demo .hm__name,
.hm.is-demo .hm__tab,
.hm.is-demo .hm__chip {
animation: none !important;
}
.hm.is-demo .hm__name {
opacity: 0 !important;
}
.hm.is-demo .hm__name.is-on {
opacity: 1 !important;
}
}(function () {
var root = document.querySelector('.hm');
var base = document.getElementById('hmBase');
var tabs = [].slice.call(root.querySelectorAll('.hm__tab'));
var names = [].slice.call(root.querySelectorAll('.hm__name'));
var baseFormat = '기준 색상값 %h도';
// 배색 = 기준 색상값에 더할 각도 다섯 개. 칩 셋째 칸은 언제나 0 이라 기준 색 그대로다
var sets = { comp: [0, 0, 0, 180, 180], ana: [-60, -30, 0, 30, 60], tri: [-120, -120, 0, 120, 120] };
var hue = 262;
var current = 'comp';
// 글자와 색을 따로 쓰지 않는다 — 각도 다섯 개만 넘기면 hsl() 이 칩 색을, 계수기가 각도 글자를 찍는다
function apply() {
var offsets = sets[current];
for (var i = 0; i < offsets.length; i++) {
root.style.setProperty('--o' + (i + 1), offsets[i]);
}
root.style.setProperty('--base', hue);
base.setAttribute('aria-valuenow', hue);
base.setAttribute('aria-valuetext', baseFormat.replace('%h', hue));
tabs.forEach(function (btn, i) {
var on = btn.getAttribute('data-set') === current;
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
names[i].classList.toggle('is-on', on);
});
}
tabs.forEach(function (btn) {
btn.addEventListener('click', function () {
current = btn.getAttribute('data-set');
root.classList.remove('is-demo');
apply();
});
});
base.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 15 : 3;
if (e.key === 'ArrowRight') { hue = (hue + step) % 360; }
else if (e.key === 'ArrowLeft') { hue = (hue - step + 360) % 360; }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
apply();
});
apply();
})();// HarmonyGenerator — vanilla 판(run/286/demo/06-color-picker-harmony-generator) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 기준 색상값 하나와 고른 배색 이름 하나뿐이다. 칩 색과 각도 글자는 JS 가 쓰지 않는다 —
// "기준에 더할 각도" 다섯 개(--o1 ~ --o5)만 넘기면 hsl() 이 색을, counter-set 이 각도 숫자를 찍는다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HarmonyGenerator.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
/** 배색 = 기준 색상값에 더할 각도 다섯 개. 셋째 칸은 언제나 0 이라 기준 색 그대로다 */
type HarmonyName = "comp" | "ana" | "tri";
export type HarmonyGeneratorProps = {
baseLabel?: string;
/** "기준 색상값 %h도" 처럼 자리표시자 하나를 가진 틀 */
baseFormat?: string;
groupLabel?: string;
/** 탭 라벨 셋 — 보색·유사색·삼각 */
tabLabels?: string[];
/** 배색 이름 셋 — 탭보다 긴 형태 */
setLabels?: string[];
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--base"?: number;
"--o1"?: number;
"--o2"?: number;
"--o3"?: number;
"--o4"?: number;
"--o5"?: number;
};
const SETS: Record<HarmonyName, number[]> = {
comp: [0, 0, 0, 180, 180],
ana: [-60, -30, 0, 30, 60],
tri: [-120, -120, 0, 120, 120],
};
const ORDER: HarmonyName[] = ["comp", "ana", "tri"];
export default function HarmonyGenerator({
baseLabel = "기준 색상값",
baseFormat = "기준 색상값 %h도",
groupLabel = "배색 고르기",
tabLabels = ["보색", "유사색", "삼각"],
setLabels = ["보색 배색", "유사색 배색", "삼각 배색"],
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: HarmonyGeneratorProps) {
const [live, setLive] = useState(demo);
const [hue, setHue] = useState(262);
const [current, setCurrent] = useState<HarmonyName>("comp");
const offsets = SETS[current];
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--base": hue,
"--o1": offsets[0],
"--o2": offsets[1],
"--o3": offsets[2],
"--o4": offsets[3],
"--o5": offsets[4],
};
function pick(name: HarmonyName) {
setLive(false);
setCurrent(name);
}
function keyBase(e: KeyEvt) {
const step = e.shiftKey ? 15 : 3;
if (e.key === "ArrowRight") setHue((hue + step) % 360);
else if (e.key === "ArrowLeft") setHue((hue - step + 360) % 360);
else return;
e.preventDefault();
setLive(false);
}
const baseText = baseFormat.replace("%h", String(hue));
return (
<div className={[styles.hm, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div className={styles.top}>
<div
className={styles.base}
role="slider"
tabIndex={0}
aria-label={baseLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={hue}
aria-valuetext={baseText}
onKeyDown={keyBase}
/>
<span className={styles.basev} aria-hidden="true" />
<span className={styles.names} aria-hidden="true">
{setLabels.map((label, i) => (
<b
key={label}
className={[styles.name, styles[`name${i + 1}`], ORDER[i] === current ? styles.isOn : ""]
.filter(Boolean)
.join(" ")}
>
{label}
</b>
))}
</span>
</div>
<div className={styles.tabs} role="group" aria-label={groupLabel}>
{tabLabels.map((label, i) => (
<button
key={label}
type="button"
className={styles.tab}
aria-pressed={ORDER[i] === current}
onClick={() => pick(ORDER[i])}
>
{label}
</button>
))}
</div>
<ul className={styles.chips}>
{[0, 1, 2, 3, 4].map((i) => (
<li key={i} className={styles.chip}>
<span className={styles.sw} />
<span className={styles.deg} />
</li>
))}
</ul>
</div>
);
}06 색상값을 돌려 만드는 보색·유사색·삼각 배색 — 측정 누적면적 9.759% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 컬러 피커 직접 만들기 CSS+JS 9종 — 라이브러리 없이
이 데모가 속한 글
컬러 피커 직접 만들기 CSS+JS 9종 — 라이브러리 없이컬러 피커는 화면에서 색을 골라 HEX 코드로 돌려주는 선택 창입니다. 이 페이지에는 그 부품 아홉 벌이 라이브러리 없이 색 공간 계산까지 직접 짠 코드로 돌아가고 있고, 코드는 통째로 복사해 쓰면 됩니다. 채도·명 이 글의 데모 전부 보기


