07Badge Difficulty Selector Whose Color Comes From a Hue Formula
The five badges aren't five fixed colors — each color comes from feeding the tier number into a hue-angle formula, so adding a tier recalculates the spacing between all the colors.
Best forA place in an admin screen that tags a difficulty tier like a label
badgeHuebadgeColorpaint

<main class="stage">
<div class="bhs is-demo" id="bhs" role="radiogroup" aria-label="Difficulty">
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="0" tabindex="0">Easy</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="1" tabindex="-1">Normal</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="true" data-i="2" tabindex="-1">Hard</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="3" tabindex="-1">Very Hard</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="4" tabindex="-1">Extreme</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
// 계산 겹: badgeHue(i) 가 배지 색을 (기준 색상각 + 번호*간격) 수식으로 구한다(JS) — scss 는 배지 모양만
@include stage($stage-orange);
.bhs { display: flex; flex-wrap: wrap; gap: $sp-2; justify-content: center; max-width: 290px; }
.bhs__badge {
appearance: none; border: 2px solid transparent; border-radius: $r-pill;
padding: $sp-2 $sp-3; font-weight: 700; font-size: 12px; color: $color; cursor: pointer;
transition: transform $dur-quick $ease-pop, border-color $dur-quick $ease-out;
}
.bhs__badge:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.bhs__badge[aria-checked="true"] { border-color: $subject-ink; transform: scale(1.08); }
@media (max-width: 340px) {
.bhs { max-width: 250px; }
.bhs__badge { font-size: 11px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 실제로 배지를 골라도 확대·테두리는 트랜지션 없이 즉시 반영된다(색 자체는 JS 가 즉시 대입)
.bhs__badge { 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);
}
.bhs {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 290px;
}
.bhs__badge {
appearance: none;
border: 2px solid transparent;
border-radius: 999px;
padding: 8px 12px;
font-weight: 700;
font-size: 12px;
color: #17141a;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms ease-out;
}
.bhs__badge:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.bhs__badge[aria-checked=true] {
border-color: #17141a;
transform: scale(1.08);
}
@media (max-width: 340px) {
.bhs {
max-width: 250px;
}
.bhs__badge {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bhs__badge {
transition: none;
}
}(function () {
var LEVELS = ['Easy', 'Normal', 'Hard', 'Very Hard', 'Extreme'];
var BASE_HUE = 140, HUE_SPACING = 46, SAT = 45, LIGHT = 72;
var root = document.getElementById('bhs');
var badges = Array.prototype.slice.call(root.querySelectorAll('.bhs__badge'));
var selected = 2;
// 계산 겹: 배지 색을 고정 다섯 색이 아니라 단계 번호를 색상각 수식(기준 색상 + 번호*간격)에 넣어 구한다
function badgeHue(i) { return (BASE_HUE + i * HUE_SPACING) % 360; }
function badgeColor(i) { return 'hsl(' + badgeHue(i) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paint() {
badges.forEach(function (b, i) {
b.style.background = badgeColor(i);
b.setAttribute('aria-checked', i === selected ? 'true' : 'false');
b.setAttribute('tabindex', i === selected ? '0' : '-1');
});
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function select(i) { selected = i; paint(); }
badges.forEach(function (b, i) {
b.addEventListener('click', function () { human(); select(i); });
b.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); human(); var n = Math.min(LEVELS.length - 1, selected + 1); select(n); badges[n].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); human(); var p = Math.max(0, selected - 1); select(p); badges[p].focus({ preventScroll: true }); }
});
});
var sequence = [4, 0, 3, 1, 2];
var si = 0;
function tick() { select(sequence[si % sequence.length]); si++; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 700);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorBadgeHueShift.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const BASE_HUE = 140, HUE_SPACING = 46, SAT = 45, LIGHT = 72;
const SEQUENCE = [4, 0, 3, 1, 2];
const TICK_MS = 700;
export default function DifficultySelectorBadgeHueShift() {
const rootRef = useRef<HTMLDivElement>(null);
const badgeRefs = useRef<HTMLButtonElement[]>([]);
useEffect(() => {
const root = rootRef.current;
if (!root || !badgeRefs.current) return undefined;
const badges = badgeRefs.current;
if (badges.length !== LEVELS.length) return undefined;
let selected = 2;
// 계산 겹: 배지 색을 고정 다섯 색이 아니라 단계 번호를 색상각 수식(기준 색상 + 번호*간격)에 넣어 구한다
function badgeHue(i: number) { return (BASE_HUE + i * HUE_SPACING) % 360; }
function badgeColor(i: number) { return 'hsl(' + badgeHue(i) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paint() {
badges.forEach((b, i) => {
b.style.background = badgeColor(i);
b.setAttribute('aria-checked', i === selected ? 'true' : 'false');
b.setAttribute('tabindex', i === selected ? '0' : '-1');
});
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function select(i: number) { selected = i; paint(); }
const offs = badges.map((b, i) => {
function onClick() { human(); select(i); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight') { e.preventDefault(); human(); const n = Math.min(LEVELS.length - 1, selected + 1); select(n); badges[n].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); human(); const p = Math.max(0, selected - 1); select(p); badges[p].focus({ preventScroll: true }); }
}
b.addEventListener('click', onClick);
b.addEventListener('keydown', onKey);
return () => { b.removeEventListener('click', onClick); b.removeEventListener('keydown', onKey); };
});
let si = 0;
function tick() { select(SEQUENCE[si % SEQUENCE.length]); si++; }
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offs.forEach((off) => off()); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.bhs + ' ' + styles['is-demo']} ref={rootRef} role="radiogroup" aria-label="난이도">
{LEVELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.bhsBadge}
ref={(el: HTMLButtonElement | null) => { if (el && badgeRefs.current) badgeRefs.current[i] = el; }}
role="radio"
aria-checked={i === 2 ? 'true' : 'false'}
data-i={i}
tabIndex={i === 0 ? 0 : -1}
>
{label}
</button>
))}
</div>
</main>
);
}07 Badge Difficulty Selector Whose Color Comes From a Hue Formula — Measured area 4.045% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · Difficulty Selector UI — 9 Computed Patterns
The post this demo lives in
Difficulty Selector UI — 9 Computed PatternsAmong nine difficulty selector ui patterns, a slider handle overshoots then springs back, badge colors come from a hue f See all demos in this post
If you like this effect - similar demos
Pickers06 Complementary, analogous and triadic sets from one hueCustom Color Picker: 9 JS Parts, No Library
Pickers08 Color-temperature track — interpolating hue and saturation from the value ratioCSS Range Slider: 9 Physics-Grade Custom Sliders
Pickers07 Value color shift — the star's color value changes in stages according to the size of the selected valueStar Rating UI: 9 Interactive Rating Patterns