09Recent colors — a list picker that merges near-duplicate colors by computed distance
The distance between color values in the recent-colors list gets computed, and colors close enough by that distance are merged into one, shrinking how many chips show at once.
Best forRecent-color shortcuts for repeat tasks, document-editor color history
distMERGE_DISThslToRgb

<main class="stage">
<section class="panel">
<div class="grid" id="grid" role="list" aria-label="Recently used colors">
<span class="chip" data-i="0" role="img" aria-label="Recent color 1"></span>
<span class="chip" data-i="1" role="img" aria-label="Recent color 2"></span>
<span class="chip" data-i="2" role="img" aria-label="Recent color 3"></span>
<span class="chip" data-i="3" role="img" aria-label="Recent color 4"></span>
<span class="chip" data-i="4" role="img" aria-label="Recent color 5"></span>
<span class="chip" data-i="5" role="img" aria-label="Recent color 6"></span>
<span class="chip" data-i="6" role="img" aria-label="Recent color 7"></span>
<span class="chip" data-i="7" role="img" aria-label="Recent color 8"></span>
<span class="chip" data-i="8" role="img" aria-label="Recent color 9"></span>
<span class="chip" data-i="9" role="img" aria-label="Recent color 10"></span>
</div>
<p class="count" id="count">10 → 10 colors</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;
}
}// 최근 Hue(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 + ' colors';
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 Recent colors — a list picker that merges near-duplicate colors by computed distance — Measured area 13.808% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Color Picker Design Patterns (Copy-Paste)
The post this demo lives in
9 Color Picker Design Patterns (Copy-Paste)Nine color-picker UIs that compute: a point's angle turns into a hue, swatch brightness flips the check-icon color, and See all demos in this post
If you like this effect - similar demos
Inputs01 Recent Tab That Reorders by a Usage-Count ValueEmoji Picker: 9 Patterns by One Value
Inputs06 Character counter that turns warning near the limit9 Form Validation UI Patterns — As You Type
Inputs09 Reduced motion — killing shimmer and stagger for a list that appears instantlyAutocomplete Input: 9 Search Dropdowns