09최근 색상 — 색상 간 거리를 계산해 가까운 색을 합치는 목록형 컬러피커
최근 사용한 색상 목록 안에서 색상값 사이의 거리를 계산해, 그 거리가 가까운 색끼리는 하나로 합쳐 표시 개수를 줄이는 목록형 컬러피커입니다.
쓰임반복 작업용 최근 색상 바로가기, 문서 편집기 색상 이력
distMERGE_DISThslToRgb

<main class="stage">
<section class="panel">
<div class="grid" id="grid" role="list" aria-label="최근 사용한 색상 목록">
<span class="chip" data-i="0" role="img" aria-label="최근 색상 1"></span>
<span class="chip" data-i="1" role="img" aria-label="최근 색상 2"></span>
<span class="chip" data-i="2" role="img" aria-label="최근 색상 3"></span>
<span class="chip" data-i="3" role="img" aria-label="최근 색상 4"></span>
<span class="chip" data-i="4" role="img" aria-label="최근 색상 5"></span>
<span class="chip" data-i="5" role="img" aria-label="최근 색상 6"></span>
<span class="chip" data-i="6" role="img" aria-label="최근 색상 7"></span>
<span class="chip" data-i="7" role="img" aria-label="최근 색상 8"></span>
<span class="chip" data-i="8" role="img" aria-label="최근 색상 9"></span>
<span class="chip" data-i="9" role="img" aria-label="최근 색상 10"></span>
</div>
<p class="count" id="count">10개 → 10개</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.grid {
position: relative;
width: 252px;
height: 96px;
}
.chip {
position: absolute;
top: 0;
left: 0;
width: 44px;
height: 44px;
border-radius: $r-sm;
box-shadow: $shadow-press;
will-change: transform, opacity;
}
.count {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.grid {
position: relative;
width: 252px;
height: 96px;
}
.chip {
position: absolute;
top: 0;
left: 0;
width: 44px;
height: 44px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
will-change: transform, opacity;
}
.count {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}// 최근 색상(09) — 목록 안 색상값 사이의 실제 거리를 계산해, 그 거리가 MERGE_DIST 보다
// 가까운 색끼리는 대표 하나로 합쳐 표시 개수를 줄인다. 실키·실서버 요청 없음.
(function () {
var gridEl = document.getElementById('grid');
var countEl = document.getElementById('count');
var chipEls = gridEl ? gridEl.querySelectorAll('.chip') : [];
if (!gridEl || !countEl || chipEls.length !== 10) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MERGE_DIST = 30;
var CELL = 44;
var GAP = 8;
var STEP = CELL + GAP;
var COLS = 5;
var CYCLE_MS = 2800;
var RAW = [
{ h: 8, s: 70, l: 55 },
{ h: 12, s: 68, l: 57 },
{ h: 45, s: 80, l: 60 },
{ h: 200, s: 60, l: 50 },
{ h: 205, s: 58, l: 52 },
{ h: 140, s: 50, l: 40 },
{ h: 280, s: 45, l: 65 },
{ h: 283, s: 47, l: 63 },
{ h: 20, s: 65, l: 58 },
{ h: 320, s: 55, l: 50 }
];
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function dist(a, b) {
return Math.sqrt(
Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)
);
}
var RGB = RAW.map(function (c) { return hslToRgb(c.h, c.s, c.l); });
var kept = [];
var groupOf = new Array(RAW.length);
for (var i = 0; i < RAW.length; i++) {
var found = -1;
for (var j = 0; j < kept.length; j++) {
if (dist(RGB[i], RGB[kept[j]]) < MERGE_DIST) { found = kept[j]; break; }
}
if (found === -1) {
kept.push(i);
groupOf[i] = i;
} else {
groupOf[i] = found;
}
}
function centerOf(i) {
var col = i % COLS;
var row = Math.floor(i / COLS);
return { x: col * STEP, y: row * STEP };
}
RAW.forEach(function (c, i) {
var rgb = RGB[i];
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
chipEls[i].style.background = hex;
});
countEl.textContent = RAW.length + '개 → ' + kept.length + '개';
function apply(t) {
var p = (t / CYCLE_MS) % 1;
var mergeAmount = p < .5 ? p * 2 : (1 - p) * 2;
for (var i = 0; i < RAW.length; i++) {
var own = centerOf(i);
if (groupOf[i] === i) {
var pulse = 1 + .3 * mergeAmount;
var tilt = mergeAmount * 10;
chipEls[i].style.transform = 'translate(' + own.x.toFixed(2) + 'px, ' + own.y.toFixed(2) + 'px) scale(' + pulse.toFixed(3) + ') rotate(' + tilt.toFixed(2) + 'deg)';
chipEls[i].style.opacity = '1';
} else {
var target = centerOf(groupOf[i]);
var x = own.x + (target.x - own.x) * mergeAmount;
var y = own.y + (target.y - own.y) * mergeAmount;
var scale = 1 - .95 * mergeAmount;
var opacity = 1 - .9 * mergeAmount;
chipEls[i].style.transform = 'translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
chipEls[i].style.opacity = opacity.toFixed(3);
}
}
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerRecentColors.module.scss";
// 최근 색상(09) — 목록 안 색상값 사이의 실제 거리를 계산해, 그 거리가 MERGE_DIST 보다
// 가까운 색끼리는 대표 하나로 합쳐 표시 개수를 줄인다. 실키·실서버 요청 없음.
const MERGE_DIST = 30;
const CELL = 44;
const GAP = 8;
const STEP = CELL + GAP;
const COLS = 5;
const CYCLE_MS = 2800;
const RAW = [
{ h: 8, s: 70, l: 55 },
{ h: 12, s: 68, l: 57 },
{ h: 45, s: 80, l: 60 },
{ h: 200, s: 60, l: 50 },
{ h: 205, s: 58, l: 52 },
{ h: 140, s: 50, l: 40 },
{ h: 280, s: 45, l: 65 },
{ h: 283, s: 47, l: 63 },
{ h: 20, s: 65, l: 58 },
{ h: 320, s: 55, l: 50 },
];
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100; l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
function dist(a: number[], b: number[]) {
return Math.sqrt(
Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)
);
}
const RECENT_LABELS = [
"최근 색상 1", "최근 색상 2", "최근 색상 3", "최근 색상 4", "최근 색상 5",
"최근 색상 6", "최근 색상 7", "최근 색상 8", "최근 색상 9", "최근 색상 10",
];
const RGB = RAW.map((c) => hslToRgb(c.h, c.s, c.l));
const kept: number[] = [];
const groupOf: number[] = new Array(RAW.length);
for (let i = 0; i < RAW.length; i++) {
let found = -1;
for (let j = 0; j < kept.length; j++) {
if (dist(RGB[i], RGB[kept[j]]) < MERGE_DIST) { found = kept[j]; break; }
}
if (found === -1) {
kept.push(i);
groupOf[i] = i;
} else {
groupOf[i] = found;
}
}
function centerOf(i: number) {
const col = i % COLS;
const row = Math.floor(i / COLS);
return { x: col * STEP, y: row * STEP };
}
export default function ColorPickerRecentColors() {
const chipRef0 = useRef<HTMLSpanElement>(null);
const chipRef1 = useRef<HTMLSpanElement>(null);
const chipRef2 = useRef<HTMLSpanElement>(null);
const chipRef3 = useRef<HTMLSpanElement>(null);
const chipRef4 = useRef<HTMLSpanElement>(null);
const chipRef5 = useRef<HTMLSpanElement>(null);
const chipRef6 = useRef<HTMLSpanElement>(null);
const chipRef7 = useRef<HTMLSpanElement>(null);
const chipRef8 = useRef<HTMLSpanElement>(null);
const chipRef9 = useRef<HTMLSpanElement>(null);
const countRef = useRef<HTMLParagraphElement>(null);
const chipRefs = [chipRef0, chipRef1, chipRef2, chipRef3, chipRef4, chipRef5, chipRef6, chipRef7, chipRef8, chipRef9];
useEffect(() => {
const chips = chipRefs.map((r) => r.current);
const countEl = countRef.current;
if (chips.some((c) => !c) || !countEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
RAW.forEach((_c, i) => {
const rgb = RGB[i];
const hex = "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
chips[i]!.style.background = hex;
});
countEl.textContent = `${RAW.length}개 → ${kept.length}개`;
function apply(t: number) {
const p = (t / CYCLE_MS) % 1;
const mergeAmount = p < .5 ? p * 2 : (1 - p) * 2;
for (let i = 0; i < RAW.length; i++) {
const own = centerOf(i);
if (groupOf[i] === i) {
const pulse = 1 + .3 * mergeAmount;
const tilt = mergeAmount * 10;
chips[i]!.style.transform = `translate(${own.x.toFixed(2)}px, ${own.y.toFixed(2)}px) scale(${pulse.toFixed(3)}) rotate(${tilt.toFixed(2)}deg)`;
chips[i]!.style.opacity = "1";
} else {
const target = centerOf(groupOf[i]);
const x = own.x + (target.x - own.x) * mergeAmount;
const y = own.y + (target.y - own.y) * mergeAmount;
const scale = 1 - .95 * mergeAmount;
const opacity = 1 - .9 * mergeAmount;
chips[i]!.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${scale.toFixed(3)})`;
chips[i]!.style.opacity = opacity.toFixed(3);
}
}
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.grid} role="list" aria-label="최근 사용한 색상 목록">
{chipRefs.map((r, i) => (
<span key={i} className={styles.chip} ref={r} role="img" aria-label={RECENT_LABELS[i]} />
))}
</div>
<p className={styles.count} ref={countRef}>10개 → 10개</p>
</section>
</div>
);
}09 최근 색상 — 색상 간 거리를 계산해 가까운 색을 합치는 목록형 컬러피커 — 측정 누적면적 13.808% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 컬러피커 UI 디자인 9종 — 휠부터 최근 색상까지
이 데모가 속한 글
컬러피커 UI 디자인 9종 — 휠부터 최근 색상까지선택 지점의 각도가 색상값으로 다시 계산되고, 스와치 밝기 값에 따라 체크 아이콘 색이 뒤바뀌고, 최근 색상 사이 거리가 계산되어 가까운 색끼리 합쳐지는 컬러피커 UI 9종입니다. 아홉 화면 전부 속성 값 바꾸기로 이 글의 데모 전부 보기


