07컬러 스와치 격자 선택
목록 대신 색 동그라미 격자가 열리고 고른 색 위에만 체크가 그려지며, 버튼의 미리보기 원이 그 색으로 바뀝니다.
쓰임상품 색상·라벨 색·캘린더 색처럼 이름보다 색 자체를 보고 골라야 하는 값을 고르게 할 때
gridrole=listboxsteps(1, end)

<main class="stage">
<div class="cs is-demo is-open">
<span class="cs__label" id="cs-label">라벨 색</span>
<button class="cs__btn" type="button" id="cs-btn" aria-haspopup="listbox" aria-expanded="true" aria-controls="cs-grid" aria-labelledby="cs-label cs-name">
<span class="cs__preview">
<span class="cs__dot cs__slot cs__slot--a" style="background:#d09faa"></span>
<span class="cs__dot cs__slot cs__slot--b" aria-hidden="true" style="background:#c0bbdd"></span>
</span>
<span class="cs__name" id="cs-name">
<span class="cs__slot cs__slot--a">코랄</span>
<span class="cs__slot cs__slot--b" aria-hidden="true">라벤더</span>
</span>
<span class="cs__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<div class="cs__panel">
<span class="cs__ring" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="#ffffff"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<div class="cs__grid" id="cs-grid" role="listbox" aria-labelledby="cs-label" tabindex="-1">
<span class="cs__sw" role="option" aria-selected="true" aria-label="코랄" style="background:#d09faa"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="귤" style="background:#c7ae8c"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="레몬" style="background:#d7caad"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="민트" style="background:#62b29d"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="바다" style="background:#5393a8"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="라벤더" style="background:#c0bbdd"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="벚꽃" style="background:#e9d2da"></span>
<span class="cs__sw" role="option" aria-selected="false" aria-label="먹" style="background:#2b2d42"></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-orange);
.cs {
position: relative;
box-sizing: border-box;
width: min(180px, 84vw);
// 열린 패널은 absolute 라 높이에 안 잡힌다 — 그만큼 아래를 비워 둔다
padding-bottom: 96px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.cs__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: $subject-ink;
margin: 0 0 $sp-1;
}
.cs__btn {
box-sizing: border-box;
width: 100%; height: 32px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-3;
border: 0;
background: #fff;
border-radius: $r-control;
color: $stage-ink;
font-family: inherit;
font-size: 12px; font-weight: 700;
cursor: pointer;
box-shadow: $shadow-press;
}
.cs__preview, .cs__name { display: inline-grid; }
.cs__slot { grid-area: 1 / 1; }
.cs__slot--b { opacity: 0; }
.cs__dot {
display: block;
width: 16px; height: 16px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .16);
}
.cs__caret { margin-left: auto; display: inline-flex; color: rgba(23, 20, 26, .4); }
.cs__panel {
position: absolute;
top: 52px; left: 0;
box-sizing: border-box;
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: top left; // 규칙8: 버튼 좌상단에서 팝
transform: scale(.9);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.cs.is-open .cs__panel { transform: scale(1); opacity: 1; pointer-events: auto; }
.cs__grid {
display: grid;
grid-template-columns: repeat(4, 32px);
gap: $sp-3;
}
.cs__grid:focus-visible { outline: 2px solid $color; outline-offset: 4px; border-radius: $r-sm; }
.cs__sw {
width: 32px; height: 32px;
border-radius: 50%;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .12);
transition: transform $duration $easing;
}
.cs__sw:hover { transform: scale(1.12); }
.cs__ring {
position: absolute;
top: 17px; left: 17px;
z-index: 1;
display: inline-flex;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, .45));
transform: translate(0, 0);
transition: transform $duration $easing;
}
// ── 자동 루프: 체크가 첫 색에서 여섯 번째 색(2행 3열)으로 갔다가 그대로 되돌아온다 ──
.cs.is-demo .cs__ring { animation: cs-hop $dur-loop $easing infinite; }
// 미리보기 원과 색 이름은 겹쳐 놓은 두 겹이라 steps(1) 로 한 번에 갈아 끼운다 (서서히 겹치면 두 색이 동시에 비친다)
.cs.is-demo .cs__slot--a { animation: cs-swap-a $dur-loop steps(1, end) infinite; }
.cs.is-demo .cs__slot--b { animation: cs-swap-b $dur-loop steps(1, end) infinite; }
@keyframes cs-hop {
0%, 100% { transform: translate(0, 0); }
30%, 70% { transform: translate(88px, 44px); }
}
@keyframes cs-swap-a {
0%, 29% { opacity: 1; }
30%, 69% { opacity: 0; }
70%, 100% { opacity: 1; }
}
@keyframes cs-swap-b {
0%, 29% { opacity: 0; }
30%, 69% { opacity: 1; }
70%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__ring, .cs.is-demo .cs__slot--a, .cs.is-demo .cs__slot--b { animation: none !important; }
.cs__slot--b { opacity: 0; }
.cs__panel, .cs__ring, .cs__sw { transition: none; }
}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);
}
.cs {
position: relative;
box-sizing: border-box;
width: min(180px, 84vw);
padding-bottom: 96px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cs__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: #17141a;
margin: 0 0 4px;
}
.cs__btn {
box-sizing: border-box;
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
background: #fff;
border-radius: 12px;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cs__preview, .cs__name {
display: inline-grid;
}
.cs__slot {
grid-area: 1/1;
}
.cs__slot--b {
opacity: 0;
}
.cs__dot {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.cs__caret {
margin-left: auto;
display: inline-flex;
color: rgba(23, 20, 26, 0.4);
}
.cs__panel {
position: absolute;
top: 52px;
left: 0;
box-sizing: border-box;
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top left;
transform: scale(0.9);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cs.is-open .cs__panel {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.cs__grid {
display: grid;
grid-template-columns: repeat(4, 32px);
gap: 12px;
}
.cs__grid:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 8px;
}
.cs__sw {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cs__sw:hover {
transform: scale(1.12);
}
.cs__ring {
position: absolute;
top: 17px;
left: 17px;
z-index: 1;
display: inline-flex;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, 0.45));
transform: translate(0, 0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cs.is-demo .cs__ring {
animation: cs-hop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.cs.is-demo .cs__slot--a {
animation: cs-swap-a 2s steps(1, end) infinite;
}
.cs.is-demo .cs__slot--b {
animation: cs-swap-b 2s steps(1, end) infinite;
}
@keyframes cs-hop {
0%, 100% {
transform: translate(0, 0);
}
30%, 70% {
transform: translate(88px, 44px);
}
}
@keyframes cs-swap-a {
0%, 29% {
opacity: 1;
}
30%, 69% {
opacity: 0;
}
70%, 100% {
opacity: 1;
}
}
@keyframes cs-swap-b {
0%, 29% {
opacity: 0;
}
30%, 69% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__ring, .cs.is-demo .cs__slot--a, .cs.is-demo .cs__slot--b {
animation: none !important;
}
.cs__slot--b {
opacity: 0;
}
.cs__panel, .cs__ring, .cs__sw {
transition: none;
}
}// 실제 동작: 색을 고르면 체크가 그 색 위로 옮겨가고 버튼의 미리보기 원이 그 색이 된다.
var root = document.querySelector('.cs');
var btn = document.getElementById('cs-btn');
var grid = document.getElementById('cs-grid');
var ring = root.querySelector('.cs__ring');
var preview = root.querySelector('.cs__preview');
var name = document.getElementById('cs-name');
var sws = [].slice.call(grid.querySelectorAll('.cs__sw'));
var STEP = 44;
var at = 0;
function place(i) {
ring.style.transform = 'translate(' + ((i % 4) * STEP) + 'px,' + (Math.floor(i / 4) * STEP) + 'px)';
}
function pick(i) {
root.classList.remove('is-demo');
at = i;
sws.forEach(function (s, k) { s.setAttribute('aria-selected', String(k === i)); });
place(i);
preview.innerHTML = '<span class="cs__dot" style="background:' + sws[i].style.background + '"></span>';
name.textContent = sws[i].getAttribute('aria-label');
}
function open(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
if (on) grid.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { open(!root.classList.contains('is-open')); });
sws.forEach(function (s, i) { s.addEventListener('click', function () { pick(i); }); });
grid.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 4, ArrowUp: -4 }[e.key];
if (step) { pick(Math.max(0, Math.min(at + step, sws.length - 1))); e.preventDefault(); }
else if (e.key === 'Escape') { open(false); btn.focus({ preventScroll: true }); }
});
document.addEventListener('click', function (e) { if (!e.target.closest('.cs')) open(false); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColorSwatchSelect.module.scss";
export type Swatch = { name: string; hex: string };
export type ColorSwatchSelectProps = {
label?: string;
swatches?: Swatch[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { stopPropagation: () => void };
const COLS = 4;
const STEP = 44;
const DEFAULT_SWATCHES: Swatch[] = [
{ name: "Coral", hex: "#ef476f" },
{ name: "Tangerine", hex: "#ff9f1c" },
{ name: "Lemon", hex: "#ffc94d" },
{ name: "Mint", hex: "#06d6a0" },
{ name: "Ocean", hex: "#118ab2" },
{ name: "Lavender", hex: "#7b61ff" },
{ name: "Blossom", hex: "#f48fb1" },
{ name: "Ink", hex: "#2b2d42" },
];
const KEY_STEP: Record<string, number | undefined> = {
ArrowRight: 1,
ArrowLeft: -1,
ArrowDown: COLS,
ArrowUp: -COLS,
};
export default function ColorSwatchSelect({
label = "Label color",
swatches = DEFAULT_SWATCHES,
duration = 180,
easing = "cubic-bezier(.34,1.56,.64,1)",
color = "#2f6df6",
}: ColorSwatchSelectProps) {
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const current = swatches[picked];
const ringStyle: CSSProperties = {
transform: `translate(${(picked % COLS) * STEP}px, ${Math.floor(picked / COLS) * STEP}px)`,
};
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
useEffect(() => {
if (open) gridRef.current?.focus({ preventScroll: true });
}, [open]);
function pick(i: number) {
setPicked(Math.max(0, Math.min(i, swatches.length - 1)));
}
return (
<div className={`${styles.cs} ${open ? styles.isOpen : ""}`} style={vars} ref={rootRef}>
<span className={styles.label} id="cs-label">{label}</span>
<button
ref={btnRef}
type="button"
id="cs-btn"
className={styles.btn}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="cs-grid"
aria-labelledby="cs-label cs-name"
onClick={() => setOpen((o) => !o)}
>
<span className={styles.dot} style={{ background: current.hex }} />
<span className={styles.name} id="cs-name">{current.name}</span>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
</span>
</button>
<div className={styles.panel}>
<span className={styles.ring} style={ringStyle} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="#ffffff">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
</span>
<div
ref={gridRef}
id="cs-grid"
className={styles.grid}
role="listbox"
aria-labelledby="cs-label"
tabIndex={-1}
onKeyDown={(e: KeyEvt) => {
const step = KEY_STEP[e.key];
if (step) { pick(picked + step); e.preventDefault(); }
else if (e.key === "Escape") { setOpen(false); btnRef.current?.focus({ preventScroll: true }); }
}}
>
{swatches.map((s, i) => (
<span
key={s.hex}
className={styles.sw}
role="option"
aria-selected={i === picked}
aria-label={s.name}
style={{ background: s.hex }}
onClick={() => pick(i)}
/>
))}
</div>
</div>
</div>
);
}07 컬러 스와치 격자 선택 — 측정 누적면적 0.888% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운
이 데모가 속한 글
css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운css 셀렉트박스 디자인은 브라우저가 제 마음대로 그리는 기본 <select> 상자를 버리고, 버튼과 목록을 직접 만들어 여닫는 방식입니다. 아홉 개 중 하나는 목록을 아예 열지 않으며, 그 안의 품절 칸은 눌러도 이 글의 데모 전부 보기


