04macOS crayon — a grid color picker that sets its border color from a brightness value
The brightness value of the currently selected crayon color is computed, and the selection ring switches automatically to a white or black border depending on whether that value is low or high.
Best forRetro-style color palettes, casual app theme pickers
role=radiogroupLUM_THRESHOLDcenterOf

<main class="stage">
<section class="panel">
<div class="grid" id="grid" role="radiogroup" aria-label="Crayon palette">
<span class="crayon" data-i="0" role="radio" aria-checked="false" aria-label="Crayon 1"></span>
<span class="crayon" data-i="1" role="radio" aria-checked="false" aria-label="Crayon 2"></span>
<span class="crayon" data-i="2" role="radio" aria-checked="false" aria-label="Crayon 3"></span>
<span class="crayon" data-i="3" role="radio" aria-checked="false" aria-label="Crayon 4"></span>
<span class="crayon" data-i="4" role="radio" aria-checked="false" aria-label="Crayon 5"></span>
<span class="crayon" data-i="5" role="radio" aria-checked="false" aria-label="Crayon 6"></span>
<span class="crayon" data-i="6" role="radio" aria-checked="false" aria-label="Crayon 7"></span>
<span class="crayon" data-i="7" role="radio" aria-checked="false" aria-label="Crayon 8"></span>
<span class="crayon" data-i="8" role="radio" aria-checked="false" aria-label="Crayon 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 crayon — a grid color picker that sets its border color from a brightness value — Measured area 14.251% · 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
Inputs02 File-size Badge That Picks Its Own Unit From a Byte ValueCSS Calculator Design: 9 Inputs, Unit Converter UI
Inputs03 Limit bounce — a stepper value that overshoots its min or max slightly before settling along a spring curveNumber Stepper UI: 9 Interaction Patterns
Inputs09 Continuous color-value morph — the strength value changes continuously instead of jumping between stages, with its mapped color value shifting smoothly alongPassword Strength Meter UI: 9 Calculation Patterns