04macOS 크레용 — 밝기 값으로 테두리 색을 정하는 격자형 컬러피커
선택된 크레용 색의 밝기 값을 계산해, 그 값이 낮으면 흰색 테두리로 높으면 검은색 테두리로 선택 표시가 자동으로 바뀌는 격자형 컬러피커입니다.
쓰임레트로 스타일 색상 선택 팔레트, 캐주얼 앱 테마 선택
role=radiogroupLUM_THRESHOLDcenterOf

<main class="stage">
<section class="panel">
<div class="grid" id="grid" role="radiogroup" aria-label="크레용 팔레트">
<span class="crayon" data-i="0" role="radio" aria-checked="false" aria-label="크레용 1"></span>
<span class="crayon" data-i="1" role="radio" aria-checked="false" aria-label="크레용 2"></span>
<span class="crayon" data-i="2" role="radio" aria-checked="false" aria-label="크레용 3"></span>
<span class="crayon" data-i="3" role="radio" aria-checked="false" aria-label="크레용 4"></span>
<span class="crayon" data-i="4" role="radio" aria-checked="false" aria-label="크레용 5"></span>
<span class="crayon" data-i="5" role="radio" aria-checked="false" aria-label="크레용 6"></span>
<span class="crayon" data-i="6" role="radio" aria-checked="false" aria-label="크레용 7"></span>
<span class="crayon" data-i="7" role="radio" aria-checked="false" aria-label="크레용 8"></span>
<span class="crayon" data-i="8" role="radio" aria-checked="false" aria-label="크레용 9"></span>
<div class="ring" id="ring"></div>
</div>
<div class="readout" id="readout">
<span class="swatch" id="swatch"></span>
<span class="hex" id="hex">#000000</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: repeat(3, 40px);
gap: $sp-3;
}
.crayon {
width: 40px;
height: 40px;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.ring {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
border-radius: $r-pill;
border: 3px solid $subject-cream;
box-shadow: $shadow-raised;
pointer-events: none;
&.is-black-ring {
border-color: $subject-ink;
}
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: $shadow-press;
}
.swatch {
width: 16px;
height: 16px;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.hex {
color: $subject-ink;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.grid {
grid-template-columns: repeat(3, 32px);
grid-template-rows: repeat(3, 32px);
gap: $sp-2;
}
.crayon { width: 32px; height: 32px; }
.ring { width: 32px; height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.ring { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
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;
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: repeat(3, 40px);
gap: 12px;
}
.crayon {
width: 40px;
height: 40px;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ring {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
}
.ring.is-black-ring {
border-color: #17141a;
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.swatch {
width: 16px;
height: 16px;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hex {
color: #17141a;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.grid {
grid-template-columns: repeat(3, 32px);
grid-template-rows: repeat(3, 32px);
gap: 8px;
}
.crayon {
width: 32px;
height: 32px;
}
.ring {
width: 32px;
height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ring {
transition: none;
}
}// macOS 크레용(04) — 선택된 크레용 색의 밝기 값을 계산해 그 값이 낮으면 흰색 테두리,
// 높으면 검은색 테두리로 선택 표시가 자동으로 바뀐다. 실키·실서버 요청 없음.
(function () {
var gridEl = document.getElementById('grid');
var ringEl = document.getElementById('ring');
var readoutEl = document.getElementById('readout');
var swatchEl = document.getElementById('swatch');
var hexEl = document.getElementById('hex');
var crayonEls = gridEl ? gridEl.querySelectorAll('.crayon') : [];
if (!gridEl || !ringEl || !readoutEl || !swatchEl || !hexEl || crayonEls.length !== 9) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LUM_THRESHOLD = .6;
var CELL = 40;
var GAP = 12;
var STEP = CELL + GAP;
var CYCLE_MS = 2700;
var CRAYONS = [
{ h: 0, s: 70, l: 50 },
{ h: 40, s: 80, l: 60 },
{ h: 80, s: 60, l: 45 },
{ h: 120, s: 50, l: 35 },
{ h: 160, s: 55, l: 70 },
{ h: 200, s: 65, l: 50 },
{ h: 240, s: 60, l: 35 },
{ h: 280, s: 50, l: 65 },
{ h: 320, s: 70, l: 55 }
];
var ORDER = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0];
var SEG_MS = CYCLE_MS / (ORDER.length - 1);
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 centerOf(i) {
var col = i % 3;
var row = Math.floor(i / 3);
return { x: col * STEP, y: row * STEP };
}
CRAYONS.forEach(function (c, i) {
var rgb = hslToRgb(c.h, c.s, c.l);
crayonEls[i].style.background = 'hsl(' + c.h + 'deg ' + c.s + '% ' + c.l + '%)';
crayonEls[i].dataset.hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
});
function apply(t) {
var idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = centerOf(ORDER[idx]);
var b = centerOf(ORDER[idx + 1]);
var x = a.x + (b.x - a.x) * frac;
var y = a.y + (b.y - a.y) * frac;
ringEl.style.transform = 'translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px)';
var selIndex = ORDER[frac < .5 ? idx : idx + 1];
var c = CRAYONS[selIndex];
var rgb = hslToRgb(c.h, c.s, c.l);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 / 255;
var white = brightness < LUM_THRESHOLD;
ringEl.classList.toggle('is-white-ring', white);
ringEl.classList.toggle('is-black-ring', !white);
var hex = crayonEls[selIndex].dataset.hex;
swatchEl.style.background = hex;
hexEl.textContent = hex;
for (var i = 0; i < crayonEls.length; i++) {
crayonEls[i].setAttribute('aria-checked', i === selIndex ? 'true' : 'false');
}
}
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 "./ColorPickerMacosCrayon.module.scss";
// macOS 크레용 — 선택된 크레용 색의 밝기 값을 계산해 그 값이 낮으면 흰색 테두리,
// 높으면 검은색 테두리로 선택 표시가 자동으로 바뀐다. 실키·실서버 요청 없음.
const LUM_THRESHOLD = .6;
const CELL = 40;
const GAP = 12;
const STEP = CELL + GAP;
const CYCLE_MS = 2700;
const CRAYONS = [
{ h: 0, s: 70, l: 50 },
{ h: 40, s: 80, l: 60 },
{ h: 80, s: 60, l: 45 },
{ h: 120, s: 50, l: 35 },
{ h: 160, s: 55, l: 70 },
{ h: 200, s: 65, l: 50 },
{ h: 240, s: 60, l: 35 },
{ h: 280, s: 50, l: 65 },
{ h: 320, s: 70, l: 55 },
];
const ORDER = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0];
const SEG_MS = CYCLE_MS / (ORDER.length - 1);
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 centerOf(i: number) {
const col = i % 3;
const row = Math.floor(i / 3);
return { x: col * STEP, y: row * STEP };
}
const CRAYON_HEX = CRAYONS.map((c) => {
const rgb = hslToRgb(c.h, c.s, c.l);
return "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
});
export default function ColorPickerMacosCrayon() {
const gridRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const crayonRef0 = useRef<HTMLSpanElement>(null);
const crayonRef1 = useRef<HTMLSpanElement>(null);
const crayonRef2 = useRef<HTMLSpanElement>(null);
const crayonRef3 = useRef<HTMLSpanElement>(null);
const crayonRef4 = useRef<HTMLSpanElement>(null);
const crayonRef5 = useRef<HTMLSpanElement>(null);
const crayonRef6 = useRef<HTMLSpanElement>(null);
const crayonRef7 = useRef<HTMLSpanElement>(null);
const crayonRef8 = useRef<HTMLSpanElement>(null);
const swatchRef = useRef<HTMLSpanElement>(null);
const hexRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const grid = gridRef.current;
const ring = ringRef.current;
const swatch = swatchRef.current;
const hexEl = hexRef.current;
const crayons = [
crayonRef0.current, crayonRef1.current, crayonRef2.current,
crayonRef3.current, crayonRef4.current, crayonRef5.current,
crayonRef6.current, crayonRef7.current, crayonRef8.current,
];
if (!grid || !ring || !swatch || !hexEl || crayons.some((c) => !c)) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
const frac = (t % SEG_MS) / SEG_MS;
const a = centerOf(ORDER[idx]);
const b = centerOf(ORDER[idx + 1]);
const x = a.x + (b.x - a.x) * frac;
const y = a.y + (b.y - a.y) * frac;
ring!.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)`;
const selIndex = ORDER[frac < .5 ? idx : idx + 1];
const c = CRAYONS[selIndex];
const rgb = hslToRgb(c.h, c.s, c.l);
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 / 255;
const white = brightness < LUM_THRESHOLD;
ring!.classList.toggle(styles.isWhiteRing, white);
ring!.classList.toggle(styles.isBlackRing, !white);
const hex = CRAYON_HEX[selIndex];
swatch!.style.background = hex;
hexEl!.textContent = hex;
crayons.forEach((el, i) => el!.setAttribute("aria-checked", i === selIndex ? "true" : "false"));
}
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);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.grid} ref={gridRef} role="radiogroup" aria-label="크레용 팔레트">
{[crayonRef0, crayonRef1, crayonRef2, crayonRef3, crayonRef4, crayonRef5, crayonRef6, crayonRef7, crayonRef8].map((r, i) => (
<span
key={i}
className={styles.crayon}
ref={r}
role="radio"
aria-checked="false"
aria-label={`크레용 ${i + 1}`}
style={{ background: `hsl(${CRAYONS[i].h}deg ${CRAYONS[i].s}% ${CRAYONS[i].l}%)` }}
/>
))}
<div className={styles.ring} ref={ringRef} />
</div>
<div className={styles.readout}>
<span className={styles.swatch} ref={swatchRef} />
<span className={styles.hex} ref={hexRef}>#000000</span>
</div>
</section>
</div>
);
}04 macOS 크레용 — 밝기 값으로 테두리 색을 정하는 격자형 컬러피커 — 측정 누적면적 14.251% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 컬러피커 UI 디자인 9종 — 휠부터 최근 색상까지
이 데모가 속한 글
컬러피커 UI 디자인 9종 — 휠부터 최근 색상까지선택 지점의 각도가 색상값으로 다시 계산되고, 스와치 밝기 값에 따라 체크 아이콘 색이 뒤바뀌고, 최근 색상 사이 거리가 계산되어 가까운 색끼리 합쳐지는 컬러피커 UI 9종입니다. 아홉 화면 전부 속성 값 바꾸기로 이 글의 데모 전부 보기


