難易度選択 ui 9種 — 角度と数式で決まる選択まで
難易度選択 ui 9種のうち、1つは手を離すとスライダーのつまみが段階を少し行き過ぎてからばね計算で戻って止まり、1つは5つのバッジの色を固定値ではなく段階番号を入れた色相角の数式で計算し、1つは半円ゲージの針を引く指の角度を値に変換します。
01 difficulty-selector-dot-ramp
02 difficulty-selector-radio-card-grid
03 difficulty-selector-slider-snap-tier
04 difficulty-selector-bar-meter-fill
06 difficulty-selector-keyboard-arrow-lerp
07 difficulty-selector-badge-hue-shift
08 difficulty-selector-numeric-stepper-odometer
09 difficulty-selector-gauge-arc-drag
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 色が段階ごとに変わるドット式難易度セレクター
- 02 選ぶほど影が深くなるカード型難易度選択
- 03 段階で弾んで止まる難易度スライダー
- 04 光沢が走り抜ける棒型難易度ゲージ
- 05 段階ごとにアイコンの形が変形する難易度選択
- 06 フォーカスリングが慣性で追いかけるキーボード難易度選択
- 07 色が数式でつながるバッジ型難易度選択
- 08 数字が走行距離計のように回る難易度ステッパー
- 09 ドラッグ角度で値が決まる半円型難易度ゲージ
この記事では、ドット・カード・スライダー・バー・アイコン・キーボードフォーカス・バッジ・数字ステッパー・半円ゲージという9つの難易度選択画面パターンを、実際に動くデモとソースコードのまま並べます。見た目の違いだけでなく、それぞれがどんな計算式で「今どの段階か」を画面に反映しているかを見ていきます。
デモ

<main class="stage">
<div class="dr is-demo" id="dr">
<div class="dr__track" id="drTrack" role="radiogroup" aria-label="難易度">
<div class="dr__fill" id="drFill"></div>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="簡単" data-i="0" tabindex="0"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="普通" data-i="1" tabindex="-1"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="難しい" data-i="2" tabindex="-1"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="とても難しい" data-i="3" tabindex="-1"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="最高" data-i="4" tabindex="-1"></button>
</div>
<p class="dr__label"><span id="drName">普通</span> <span id="drCount">2 / 5</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
// 계산 겹: dotHue(i) 가 점 순서를 초록→빨강 색상각으로 바꾼다(JS). scss 는 배치·상태만 담당.
@include stage($stage-ink);
.dr { width: min(300px, 86vw); color: $subject-cream; text-align: center; }
.dr__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-8 $sp-2 $sp-4;
}
.dr__fill {
position: absolute; top: 0; bottom: 0; left: 0; width: 40%;
background: $color; border-radius: $r-pill; transition: width $duration $easing, background $duration $easing;
}
.dr__dot {
position: absolute; top: 50%; width: 20px; height: 20px;
appearance: none; border: 2px solid rgba($subject-cream, .5); border-radius: 50%;
background: transparent; padding: 0; cursor: pointer; transform: translate(-50%, -50%);
transition: background $duration $easing, transform $dur-quick $ease-pop, border-color $duration $easing;
}
.dr__dot:nth-of-type(2) { left: 0%; }
.dr__dot:nth-of-type(3) { left: 25%; }
.dr__dot:nth-of-type(4) { left: 50%; }
.dr__dot:nth-of-type(5) { left: 75%; }
.dr__dot:nth-of-type(6) { left: 100%; }
.dr__dot:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.dr__dot[aria-checked="true"] {
border-color: $subject-cream; transform: translate(-50%, -50%) scale(1.3);
}
.dr__label {
margin: $sp-3 0 0; font-weight: 700; font-size: 16px; line-height: 1.3;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dr { width: min(250px, 82vw); }
.dr__dot { width: 16px; height: 16px; }
.dr__label { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 실제로 눌러도 채움·점 색은 트랜지션 없이 즉시 바뀐다
.dr__fill, .dr__dot { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dr {
width: min(300px, 86vw);
color: #fff7e6;
text-align: center;
}
.dr__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 32px 8px 16px;
}
.dr__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: #4cd4a6;
border-radius: 999px;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dr__dot {
position: absolute;
top: 50%;
width: 20px;
height: 20px;
appearance: none;
border: 2px solid rgba(255, 247, 230, 0.5);
border-radius: 50%;
background: transparent;
padding: 0;
cursor: pointer;
transform: translate(-50%, -50%);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dr__dot:nth-of-type(2) {
left: 0%;
}
.dr__dot:nth-of-type(3) {
left: 25%;
}
.dr__dot:nth-of-type(4) {
left: 50%;
}
.dr__dot:nth-of-type(5) {
left: 75%;
}
.dr__dot:nth-of-type(6) {
left: 100%;
}
.dr__dot:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.dr__dot[aria-checked=true] {
border-color: #fff7e6;
transform: translate(-50%, -50%) scale(1.3);
}
.dr__label {
margin: 12px 0 0;
font-weight: 700;
font-size: 16px;
line-height: 1.3;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dr {
width: min(250px, 82vw);
}
.dr__dot {
width: 16px;
height: 16px;
}
.dr__label {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.dr__fill, .dr__dot {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
var HUE_LOW = 132, HUE_HIGH = 0, SAT = 46, LIGHT = 68;
var root = document.getElementById('dr');
var track = document.getElementById('drTrack');
var fill = document.getElementById('drFill');
var dots = Array.prototype.slice.call(track.querySelectorAll('.dr__dot'));
var nameOut = document.getElementById('drName');
var countOut = document.getElementById('drCount');
var selected = 1;
// 계산 겹: 점 순서(0~4)를 초록(132°)에서 빨강(0°)으로 이어지는 색상각 값으로 바꾼다
function dotHue(i) { return HUE_LOW + (HUE_HIGH - HUE_LOW) * (i / (LEVELS.length - 1)); }
function dotColor(i) { return 'hsl(' + dotHue(i).toFixed(1) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paintUpTo(i) {
dots.forEach(function (d, idx) {
d.style.background = idx <= i ? dotColor(idx) : '';
});
fill.style.width = ((i + 1) / LEVELS.length * 100) + '%';
fill.style.background = dotColor(i);
}
function commit(i) {
selected = i;
dots.forEach(function (d, idx) {
d.setAttribute('aria-checked', idx === i ? 'true' : 'false');
d.setAttribute('tabindex', idx === i ? '0' : '-1');
});
paintUpTo(i);
nameOut.textContent = LEVELS[i];
countOut.textContent = (i + 1) + ' / ' + LEVELS.length;
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
dots.forEach(function (d, i) {
d.addEventListener('pointerenter', function () { if (!root.classList.contains('is-demo')) paintUpTo(i); });
d.addEventListener('pointerleave', function () { if (!root.classList.contains('is-demo')) paintUpTo(selected); });
d.addEventListener('click', function () { human(); commit(i); });
d.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); commit(Math.min(LEVELS.length - 1, selected + 1)); dots[selected].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); commit(Math.max(0, selected - 1)); dots[selected].focus({ preventScroll: true }); }
});
});
var sequence = [3, 0, 4, 1, 2];
var si = 0;
function tick() { commit(sequence[si % sequence.length]); si++; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 620);
commit(selected);
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorDotRamp.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const HUE_LOW = 132, HUE_HIGH = 0, SAT = 46, LIGHT = 68;
const SEQUENCE = [3, 0, 4, 1, 2];
const TICK_MS = 620;
export default function DifficultySelectorDotRamp() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const dotRefs = useRef<HTMLButtonElement[]>([]);
const nameRef = useRef<HTMLSpanElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const nameOut = nameRef.current;
const countOut = countRef.current;
if (!root || !track || !fill || !nameOut || !countOut || !dotRefs.current) return undefined;
const dots = dotRefs.current;
if (dots.length !== LEVELS.length) return undefined;
let selected = 1;
// 계산 겹: 점 순서(0~4)를 초록(132°)에서 빨강(0°)으로 이어지는 색상각 값으로 바꾼다
function dotHue(i: number) { return HUE_LOW + (HUE_HIGH - HUE_LOW) * (i / (LEVELS.length - 1)); }
function dotColor(i: number) { return 'hsl(' + dotHue(i).toFixed(1) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paintUpTo(i: number) {
dots.forEach((d, idx) => { d.style.background = idx <= i ? dotColor(idx) : ''; });
fill!.style.width = ((i + 1) / LEVELS.length * 100) + '%';
fill!.style.background = dotColor(i);
}
function commit(i: number) {
selected = i;
dots.forEach((d, idx) => {
d.setAttribute('aria-checked', idx === i ? 'true' : 'false');
d.setAttribute('tabindex', idx === i ? '0' : '-1');
});
paintUpTo(i);
nameOut!.textContent = LEVELS[i];
countOut!.textContent = (i + 1) + ' / ' + LEVELS.length;
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
const offs = dots.map((d, i) => {
function onEnter() { if (root!.classList.contains(styles['is-demo'])) return; paintUpTo(i); }
function onLeave() { if (root!.classList.contains(styles['is-demo'])) return; paintUpTo(selected); }
function onClick() { human(); commit(i); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); commit(Math.min(LEVELS.length - 1, selected + 1)); dots[selected].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); commit(Math.max(0, selected - 1)); dots[selected].focus({ preventScroll: true }); }
}
d.addEventListener('pointerenter', onEnter);
d.addEventListener('pointerleave', onLeave);
d.addEventListener('click', onClick);
d.addEventListener('keydown', onKey);
return () => {
d.removeEventListener('pointerenter', onEnter);
d.removeEventListener('pointerleave', onLeave);
d.removeEventListener('click', onClick);
d.removeEventListener('keydown', onKey);
};
});
let si = 0;
function tick() { commit(SEQUENCE[si % SEQUENCE.length]); si++; }
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
commit(selected);
return () => { offs.forEach((off) => off()); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.dr + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.drTrack} ref={trackRef} role="radiogroup" aria-label="난이도">
<div className={styles.drFill} ref={fillRef} />
{LEVELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.drDot}
ref={(el: HTMLButtonElement | null) => { if (el && dotRefs.current) dotRefs.current[i] = el; }}
role="radio"
aria-checked="false"
aria-label={label}
data-i={i}
tabIndex={i === 0 ? 0 : -1}
/>
))}
</div>
<p className={styles.drLabel}>
<span ref={nameRef}>보통</span> <span ref={countRef}>2 / 5</span>
</p>
</div>
</main>
);
}01 · 色が段階ごとに変わるドット式難易度セレクター — 計測 累積面積 3.112% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
まずは1番目、色が段階ごとに変わるドット式難易度セレクターです。他の8つも同じ形式で、それぞれ自分のデモとコードブロックがすぐ下に続きます。
9つの難易度セレクターの解剖 — どこで計算の一層が回るか
9種はどれも「クリックした段階を覚えて表示に反映する」という土台は同じですが、その表示を更新する瞬間に何を計算しているかが1つずつ違います。まず、キャプチャした24フレームから求めた累積変化面積・平均強度・動いたフレーム数(いずれも23フレーム中)を並べます。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 色が段階ごとに変わるドット式難易度セレクター | 3.11% | 68.0 | 23中13 |
| 02 | 選ぶほど影が深くなるカード型選択UI | 8.92% | 78.1 | 23中12 |
| 03 | 段階で弾んで止まる難易度スライダー | 4.29% | 108.3 | 23中13 |
| 04 | 光沢が走り抜ける棒型難易度ゲージ | 2.77% | 77.6 | 23中12 |
| 05 | 段階ごとにアイコンの形が変形する選択UI | 2.07% | 81.1 | 23中11 |
| 06 | フォーカスリングが慣性で追いかけるキーボード選択UI | 5.87% | 144.7 | 23中14 |
| 07 | 色が数式でつながるバッジ型選択UI | 4.04% | 70.0 | 23中10 |
| 08 | 数字が走行距離計のように回る難易度ステッパー | 0.69% | 105.6 | 23中11 |
| 09 | ドラッグ角度で値が決まる半円型難易度ゲージ | 2.20% | 116.6 | 23中9 |
面積が最も大きいのは02のカード型(8.92%)で、影のy・ぼかし・浮き上がる高さの3つが同時に変わるぶん画面の変化量が大きくなります。逆に最も小さいのは08の数字ステッパー(0.69%)で、動くのは細い数字リールの帯だけです。平均強度が最も高いのは06のキーボードフォーカス(144.7)で、リングが遅れて追いつく間ずっとピクセル差が出続けるためです。動いたフレームが最も多いのも06(23中14)で、最も少ないのは09の半円ゲージ(23中9)です。
見た目の一属性だけを式で決める系統
01のドット色、02のカード影、07のバッジ色は、どれもクリックのたびにトランジションで滑らかに動くのではなく、選ばれた段階番号を1つの数式に入れて色や影の数値をその場で計算し直します。ドット色は色相角の直線補間、カード影はy・ぼかし・高さの3つの線形式、バッジ色は色相角に間隔を掛けた式です。
時間経過を伴う物理・イージング系統
03のスライダー、04のバーの光沢、05のアイコン変形、06のフォーカスリングは、目標値が決まった後もrequestAnimationFrameで毎フレーム値を更新し続けます。03はばね-減衰方程式で段階を行き過ぎてから戻り、04はeaseOutCubicで光沢を走らせ、05は輪郭の各頂点を線形補間し、06は指数減衰(lerp)でリングを目標に近づけます。
ポインタ座標を角度・比率に変換する系統
03のスライダーはポインタのx座標を割合に、09の半円ゲージはポインタ座標とピボットの間の角度をatan2で求めて0〜4の値に変換します。04のバーもポインタのx位置を段階に変換する点で同じ計算の仲間です。座標をそのまま使わず、いったん角度や比率という別の単位に変換してから難易度の値に戻しているのが共通点です。
パターン別コード
以下の9つはそれぞれのデモ本体index.htmlが実際に実行している計算関数を、変数名も順序もそのまま抜き出したものです。省略や並べ替えは行っていません。
01色が段階ごとに変わるドット式難易度セレクター
ドットを一つ指すとそこまでのドットが緑から赤へ計算された色で満ち、実際に選んだドットまでその色が残ります。アンケートやクイズの前に体感難易度を選ぶ場所で使う想定です。

<main class="stage">
<div class="dr is-demo" id="dr">
<div class="dr__track" id="drTrack" role="radiogroup" aria-label="難易度">
<div class="dr__fill" id="drFill"></div>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="簡単" data-i="0" tabindex="0"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="普通" data-i="1" tabindex="-1"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="難しい" data-i="2" tabindex="-1"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="とても難しい" data-i="3" tabindex="-1"></button>
<button type="button" class="dr__dot" role="radio" aria-checked="false" aria-label="最高" data-i="4" tabindex="-1"></button>
</div>
<p class="dr__label"><span id="drName">普通</span> <span id="drCount">2 / 5</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
// 계산 겹: dotHue(i) 가 점 순서를 초록→빨강 색상각으로 바꾼다(JS). scss 는 배치·상태만 담당.
@include stage($stage-ink);
.dr { width: min(300px, 86vw); color: $subject-cream; text-align: center; }
.dr__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-8 $sp-2 $sp-4;
}
.dr__fill {
position: absolute; top: 0; bottom: 0; left: 0; width: 40%;
background: $color; border-radius: $r-pill; transition: width $duration $easing, background $duration $easing;
}
.dr__dot {
position: absolute; top: 50%; width: 20px; height: 20px;
appearance: none; border: 2px solid rgba($subject-cream, .5); border-radius: 50%;
background: transparent; padding: 0; cursor: pointer; transform: translate(-50%, -50%);
transition: background $duration $easing, transform $dur-quick $ease-pop, border-color $duration $easing;
}
.dr__dot:nth-of-type(2) { left: 0%; }
.dr__dot:nth-of-type(3) { left: 25%; }
.dr__dot:nth-of-type(4) { left: 50%; }
.dr__dot:nth-of-type(5) { left: 75%; }
.dr__dot:nth-of-type(6) { left: 100%; }
.dr__dot:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.dr__dot[aria-checked="true"] {
border-color: $subject-cream; transform: translate(-50%, -50%) scale(1.3);
}
.dr__label {
margin: $sp-3 0 0; font-weight: 700; font-size: 16px; line-height: 1.3;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dr { width: min(250px, 82vw); }
.dr__dot { width: 16px; height: 16px; }
.dr__label { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 실제로 눌러도 채움·점 색은 트랜지션 없이 즉시 바뀐다
.dr__fill, .dr__dot { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dr {
width: min(300px, 86vw);
color: #fff7e6;
text-align: center;
}
.dr__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 32px 8px 16px;
}
.dr__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: #4cd4a6;
border-radius: 999px;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dr__dot {
position: absolute;
top: 50%;
width: 20px;
height: 20px;
appearance: none;
border: 2px solid rgba(255, 247, 230, 0.5);
border-radius: 50%;
background: transparent;
padding: 0;
cursor: pointer;
transform: translate(-50%, -50%);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dr__dot:nth-of-type(2) {
left: 0%;
}
.dr__dot:nth-of-type(3) {
left: 25%;
}
.dr__dot:nth-of-type(4) {
left: 50%;
}
.dr__dot:nth-of-type(5) {
left: 75%;
}
.dr__dot:nth-of-type(6) {
left: 100%;
}
.dr__dot:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.dr__dot[aria-checked=true] {
border-color: #fff7e6;
transform: translate(-50%, -50%) scale(1.3);
}
.dr__label {
margin: 12px 0 0;
font-weight: 700;
font-size: 16px;
line-height: 1.3;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dr {
width: min(250px, 82vw);
}
.dr__dot {
width: 16px;
height: 16px;
}
.dr__label {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.dr__fill, .dr__dot {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
var HUE_LOW = 132, HUE_HIGH = 0, SAT = 46, LIGHT = 68;
var root = document.getElementById('dr');
var track = document.getElementById('drTrack');
var fill = document.getElementById('drFill');
var dots = Array.prototype.slice.call(track.querySelectorAll('.dr__dot'));
var nameOut = document.getElementById('drName');
var countOut = document.getElementById('drCount');
var selected = 1;
// 계산 겹: 점 순서(0~4)를 초록(132°)에서 빨강(0°)으로 이어지는 색상각 값으로 바꾼다
function dotHue(i) { return HUE_LOW + (HUE_HIGH - HUE_LOW) * (i / (LEVELS.length - 1)); }
function dotColor(i) { return 'hsl(' + dotHue(i).toFixed(1) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paintUpTo(i) {
dots.forEach(function (d, idx) {
d.style.background = idx <= i ? dotColor(idx) : '';
});
fill.style.width = ((i + 1) / LEVELS.length * 100) + '%';
fill.style.background = dotColor(i);
}
function commit(i) {
selected = i;
dots.forEach(function (d, idx) {
d.setAttribute('aria-checked', idx === i ? 'true' : 'false');
d.setAttribute('tabindex', idx === i ? '0' : '-1');
});
paintUpTo(i);
nameOut.textContent = LEVELS[i];
countOut.textContent = (i + 1) + ' / ' + LEVELS.length;
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
dots.forEach(function (d, i) {
d.addEventListener('pointerenter', function () { if (!root.classList.contains('is-demo')) paintUpTo(i); });
d.addEventListener('pointerleave', function () { if (!root.classList.contains('is-demo')) paintUpTo(selected); });
d.addEventListener('click', function () { human(); commit(i); });
d.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); commit(Math.min(LEVELS.length - 1, selected + 1)); dots[selected].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); commit(Math.max(0, selected - 1)); dots[selected].focus({ preventScroll: true }); }
});
});
var sequence = [3, 0, 4, 1, 2];
var si = 0;
function tick() { commit(sequence[si % sequence.length]); si++; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 620);
commit(selected);
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorDotRamp.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const HUE_LOW = 132, HUE_HIGH = 0, SAT = 46, LIGHT = 68;
const SEQUENCE = [3, 0, 4, 1, 2];
const TICK_MS = 620;
export default function DifficultySelectorDotRamp() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const dotRefs = useRef<HTMLButtonElement[]>([]);
const nameRef = useRef<HTMLSpanElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const nameOut = nameRef.current;
const countOut = countRef.current;
if (!root || !track || !fill || !nameOut || !countOut || !dotRefs.current) return undefined;
const dots = dotRefs.current;
if (dots.length !== LEVELS.length) return undefined;
let selected = 1;
// 계산 겹: 점 순서(0~4)를 초록(132°)에서 빨강(0°)으로 이어지는 색상각 값으로 바꾼다
function dotHue(i: number) { return HUE_LOW + (HUE_HIGH - HUE_LOW) * (i / (LEVELS.length - 1)); }
function dotColor(i: number) { return 'hsl(' + dotHue(i).toFixed(1) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paintUpTo(i: number) {
dots.forEach((d, idx) => { d.style.background = idx <= i ? dotColor(idx) : ''; });
fill!.style.width = ((i + 1) / LEVELS.length * 100) + '%';
fill!.style.background = dotColor(i);
}
function commit(i: number) {
selected = i;
dots.forEach((d, idx) => {
d.setAttribute('aria-checked', idx === i ? 'true' : 'false');
d.setAttribute('tabindex', idx === i ? '0' : '-1');
});
paintUpTo(i);
nameOut!.textContent = LEVELS[i];
countOut!.textContent = (i + 1) + ' / ' + LEVELS.length;
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
const offs = dots.map((d, i) => {
function onEnter() { if (root!.classList.contains(styles['is-demo'])) return; paintUpTo(i); }
function onLeave() { if (root!.classList.contains(styles['is-demo'])) return; paintUpTo(selected); }
function onClick() { human(); commit(i); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); commit(Math.min(LEVELS.length - 1, selected + 1)); dots[selected].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); commit(Math.max(0, selected - 1)); dots[selected].focus({ preventScroll: true }); }
}
d.addEventListener('pointerenter', onEnter);
d.addEventListener('pointerleave', onLeave);
d.addEventListener('click', onClick);
d.addEventListener('keydown', onKey);
return () => {
d.removeEventListener('pointerenter', onEnter);
d.removeEventListener('pointerleave', onLeave);
d.removeEventListener('click', onClick);
d.removeEventListener('keydown', onKey);
};
});
let si = 0;
function tick() { commit(SEQUENCE[si % SEQUENCE.length]); si++; }
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
commit(selected);
return () => { offs.forEach((off) => off()); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.dr + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.drTrack} ref={trackRef} role="radiogroup" aria-label="난이도">
<div className={styles.drFill} ref={fillRef} />
{LEVELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.drDot}
ref={(el: HTMLButtonElement | null) => { if (el && dotRefs.current) dotRefs.current[i] = el; }}
role="radio"
aria-checked="false"
aria-label={label}
data-i={i}
tabIndex={i === 0 ? 0 : -1}
/>
))}
</div>
<p className={styles.drLabel}>
<span ref={nameRef}>보통</span> <span ref={countRef}>2 / 5</span>
</p>
</div>
</main>
);
}01 · 色が段階ごとに変わるドット式難易度セレクター — 計測 累積面積 3.112% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: ドットの並び(0〜4)を緑(132°)から赤(0°)へ移る色相角の値に変換する
function dotHue(i) { return HUE_LOW + (HUE_HIGH - HUE_LOW) * (i / (LEVELS.length - 1)); }
function dotColor(i) { return 'hsl(' + dotHue(i).toFixed(1) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
function paintUpTo(i) {
dots.forEach(function (d, idx) {
d.style.background = idx <= i ? dotColor(idx) : '';
});
fill.style.width = ((i + 1) / LEVELS.length * 100) + '%';
fill.style.background = dotColor(i);
}
02選ぶほど影が深くなるカード型難易度選択
カードを選ぶとその影の高さとぼかしが段階番号に比例して計算され大きくなるため、上級カードほど浮いて見えます。アプリの開始画面で上・中・下の難易度を選ぶ場所を想定しています。

<main class="stage">
<div class="rc is-demo" id="rc" role="radiogroup" aria-label="難易度">
<button type="button" class="rc__card" role="radio" aria-checked="false" data-i="0" tabindex="0">簡単</button>
<button type="button" class="rc__card" role="radio" aria-checked="false" data-i="1" tabindex="-1">普通</button>
<button type="button" class="rc__card" role="radio" aria-checked="false" data-i="2" tabindex="-1">難しい</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-lilac;
// 계산 겹: shadowFor(tier) 가 그림자 y·blur·들뜬 높이를 단계 번호에 비례해 구한다(JS). scss 는 전환만 담당.
@include stage($stage-yellow);
.rc { display: flex; gap: $sp-3; }
.rc__card {
appearance: none; border: 2px solid transparent; border-radius: $r-card;
background: $subject-ink; color: $subject-cream; font-weight: 700; font-size: 14px;
width: 72px; height: 56px; cursor: pointer;
transition: box-shadow $duration $easing, transform $duration $easing, border-color $dur-quick $ease-out;
}
.rc__card:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.rc__card[aria-checked="true"] { border-color: $color; }
@media (max-width: 340px) {
.rc { gap: $sp-2; }
.rc__card { width: 60px; height: 48px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 실제로 카드를 골라도 그림자·들뜸은 트랜지션 없이 즉시 반영된다
.rc__card { 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);
}
.rc {
display: flex;
gap: 12px;
}
.rc__card {
appearance: none;
border: 2px solid transparent;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 14px;
width: 72px;
height: 56px;
cursor: pointer;
transition: box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms ease-out;
}
.rc__card:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rc__card[aria-checked=true] {
border-color: #b9a0ff;
}
@media (max-width: 340px) {
.rc {
gap: 8px;
}
.rc__card {
width: 60px;
height: 48px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc__card {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい'];
var BASE_Y = 6, Y_STEP = 9, BASE_BLUR = 14, BLUR_STEP = 12, BASE_LIFT = 2, LIFT_STEP = 5;
var root = document.getElementById('rc');
var cards = Array.prototype.slice.call(root.querySelectorAll('.rc__card'));
var selected = 0;
// 계산 겹: 카드 그림자의 y·blur·들뜬 높이를 고정값이 아니라 단계 번호(tier)에 비례해 구한다
function shadowFor(tier) {
var y = BASE_Y + tier * Y_STEP;
var blur = BASE_BLUR + tier * BLUR_STEP;
var lift = BASE_LIFT + tier * LIFT_STEP;
return { y: y, blur: blur, lift: lift };
}
function paint() {
cards.forEach(function (c, i) {
var on = i === selected;
c.setAttribute('aria-checked', on ? 'true' : 'false');
c.setAttribute('tabindex', on ? '0' : '-1');
if (on) {
var s = shadowFor(i);
c.style.boxShadow = '0 ' + s.y + 'px ' + s.blur + 'px rgba(23, 20, 26, .38)';
c.style.transform = 'translateY(-' + s.lift + 'px)';
} else {
c.style.boxShadow = '';
c.style.transform = '';
}
});
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function select(i) { selected = i; paint(); }
cards.forEach(function (c, i) {
c.addEventListener('click', function () { human(); select(i); });
c.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); human(); select((selected + 1) % LEVELS.length); cards[selected].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); human(); select((selected - 1 + LEVELS.length) % LEVELS.length); cards[selected].focus({ preventScroll: true }); }
});
});
var si = 0;
function tick() { si = (si + 1) % LEVELS.length; select(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 './DifficultySelectorRadioCardGrid.module.scss';
const LEVELS = ['쉬움', '보통', '어려움'];
const BASE_Y = 6, Y_STEP = 9, BASE_BLUR = 14, BLUR_STEP = 12, BASE_LIFT = 2, LIFT_STEP = 5;
const TICK_MS = 700;
export default function DifficultySelectorRadioCardGrid() {
const rootRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<HTMLButtonElement[]>([]);
useEffect(() => {
const root = rootRef.current;
if (!root || !cardRefs.current) return undefined;
const cards = cardRefs.current;
if (cards.length !== LEVELS.length) return undefined;
let selected = 0;
// 계산 겹: 카드 그림자의 y·blur·들뜬 높이를 고정값이 아니라 단계 번호(tier)에 비례해 구한다
function shadowFor(tier: number) {
return { y: BASE_Y + tier * Y_STEP, blur: BASE_BLUR + tier * BLUR_STEP, lift: BASE_LIFT + tier * LIFT_STEP };
}
function paint() {
cards.forEach((c, i) => {
const on = i === selected;
c.setAttribute('aria-checked', on ? 'true' : 'false');
c.setAttribute('tabindex', on ? '0' : '-1');
if (on) {
const s = shadowFor(i);
c.style.boxShadow = '0 ' + s.y + 'px ' + s.blur + 'px rgba(23, 20, 26, .38)';
c.style.transform = 'translateY(-' + s.lift + 'px)';
} else {
c.style.boxShadow = '';
c.style.transform = '';
}
});
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function select(i: number) { selected = i; paint(); }
const offs = cards.map((c, i) => {
function onClick() { human(); select(i); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); human(); select((selected + 1) % LEVELS.length); cards[selected].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); human(); select((selected - 1 + LEVELS.length) % LEVELS.length); cards[selected].focus({ preventScroll: true }); }
}
c.addEventListener('click', onClick);
c.addEventListener('keydown', onKey);
return () => { c.removeEventListener('click', onClick); c.removeEventListener('keydown', onKey); };
});
let si = 0;
function tick() { si = (si + 1) % LEVELS.length; select(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.rc + ' ' + styles['is-demo']} ref={rootRef} role="radiogroup" aria-label="난이도">
{LEVELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.rcCard}
ref={(el: HTMLButtonElement | null) => { if (el && cardRefs.current) cardRefs.current[i] = el; }}
role="radio"
aria-checked="false"
data-i={i}
tabIndex={i === 0 ? 0 : -1}
>
{label}
</button>
))}
</div>
</main>
);
}02 · 選ぶほど影が深くなるカード型難易度選択 — 計測 累積面積 8.917% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: カードの影の y・ぼかし・浮き上がる高さを固定値ではなく段階番号(tier)に比例して求める
function shadowFor(tier) {
var y = BASE_Y + tier * Y_STEP;
var blur = BASE_BLUR + tier * BLUR_STEP;
var lift = BASE_LIFT + tier * LIFT_STEP;
return { y: y, blur: blur, lift: lift };
}
function paint() {
cards.forEach(function (c, i) {
var on = i === selected;
c.setAttribute('aria-checked', on ? 'true' : 'false');
c.setAttribute('tabindex', on ? '0' : '-1');
if (on) {
var s = shadowFor(i);
c.style.boxShadow = '0 ' + s.y + 'px ' + s.blur + 'px rgba(23, 20, 26, .38)';
c.style.transform = 'translateY(-' + s.lift + 'px)';
} else {
c.style.boxShadow = '';
c.style.transform = '';
}
});
}
03段階で弾んで止まる難易度スライダー
手を離すとつまみが最も近い段階を少し行き過ぎたあと、ばね計算で引き戻されその段階でぴたりと止まります。設定画面で指で引いて難易度を決める場所を想定しており、他の難易度セレクターと違って「行き過ぎてから戻る」動き自体が今の段階を強調する役目も持ちます。

<main class="stage">
<div class="sst is-demo" id="sst">
<div class="sst__track" id="sstTrack">
<div class="sst__tick" style="left:0%"></div>
<div class="sst__tick" style="left:25%"></div>
<div class="sst__tick" style="left:50%"></div>
<div class="sst__tick" style="left:75%"></div>
<div class="sst__tick" style="left:100%"></div>
<div class="sst__fill" id="sstFill"></div>
<button type="button" class="sst__thumb" id="sstThumb" role="slider"
aria-label="難易度" aria-valuemin="0" aria-valuemax="4" aria-valuenow="1" aria-valuetext="普通" tabindex="0"></button>
</div>
<p class="sst__label" id="sstLabel">普通</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-orange-deep;
// 계산 겹: springTo() 가 스프링-감쇠 방정식을 매 프레임 적분한다(JS) — scss 는 위치만 반영, 트랜지션 없음(연속 프레임이 이미 물리 곡선)
@include stage($stage-orange);
.sst { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.sst__track { position: relative; height: 6px; border-radius: $r-pill; background: rgba($subject-ink, .18); margin: $sp-10 0 $sp-4; }
.sst__fill { position: absolute; top: 0; bottom: 0; left: 0; width: 25%; background: $color; border-radius: $r-pill; }
.sst__tick {
position: absolute; top: 50%; width: 4px; height: 4px; border-radius: 50%;
background: rgba($subject-ink, .35); transform: translate(-50%, -50%);
}
.sst__thumb {
position: absolute; top: 50%; left: 25%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%);
}
.sst__thumb:active { cursor: grabbing; }
.sst__thumb:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.sst__label { margin: $sp-2 0 0; font-weight: 700; font-size: 16px; }
@media (max-width: 340px) {
.sst { width: min(240px, 80vw); }
.sst__label { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: springTo() 안에서 reduce 를 직접 확인해 목표 위치로 즉시 이동한다(JS 쪽 처리, 여긴 대체 표시 없음)
.sst__thumb { 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);
}
.sst {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.sst__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
margin: 40px 0 16px;
}
.sst__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 25%;
background: #c93000;
border-radius: 999px;
}
.sst__tick {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.35);
transform: translate(-50%, -50%);
}
.sst__thumb {
position: absolute;
top: 50%;
left: 25%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
}
.sst__thumb:active {
cursor: grabbing;
}
.sst__thumb:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sst__label {
margin: 8px 0 0;
font-weight: 700;
font-size: 16px;
}
@media (max-width: 340px) {
.sst {
width: min(240px, 80vw);
}
.sst__label {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.sst__thumb {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
var N = LEVELS.length;
var SPRING_K = 210, SPRING_D = 18, DT = 1 / 60;
var root = document.getElementById('sst');
var track = document.getElementById('sstTrack');
var fill = document.getElementById('sstFill');
var thumb = document.getElementById('sstThumb');
var label = document.getElementById('sstLabel');
var tier = 1;
var pos = tierPct(1);
var raf = 0;
function tierPct(t) { return (t / (N - 1)) * 100; }
function paintAt(pct) {
thumb.style.left = pct + '%';
fill.style.width = pct + '%';
}
function setReadout(t) {
thumb.setAttribute('aria-valuenow', String(t));
thumb.setAttribute('aria-valuetext', LEVELS[t]);
label.textContent = LEVELS[t];
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 목표 단계까지 스프링-감쇠 방정식(가속도 = -k*(위치-목표) - d*속도)을 매 프레임 적분해
// 단계를 살짝 지나쳤다가(오버슛) 되튕겨 정확히 그 단계에서 멈춘다
function springTo(target) {
cancelAnimationFrame(raf);
if (reduce) { pos = target; paintAt(pos); return; }
var vel = 0;
function step() {
var force = -SPRING_K * (pos - target) - SPRING_D * vel;
vel += force * DT;
pos += vel * DT;
paintAt(pos);
if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 0.15) raf = requestAnimationFrame(step);
else { pos = target; paintAt(pos); }
}
raf = requestAnimationFrame(step);
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function settle(t) { tier = Math.max(0, Math.min(N - 1, t)); setReadout(tier); springTo(tierPct(tier)); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100));
}
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId); cancelAnimationFrame(raf);
function move(ev) { pos = pctFromX(ev.clientX); paintAt(pos); }
function up() {
thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up);
settle(Math.round(pos / 100 * (N - 1)));
}
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); settle(tier + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); settle(tier - 1); }
else if (e.key === 'Home') { e.preventDefault(); human(); settle(0); }
else if (e.key === 'End') { e.preventDefault(); human(); settle(N - 1); }
});
var sequence = [3, 0, 4, 1, 2];
var si = 0;
function tick() { settle(sequence[si % sequence.length]); si++; }
var timer = reduce ? null : setInterval(tick, 900);
setReadout(tier); paintAt(pos);
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorSliderSnapTier.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const N = LEVELS.length;
const SPRING_K = 210, SPRING_D = 18, DT = 1 / 60;
const SEQUENCE = [3, 0, 4, 1, 2];
const TICK_MS = 900;
export default function DifficultySelectorSliderSnapTier() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLButtonElement>(null);
const labelRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const thumb = thumbRef.current;
const label = labelRef.current;
if (!root || !track || !fill || !thumb || !label) return undefined;
function tierPct(t: number) { return (t / (N - 1)) * 100; }
let tier = 1;
let pos = tierPct(1);
let raf = 0;
function paintAt(pct: number) {
thumb!.style.left = pct + '%';
fill!.style.width = pct + '%';
}
function setReadout(t: number) {
thumb!.setAttribute('aria-valuenow', String(t));
thumb!.setAttribute('aria-valuetext', LEVELS[t]);
label!.textContent = LEVELS[t];
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 목표 단계까지 스프링-감쇠 방정식(가속도 = -k*(위치-목표) - d*속도)을 매 프레임 적분해
// 단계를 살짝 지나쳤다가(오버슛) 되튕겨 정확히 그 단계에서 멈춘다
function springTo(target: number) {
cancelAnimationFrame(raf);
if (reduce) { pos = target; paintAt(pos); return; }
let vel = 0;
function step() {
const force = -SPRING_K * (pos - target) - SPRING_D * vel;
vel += force * DT;
pos += vel * DT;
paintAt(pos);
if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 0.15) raf = requestAnimationFrame(step);
else { pos = target; paintAt(pos); }
}
raf = requestAnimationFrame(step);
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function settle(t: number) { tier = Math.max(0, Math.min(N - 1, t)); setReadout(tier); springTo(tierPct(tier)); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100));
}
function onDown(e: PointerEvent) {
human(); thumb!.setPointerCapture(e.pointerId); cancelAnimationFrame(raf);
function move(ev: PointerEvent) { pos = pctFromX(ev.clientX); paintAt(pos); }
function up() {
thumb!.removeEventListener('pointermove', move); thumb!.removeEventListener('pointerup', up);
settle(Math.round(pos / 100 * (N - 1)));
}
thumb!.addEventListener('pointermove', move);
thumb!.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); settle(tier + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); settle(tier - 1); }
else if (e.key === 'Home') { e.preventDefault(); human(); settle(0); }
else if (e.key === 'End') { e.preventDefault(); human(); settle(N - 1); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
let si = 0;
function tick() { settle(SEQUENCE[si % SEQUENCE.length]); si++; }
if (!reduce) timer = window.setInterval(tick, TICK_MS);
setReadout(tier); paintAt(pos);
return () => {
thumb!.removeEventListener('pointerdown', onDown);
thumb!.removeEventListener('keydown', onKey);
cancelAnimationFrame(raf);
window.clearInterval(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sst + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.sstTrack} ref={trackRef}>
<div className={styles.sstTick} style={{ left: '0%' }} />
<div className={styles.sstTick} style={{ left: '25%' }} />
<div className={styles.sstTick} style={{ left: '50%' }} />
<div className={styles.sstTick} style={{ left: '75%' }} />
<div className={styles.sstTick} style={{ left: '100%' }} />
<div className={styles.sstFill} ref={fillRef} />
<button
type="button"
className={styles.sstThumb}
ref={thumbRef}
role="slider"
aria-label="난이도"
aria-valuemin={0}
aria-valuemax={N - 1}
aria-valuenow={1}
aria-valuetext="보통"
tabIndex={0}
/>
</div>
<p className={styles.sstLabel} ref={labelRef}>보통</p>
</div>
</main>
);
}03 · 段階で弾んで止まる難易度スライダー — 計測 累積面積 4.291% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 目標の段階までばね-減衰方程式(加速度 = -k*(位置-目標) - d*速度)を毎フレーム積分し、
// 段階を少し行き過ぎてから(オーバーシュート)跳ね返って正確にその段階で止まる
function springTo(target) {
cancelAnimationFrame(raf);
if (reduce) { pos = target; paintAt(pos); return; }
var vel = 0;
function step() {
var force = -SPRING_K * (pos - target) - SPRING_D * vel;
vel += force * DT;
pos += vel * DT;
paintAt(pos);
if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 0.15) raf = requestAnimationFrame(step);
else { pos = target; paintAt(pos); }
}
raf = requestAnimationFrame(step);
}
04光沢が走り抜ける棒型難易度ゲージ
難易度が変わるたびバーの中で明るい帯の左から右への位置を毎フレーム計算して描き、走り抜けるように見せます。ダッシュボードで現在の難易度水準を一目で見せる場所を想定しています。

<main class="stage">
<div class="bmf is-demo" id="bmf">
<div class="bmf__meter" id="bmfMeter" role="slider" tabindex="0"
aria-label="難易度" aria-valuemin="0" aria-valuemax="4" aria-valuenow="2" aria-valuetext="難しい">
<div class="bmf__fill" id="bmfFill"></div>
<div class="bmf__gloss" id="bmfGloss"></div>
</div>
<div class="bmf__row">
<button type="button" class="bmf__btn" id="bmfMinus" aria-label="難易度を下げる">−</button>
<span class="bmf__name" id="bmfName">難しい</span>
<button type="button" class="bmf__btn" id="bmfPlus" aria-label="難易度を上げる">+</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-sky;
// 계산 겹: sweep() 이 광택 띠 위치를 easeOutCubic(경과비율) 로 매 프레임 계산한다(JS)
@include stage($stage-paper);
.bmf { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.bmf__meter {
position: relative; height: 16px; border-radius: $r-sm; overflow: hidden;
background: rgba($subject-ink, .1); cursor: pointer;
}
.bmf__meter:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.bmf__fill {
position: absolute; top: 0; bottom: 0; left: 0; width: 60%;
background: $color; transition: width $duration $easing;
}
.bmf__gloss {
position: absolute; top: 0; bottom: 0; left: 0; width: 28px;
background: linear-gradient(90deg, rgba($subject-cream, 0), rgba($subject-cream, .85), rgba($subject-cream, 0));
opacity: 0; transform: translateX(-50%);
}
.bmf__row { display: flex; align-items: center; justify-content: center; gap: $sp-3; margin-top: $sp-4; }
.bmf__btn {
appearance: none; border: 0; border-radius: $r-control; width: 32px; height: 32px;
background: $subject-ink; color: $subject-cream; font-size: 18px; font-weight: 700; cursor: pointer;
transition: transform $dur-quick $ease-pop;
}
.bmf__btn:active { transform: scale(.9); }
.bmf__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.bmf__name { font-weight: 700; font-size: 15px; min-width: 78px; }
@media (max-width: 340px) {
.bmf { width: min(240px, 80vw); }
.bmf__name { font-size: 13px; min-width: 64px; }
.bmf__btn { width: 28px; height: 28px; font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 채움 너비 전환을 끄고, 광택 띠는 JS 의 reduce 분기가 아예 돌리지 않는다(위 스크립트)
.bmf__fill { 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);
}
.bmf {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.bmf__meter {
position: relative;
height: 16px;
border-radius: 8px;
overflow: hidden;
background: rgba(23, 20, 26, 0.1);
cursor: pointer;
}
.bmf__meter:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.bmf__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 60%;
background: #6ec8ff;
transition: width 300ms ease-out;
}
.bmf__gloss {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 28px;
background: linear-gradient(90deg, rgba(255, 247, 230, 0), rgba(255, 247, 230, 0.85), rgba(255, 247, 230, 0));
opacity: 0;
transform: translateX(-50%);
}
.bmf__row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
.bmf__btn {
appearance: none;
border: 0;
border-radius: 12px;
width: 32px;
height: 32px;
background: #17141a;
color: #fff7e6;
font-size: 18px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.bmf__btn:active {
transform: scale(0.9);
}
.bmf__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.bmf__name {
font-weight: 700;
font-size: 15px;
min-width: 78px;
}
@media (max-width: 340px) {
.bmf {
width: min(240px, 80vw);
}
.bmf__name {
font-size: 13px;
min-width: 64px;
}
.bmf__btn {
width: 28px;
height: 28px;
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.bmf__fill {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
var N = LEVELS.length;
var SWEEP_MS = 480;
var root = document.getElementById('bmf');
var meter = document.getElementById('bmfMeter');
var fill = document.getElementById('bmfFill');
var gloss = document.getElementById('bmfGloss');
var name = document.getElementById('bmfName');
var tier = 2;
var raf = 0, t0 = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 계산 겹: 채움이 바뀔 때마다 밝은 띠의 위치를 easeOutCubic(경과비율) 로 매 프레임 계산해 왼쪽에서 오른쪽으로 훑는다
function sweep() {
cancelAnimationFrame(raf);
if (reduce) { gloss.style.opacity = '0'; return; }
t0 = performance.now();
gloss.style.opacity = '1';
function step(now) {
var t = Math.min(1, (now - t0) / SWEEP_MS);
gloss.style.left = (easeOutCubic(t) * 100) + '%';
if (t < 1) raf = requestAnimationFrame(step);
else gloss.style.opacity = '0';
}
raf = requestAnimationFrame(step);
}
function paint() {
fill.style.width = ((tier + 1) / N * 100) + '%';
meter.setAttribute('aria-valuenow', String(tier));
meter.setAttribute('aria-valuetext', LEVELS[tier]);
name.textContent = LEVELS[tier];
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function set(t) {
var next = Math.max(0, Math.min(N - 1, t));
if (next === tier) return;
tier = next; paint(); sweep();
}
document.getElementById('bmfPlus').addEventListener('click', function () { human(); set(tier + 1); });
document.getElementById('bmfMinus').addEventListener('click', function () { human(); set(tier - 1); });
meter.addEventListener('pointerdown', function (e) {
human();
var r = meter.getBoundingClientRect();
var ratio = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
set(Math.round(ratio * (N - 1)));
});
meter.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); set(tier + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); set(tier - 1); }
});
var sequence = [4, 1, 3, 0, 2];
var si = 0;
function tick() {
var t = sequence[si % sequence.length]; si++;
tier = t; paint(); sweep();
}
var timer = reduce ? null : setInterval(tick, 900);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorBarMeterFill.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const N = LEVELS.length;
const SWEEP_MS = 480;
const SEQUENCE = [4, 1, 3, 0, 2];
const TICK_MS = 900;
export default function DifficultySelectorBarMeterFill() {
const rootRef = useRef<HTMLDivElement>(null);
const meterRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const glossRef = useRef<HTMLDivElement>(null);
const nameRef = useRef<HTMLSpanElement>(null);
const plusRef = useRef<HTMLButtonElement>(null);
const minusRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const meter = meterRef.current;
const fill = fillRef.current;
const gloss = glossRef.current;
const name = nameRef.current;
const plus = plusRef.current;
const minus = minusRef.current;
if (!root || !meter || !fill || !gloss || !name || !plus || !minus) return undefined;
let tier = 2;
let raf = 0, t0 = 0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
// 계산 겹: 채움이 바뀔 때마다 밝은 띠의 위치를 easeOutCubic(경과비율) 로 매 프레임 계산해 왼쪽에서 오른쪽으로 훑는다
function sweep() {
cancelAnimationFrame(raf);
if (reduce) { gloss!.style.opacity = '0'; return; }
t0 = performance.now();
gloss!.style.opacity = '1';
function step(now: number) {
const t = Math.min(1, (now - t0) / SWEEP_MS);
gloss!.style.left = (easeOutCubic(t) * 100) + '%';
if (t < 1) raf = requestAnimationFrame(step);
else gloss!.style.opacity = '0';
}
raf = requestAnimationFrame(step);
}
function paint() {
fill!.style.width = ((tier + 1) / N * 100) + '%';
meter!.setAttribute('aria-valuenow', String(tier));
meter!.setAttribute('aria-valuetext', LEVELS[tier]);
name!.textContent = LEVELS[tier];
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function set(t: number) {
const next = Math.max(0, Math.min(N - 1, t));
if (next === tier) return;
tier = next; paint(); sweep();
}
function onPlus() { human(); set(tier + 1); }
function onMinus() { human(); set(tier - 1); }
function onDown(e: PointerEvent) {
human();
const r = meter!.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
set(Math.round(ratio * (N - 1)));
}
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); set(tier + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); set(tier - 1); }
}
plus.addEventListener('click', onPlus);
minus.addEventListener('click', onMinus);
meter.addEventListener('pointerdown', onDown);
meter.addEventListener('keydown', onKey);
let si = 0;
function tick() {
const t = SEQUENCE[si % SEQUENCE.length]; si++;
tier = t; paint(); sweep();
}
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => {
plus!.removeEventListener('click', onPlus);
minus!.removeEventListener('click', onMinus);
meter!.removeEventListener('pointerdown', onDown);
meter!.removeEventListener('keydown', onKey);
cancelAnimationFrame(raf);
window.clearInterval(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.bmf + ' ' + styles['is-demo']} ref={rootRef}>
<div
className={styles.bmfMeter}
ref={meterRef}
role="slider"
tabIndex={0}
aria-label="난이도"
aria-valuemin={0}
aria-valuemax={N - 1}
aria-valuenow={2}
aria-valuetext="어려움"
>
<div className={styles.bmfFill} ref={fillRef} />
<div className={styles.bmfGloss} ref={glossRef} />
</div>
<div className={styles.bmfRow}>
<button type="button" className={styles.bmfBtn} ref={minusRef} aria-label="난이도 낮추기">−</button>
<span className={styles.bmfName} ref={nameRef}>어려움</span>
<button type="button" className={styles.bmfBtn} ref={plusRef} aria-label="난이도 높이기">+</button>
</div>
</div>
</main>
);
}04 · 光沢が走り抜ける棒型難易度ゲージ — 計測 累積面積 2.773% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 計算層: 塗りが変わるたびに明るい帯の位置を easeOutCubic(経過比率) で毎フレーム計算し、左から右へ走らせる
function sweep() {
cancelAnimationFrame(raf);
if (reduce) { gloss.style.opacity = '0'; return; }
t0 = performance.now();
gloss.style.opacity = '1';
function step(now) {
var t = Math.min(1, (now - t0) / SWEEP_MS);
gloss.style.left = (easeOutCubic(t) * 100) + '%';
if (t < 1) raf = requestAnimationFrame(step);
else gloss.style.opacity = '0';
}
raf = requestAnimationFrame(step);
}
05段階ごとにアイコンの形が変形する難易度選択
段階を切り替えるたびアイコンの輪郭座標が次の段階の形へ補間され、形が飛ばずに滑らかにつながります。ゲーム設定で難易度ごとの象徴アイコンを見せる場所を想定しています。
<main class="stage">
<div class="ims is-demo" id="ims">
<svg class="ims__icon" viewBox="0 0 80 80" width="64" height="64" aria-hidden="true">
<path id="imsPath" class="ims__shape"></path>
</svg>
<div class="ims__row" id="imsRow" role="radiogroup" aria-label="難易度">
<button type="button" class="ims__dot" role="radio" aria-checked="false" aria-label="簡単" data-i="0" tabindex="0"></button>
<button type="button" class="ims__dot" role="radio" aria-checked="false" aria-label="普通" data-i="1" tabindex="-1"></button>
<button type="button" class="ims__dot" role="radio" aria-checked="true" aria-label="難しい" data-i="2" tabindex="-1"></button>
<button type="button" class="ims__dot" role="radio" aria-checked="false" aria-label="とても難しい" data-i="3" tabindex="-1"></button>
<button type="button" class="ims__dot" role="radio" aria-checked="false" aria-label="最高" data-i="4" tabindex="-1"></button>
</div>
<p class="ims__label" id="imsLabel">難しい</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-pink;
// 계산 겹: polygonRadius(theta, sides) 기하 공식으로 다각형 반지름을 구하고, 단계가 바뀌면 그 반지름 배열을 보간한다(JS)
@include stage($stage-ink);
.ims { color: $subject-cream; text-align: center; }
.ims__icon { display: block; margin: 0 auto; }
.ims__shape { fill: $color; stroke: $subject-cream; stroke-width: 1.5; stroke-linejoin: round; }
.ims__row { display: flex; gap: $sp-2; justify-content: center; margin-top: $sp-3; }
.ims__dot {
appearance: none; width: 14px; height: 14px; border-radius: 50%;
border: 2px solid rgba($subject-cream, .5); background: transparent; padding: 0; cursor: pointer;
transition: background $dur-quick $ease-out, border-color $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.ims__dot:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ims__dot[aria-checked="true"] { background: $color; border-color: $subject-cream; transform: scale(1.2); }
.ims__label { margin: $sp-2 0 0; font-weight: 700; font-size: 15px; }
@media (max-width: 340px) {
.ims__icon { width: 52px; height: 52px; }
.ims__label { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: morphTo() 안에서 reduce 를 확인해 보간 없이 목표 모양으로 즉시 바뀐다(JS 쪽 처리)
.ims__dot { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ims {
color: #fff7e6;
text-align: center;
}
.ims__icon {
display: block;
margin: 0 auto;
}
.ims__shape {
fill: #ff9ebb;
stroke: #fff7e6;
stroke-width: 1.5;
stroke-linejoin: round;
}
.ims__row {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
.ims__dot {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.5);
background: transparent;
padding: 0;
cursor: pointer;
transition: background 220ms ease-out, border-color 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ims__dot:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ims__dot[aria-checked=true] {
background: #ff9ebb;
border-color: #fff7e6;
transform: scale(1.2);
}
.ims__label {
margin: 8px 0 0;
font-weight: 700;
font-size: 15px;
}
@media (max-width: 340px) {
.ims__icon {
width: 52px;
height: 52px;
}
.ims__label {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.ims__dot {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
var SIDES = [3, 4, 5, 6, 7];
var POINTS = 7, CX = 40, CY = 40, R = 30, MORPH_MS = 420;
var root = document.getElementById('ims');
var path = document.getElementById('imsPath');
var row = document.getElementById('imsRow');
var dots = Array.prototype.slice.call(row.querySelectorAll('.ims__dot'));
var label = document.getElementById('imsLabel');
var tier = 2;
var raf = 0, t0 = 0, fromR = [], toR = [];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 정다각형의 변 수(sides)와 각도(theta)로 그 방향의 반지름을 구하는 기하 공식.
// theta 가 한 변의 중심에서 멀어질수록(꼭짓점에 가까울수록) 반지름이 길어진다
function polygonRadius(theta, sides) {
var seg = (2 * Math.PI) / sides;
var a = ((theta % seg) + seg) % seg - seg / 2;
return R * Math.cos(seg / 2) / Math.cos(a);
}
function angleAt(i) { return (i / POINTS) * Math.PI * 2 - Math.PI / 2; }
function radiiFor(sides) {
var out = [];
for (var i = 0; i < POINTS; i++) out.push(polygonRadius(angleAt(i), sides));
return out;
}
function buildPath(radii) {
var d = '';
for (var i = 0; i < POINTS; i++) {
var a = angleAt(i);
var x = CX + radii[i] * Math.cos(a);
var y = CY + radii[i] * Math.sin(a);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
var currentR = radiiFor(SIDES[tier]);
function lerp(a, b, t) { return a + (b - a) * t; }
function morphTo(sides) {
cancelAnimationFrame(raf);
var target = radiiFor(sides);
if (reduce) { currentR = target; path.setAttribute('d', buildPath(currentR)); return; }
fromR = currentR.slice(); toR = target; t0 = performance.now();
function step(now) {
var t = Math.min(1, (now - t0) / MORPH_MS);
var e = 1 - Math.pow(1 - t, 3);
currentR = fromR.map(function (r, i) { return lerp(r, toR[i], e); });
path.setAttribute('d', buildPath(currentR));
if (t < 1) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function select(i) {
tier = i;
dots.forEach(function (d, idx) {
d.setAttribute('aria-checked', idx === i ? 'true' : 'false');
d.setAttribute('tabindex', idx === i ? '0' : '-1');
});
label.textContent = LEVELS[i];
morphTo(SIDES[i]);
}
dots.forEach(function (d, i) {
d.addEventListener('click', function () { human(); select(i); });
d.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); select(Math.min(LEVELS.length - 1, tier + 1)); dots[tier].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); select(Math.max(0, tier - 1)); dots[tier].focus({ preventScroll: true }); }
});
});
var sequence = [4, 0, 3, 1, 2];
var si = 0;
function tick() { select(sequence[si % sequence.length]); si++; }
var timer = reduce ? null : setInterval(tick, 900);
path.setAttribute('d', buildPath(currentR));
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorIconMorphShape.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const SIDES = [3, 4, 5, 6, 7];
const POINTS = 7, CX = 40, CY = 40, R = 30, MORPH_MS = 420;
const SEQUENCE = [4, 0, 3, 1, 2];
const TICK_MS = 900;
export default function DifficultySelectorIconMorphShape() {
const rootRef = useRef<HTMLDivElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const dotRefs = useRef<HTMLButtonElement[]>([]);
const labelRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const path = pathRef.current;
const label = labelRef.current;
if (!root || !path || !label || !dotRefs.current) return undefined;
const dots = dotRefs.current;
if (dots.length !== LEVELS.length) return undefined;
let tier = 2;
let raf = 0, t0 = 0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 정다각형의 변 수(sides)와 각도(theta)로 그 방향의 반지름을 구하는 기하 공식.
// theta 가 한 변의 중심에서 멀어질수록(꼭짓점에 가까울수록) 반지름이 길어진다
function polygonRadius(theta: number, sides: number) {
const seg = (2 * Math.PI) / sides;
const a = ((theta % seg) + seg) % seg - seg / 2;
return R * Math.cos(seg / 2) / Math.cos(a);
}
function angleAt(i: number) { return (i / POINTS) * Math.PI * 2 - Math.PI / 2; }
function radiiFor(sides: number) {
const out: number[] = [];
for (let i = 0; i < POINTS; i++) out.push(polygonRadius(angleAt(i), sides));
return out;
}
function buildPath(radii: number[]) {
let d = '';
for (let i = 0; i < POINTS; i++) {
const a = angleAt(i);
const x = CX + radii[i] * Math.cos(a);
const y = CY + radii[i] * Math.sin(a);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
let currentR = radiiFor(SIDES[tier]);
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function morphTo(sides: number) {
cancelAnimationFrame(raf);
const target = radiiFor(sides);
if (reduce) { currentR = target; path!.setAttribute('d', buildPath(currentR)); return; }
const fromR = currentR.slice();
t0 = performance.now();
function step(now: number) {
const t = Math.min(1, (now - t0) / MORPH_MS);
const e = 1 - Math.pow(1 - t, 3);
currentR = fromR.map((r, i) => lerp(r, target[i], e));
path!.setAttribute('d', buildPath(currentR));
if (t < 1) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function select(i: number) {
tier = i;
dots.forEach((d, idx) => {
d.setAttribute('aria-checked', idx === i ? 'true' : 'false');
d.setAttribute('tabindex', idx === i ? '0' : '-1');
});
label!.textContent = LEVELS[i];
morphTo(SIDES[i]);
}
const offs = dots.map((d, i) => {
function onClick() { human(); select(i); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); select(Math.min(LEVELS.length - 1, tier + 1)); dots[tier].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); select(Math.max(0, tier - 1)); dots[tier].focus({ preventScroll: true }); }
}
d.addEventListener('click', onClick);
d.addEventListener('keydown', onKey);
return () => { d.removeEventListener('click', onClick); d.removeEventListener('keydown', onKey); };
});
let si = 0;
function tick() { select(SEQUENCE[si % SEQUENCE.length]); si++; }
if (!reduce) timer = window.setInterval(tick, TICK_MS);
path.setAttribute('d', buildPath(currentR));
return () => { offs.forEach((off) => off()); cancelAnimationFrame(raf); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ims + ' ' + styles['is-demo']} ref={rootRef}>
<svg className={styles.imsIcon} viewBox="0 0 80 80" width={64} height={64} aria-hidden="true">
<path ref={pathRef} className={styles.imsShape} />
</svg>
<div className={styles.imsRow} ref={rowRef} role="radiogroup" aria-label="난이도">
{LEVELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.imsDot}
ref={(el: HTMLButtonElement | null) => { if (el && dotRefs.current) dotRefs.current[i] = el; }}
role="radio"
aria-checked={i === 2 ? 'true' : 'false'}
aria-label={label}
data-i={i}
tabIndex={i === 0 ? 0 : -1}
/>
))}
</div>
<p className={styles.imsLabel} ref={labelRef}>어려움</p>
</div>
</main>
);
}05 · 段階ごとにアイコンの形が変形する難易度選択 — 計測 累積面積 2.066% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 正多角形の辺の数(sides)と角度(theta)からその方向の半径を求める幾何公式。
// theta が辺の中心から離れるほど(頂点に近いほど)半径が長くなる
function polygonRadius(theta, sides) {
var seg = (2 * Math.PI) / sides;
var a = ((theta % seg) + seg) % seg - seg / 2;
return R * Math.cos(seg / 2) / Math.cos(a);
}
function angleAt(i) { return (i / POINTS) * Math.PI * 2 - Math.PI / 2; }
function radiiFor(sides) {
var out = [];
for (var i = 0; i < POINTS; i++) out.push(polygonRadius(angleAt(i), sides));
return out;
}
06フォーカスリングが慣性で追いかけるキーボード難易度選択
矢印キーで段階を移すとフォーカスリングは即座に移動せず、毎フレーム残り距離の一部だけを縮める計算で遅れて追いつき止まります。キーボードのみで操作するアクセシビリティ設定で、この選択UIを使う場所を想定した項目です。

<main class="stage">
<div class="kal is-demo" id="kal">
<div class="kal__row" id="kalRow" role="radiogroup" aria-label="難易度">
<div class="kal__ring" id="kalRing" aria-hidden="true"></div>
<button type="button" class="kal__opt" role="radio" aria-checked="true" data-i="0" tabindex="0">簡単</button>
<button type="button" class="kal__opt" role="radio" aria-checked="false" data-i="1" tabindex="-1">普通</button>
<button type="button" class="kal__opt" role="radio" aria-checked="false" data-i="2" tabindex="-1">難しい</button>
<button type="button" class="kal__opt" role="radio" aria-checked="false" data-i="3" tabindex="-1">とても難しい</button>
<button type="button" class="kal__opt" role="radio" aria-checked="false" data-i="4" tabindex="-1">最高</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
// 계산 겹: loop() 가 매 프레임 (목표-현재)*TRAIL_LERP 로 남은 거리의 일부만 좁히는 지수 감쇠를 적분한다(JS)
@include stage($stage-yellow);
.kal__row { position: relative; display: flex; gap: $sp-2; padding: $sp-1; }
.kal__ring {
position: absolute; top: $sp-1; bottom: $sp-1; left: 0; width: 0;
border: 2px solid $color; border-radius: $r-control; pointer-events: none;
}
.kal__opt {
position: relative; appearance: none; border: 0; border-radius: $r-control;
background: rgba($subject-ink, .08); color: $subject-ink; font-weight: 700; font-size: 12px;
padding: $sp-2 $sp-3; cursor: pointer; white-space: nowrap;
}
.kal__opt[aria-checked="true"] { background: $subject-ink; color: $subject-cream; }
.kal__opt:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
@media (max-width: 340px) {
.kal__row { flex-wrap: wrap; max-width: 260px; }
.kal__opt { font-size: 11px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: moveRingTo() 안에서 reduce 를 확인해 lerp 루프 없이 고리가 목표로 바로 이동한다(JS 쪽 처리)
.kal__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kal__row {
position: relative;
display: flex;
gap: 8px;
padding: 4px;
}
.kal__ring {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
width: 0;
border: 2px solid #2f6df6;
border-radius: 12px;
pointer-events: none;
}
.kal__opt {
position: relative;
appearance: none;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 12px;
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
}
.kal__opt[aria-checked=true] {
background: #17141a;
color: #fff7e6;
}
.kal__opt:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.kal__row {
flex-wrap: wrap;
max-width: 260px;
}
.kal__opt {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kal__ring {
transition: none;
}
}(function () {
var TRAIL_LERP = 0.18;
var root = document.getElementById('kal');
var rowEl = document.getElementById('kalRow');
var ring = document.getElementById('kalRing');
var opts = Array.prototype.slice.call(rowEl.querySelectorAll('.kal__opt'));
var index = 0;
var ringX = 0, ringW = 0, targetX = 0, targetW = 0, raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function measure(i) {
var r = opts[i].getBoundingClientRect();
var pr = rowEl.getBoundingClientRect();
return { x: r.left - pr.left, w: r.width };
}
function paintRing(x, w) { ring.style.transform = 'translateX(' + x + 'px)'; ring.style.width = w + 'px'; }
// 계산 겹: 매 프레임 남은 거리의 TRAIL_LERP 비율만 좁히는 지수 감쇠(lerp)로 고리가 목표를 뒤따라온다
function loop() {
ringX += (targetX - ringX) * TRAIL_LERP;
ringW += (targetW - ringW) * TRAIL_LERP;
paintRing(ringX, ringW);
if (Math.abs(targetX - ringX) > 0.4 || Math.abs(targetW - ringW) > 0.4) raf = requestAnimationFrame(loop);
else { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); }
}
function moveRingTo(i) {
var m = measure(i);
targetX = m.x; targetW = m.w;
cancelAnimationFrame(raf);
if (reduce) { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); return; }
raf = requestAnimationFrame(loop);
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function select(i) {
index = i;
opts.forEach(function (o, idx) {
o.setAttribute('aria-checked', idx === i ? 'true' : 'false');
o.setAttribute('tabindex', idx === i ? '0' : '-1');
});
moveRingTo(i);
}
opts.forEach(function (o, i) {
o.addEventListener('click', function () { human(); select(i); opts[i].focus({ preventScroll: true }); });
o.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); human(); var n = Math.min(opts.length - 1, index + 1); select(n); opts[n].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); human(); var p = Math.max(0, index - 1); select(p); opts[p].focus({ preventScroll: true }); }
else if (e.key === 'Home') { e.preventDefault(); human(); select(0); opts[0].focus({ preventScroll: true }); }
else if (e.key === 'End') { e.preventDefault(); human(); select(opts.length - 1); opts[opts.length - 1].focus({ preventScroll: true }); }
});
});
var sequence = [3, 1, 4, 0, 2];
var si = 0;
function tick() { select(sequence[si % sequence.length]); si++; }
var timer = reduce ? null : setInterval(tick, 850);
var m0 = measure(0); ringX = m0.x; ringW = m0.w; targetX = m0.x; targetW = m0.w; paintRing(ringX, ringW);
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorKeyboardArrowLerp.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const TRAIL_LERP = 0.18;
const SEQUENCE = [3, 1, 4, 0, 2];
const TICK_MS = 850;
export default function DifficultySelectorKeyboardArrowLerp() {
const rootRef = useRef<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const optRefs = useRef<HTMLButtonElement[]>([]);
useEffect(() => {
const root = rootRef.current;
const rowEl = rowRef.current;
const ring = ringRef.current;
if (!root || !rowEl || !ring || !optRefs.current) return undefined;
const opts = optRefs.current;
if (opts.length !== LEVELS.length) return undefined;
let index = 0;
let ringX = 0, ringW = 0, targetX = 0, targetW = 0, raf = 0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function measure(i: number) {
const r = opts[i].getBoundingClientRect();
const pr = rowEl!.getBoundingClientRect();
return { x: r.left - pr.left, w: r.width };
}
function paintRing(x: number, w: number) { ring!.style.transform = 'translateX(' + x + 'px)'; ring!.style.width = w + 'px'; }
// 계산 겹: 매 프레임 남은 거리의 TRAIL_LERP 비율만 좁히는 지수 감쇠(lerp)로 고리가 목표를 뒤따라온다
function loop() {
ringX += (targetX - ringX) * TRAIL_LERP;
ringW += (targetW - ringW) * TRAIL_LERP;
paintRing(ringX, ringW);
if (Math.abs(targetX - ringX) > 0.4 || Math.abs(targetW - ringW) > 0.4) raf = requestAnimationFrame(loop);
else { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); }
}
function moveRingTo(i: number) {
const m = measure(i);
targetX = m.x; targetW = m.w;
cancelAnimationFrame(raf);
if (reduce) { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); return; }
raf = requestAnimationFrame(loop);
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function select(i: number) {
index = i;
opts.forEach((o, idx) => {
o.setAttribute('aria-checked', idx === i ? 'true' : 'false');
o.setAttribute('tabindex', idx === i ? '0' : '-1');
});
moveRingTo(i);
}
const offs = opts.map((o, i) => {
function onClick() { human(); select(i); opts[i].focus({ preventScroll: true }); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight') { e.preventDefault(); human(); const n = Math.min(opts.length - 1, index + 1); select(n); opts[n].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); human(); const p = Math.max(0, index - 1); select(p); opts[p].focus({ preventScroll: true }); }
else if (e.key === 'Home') { e.preventDefault(); human(); select(0); opts[0].focus({ preventScroll: true }); }
else if (e.key === 'End') { e.preventDefault(); human(); select(opts.length - 1); opts[opts.length - 1].focus({ preventScroll: true }); }
}
o.addEventListener('click', onClick);
o.addEventListener('keydown', onKey);
return () => { o.removeEventListener('click', onClick); o.removeEventListener('keydown', onKey); };
});
let si = 0;
function tick() { select(SEQUENCE[si % SEQUENCE.length]); si++; }
if (!reduce) timer = window.setInterval(tick, TICK_MS);
const m0 = measure(0); ringX = m0.x; ringW = m0.w; targetX = m0.x; targetW = m0.w; paintRing(ringX, ringW);
return () => { offs.forEach((off) => off()); cancelAnimationFrame(raf); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.kal + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.kalRow} ref={rowRef} role="radiogroup" aria-label="난이도">
<div className={styles.kalRing} ref={ringRef} aria-hidden="true" />
{LEVELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.kalOpt}
ref={(el: HTMLButtonElement | null) => { if (el && optRefs.current) optRefs.current[i] = el; }}
role="radio"
aria-checked={i === 0 ? 'true' : 'false'}
data-i={i}
tabIndex={i === 0 ? 0 : -1}
>
{label}
</button>
))}
</div>
</div>
</main>
);
}06 · フォーカスリングが慣性で追いかけるキーボード難易度選択 — 計測 累積面積 5.865% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 毎フレーム残り距離の TRAIL_LERP の割合だけ縮める指数減衰(lerp)で、リングが目標を追いかける
function loop() {
ringX += (targetX - ringX) * TRAIL_LERP;
ringW += (targetW - ringW) * TRAIL_LERP;
paintRing(ringX, ringW);
if (Math.abs(targetX - ringX) > 0.4 || Math.abs(targetW - ringW) > 0.4) raf = requestAnimationFrame(loop);
else { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); }
}
function moveRingTo(i) {
var m = measure(i);
targetX = m.x; targetW = m.w;
cancelAnimationFrame(raf);
if (reduce) { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); return; }
raf = requestAnimationFrame(loop);
}
07色が数式でつながるバッジ型難易度選択
5つのバッジは固定された5色ではなく段階番号を色相角の計算式に入れて作った色のため、段階を増やすとバッジ間の色の間隔も再計算されます。管理画面で難易度等級をタグのように付ける場所を想定しています。

<main class="stage">
<div class="bhs is-demo" id="bhs" role="radiogroup" aria-label="難易度">
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="0" tabindex="0">簡単</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="1" tabindex="-1">普通</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="true" data-i="2" tabindex="-1">難しい</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="3" tabindex="-1">とても難しい</button>
<button type="button" class="bhs__badge" role="radio" aria-checked="false" data-i="4" tabindex="-1">最高</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 = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
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 · 色が数式でつながるバッジ型難易度選択 — 計測 累積面積 4.045% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: バッジの色を固定5色ではなく、段階番号を色相角の数式(基準色相 + 番号*間隔)に入れて求める
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');
});
}
08数字が走行距離計のように回る難易度ステッパー
+/-ボタンを押すと数字の一桁が上下に回って次の値へ移り、その位置は桁の位取り計算から求められ、繰り上がりが起きると左の桁も一緒に回ります。フォームで設問の難易度点数を数字で直接決める場所を想定しており、03のスライダーや09のゲージと違って「今どの段階か」を角度や影ではなく数字そのもので伝える点が特徴です。

<main class="stage">
<div class="nso is-demo" id="nso">
<div class="nso__display" id="nsoDisplay" role="spinbutton" tabindex="0"
aria-label="難易度スコア" aria-valuemin="1" aria-valuemax="12" aria-valuenow="5">
<div class="nso__col"><div class="nso__strip" id="nsoTens">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
</div></div>
<div class="nso__col"><div class="nso__strip" id="nsoOnes">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
</div></div>
</div>
<div class="nso__row">
<button type="button" class="nso__btn" id="nsoMinus" aria-label="スコアを下げる">−</button>
<button type="button" class="nso__btn" id="nsoPlus" aria-label="スコアを上げる">+</button>
</div>
<p class="nso__label">難易度スコア <span id="nsoOut">5</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
// 계산 겹: placeDigit() 가 값을 10^자리로 나눈 몫의 일의 자리를 구해 그 자릿값 * 칸 높이만큼 릴을 밀어낸다(JS)
@include stage($stage-paper);
.nso { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.nso__display {
display: flex; background: $subject-ink; border-radius: $r-card; padding: $sp-2 $sp-3;
cursor: pointer;
}
.nso__display:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.nso__col { width: 20px; height: 26px; overflow: hidden; position: relative; }
.nso__strip {
display: flex; flex-direction: column;
transition: transform $duration $easing;
}
.nso__strip span {
height: 26px; line-height: 26px; text-align: center; font-weight: 700; font-size: 18px;
color: $subject-mint; font-variant-numeric: tabular-nums;
}
.nso__row { display: flex; gap: $sp-2; }
.nso__btn {
appearance: none; border: 0; border-radius: $r-control; width: 32px; height: 32px;
background: rgba($subject-mint, .18); color: $subject-ink; font-weight: 700; font-size: 16px; cursor: pointer;
}
.nso__btn:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.nso__label { font-size: 12px; color: $subject-ink; }
@media (max-width: 340px) {
.nso__col { width: 18px; }
.nso__strip span { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 실제로 +/-·화살표로 값을 바꿔도 릴은 트랜지션 없이 즉시 그 자리로 넘어간다
.nso__strip { 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);
}
.nso {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.nso__display {
display: flex;
background: #17141a;
border-radius: 16px;
padding: 8px 12px;
cursor: pointer;
}
.nso__display:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.nso__col {
width: 20px;
height: 26px;
overflow: hidden;
position: relative;
}
.nso__strip {
display: flex;
flex-direction: column;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nso__strip span {
height: 26px;
line-height: 26px;
text-align: center;
font-weight: 700;
font-size: 18px;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.nso__row {
display: flex;
gap: 8px;
}
.nso__btn {
appearance: none;
border: 0;
border-radius: 12px;
width: 32px;
height: 32px;
background: rgba(76, 212, 166, 0.18);
color: #17141a;
font-weight: 700;
font-size: 16px;
cursor: pointer;
}
.nso__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.nso__label {
font-size: 12px;
color: #17141a;
}
@media (max-width: 340px) {
.nso__col {
width: 18px;
}
.nso__strip span {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.nso__strip {
transition: none;
}
}(function () {
var MIN = 1, MAX = 12, DIGIT_H = 26;
var root = document.getElementById('nso');
var display = document.getElementById('nsoDisplay');
var tensStrip = document.getElementById('nsoTens');
var onesStrip = document.getElementById('nsoOnes');
var out = document.getElementById('nsoOut');
var value = 5;
// 계산 겹: 값을 10^자리로 나눈 몫의 일의 자리(placeDigit)로 각 릴이 보여줄 숫자를 구하고,
// 그 자릿값 * 칸 높이만큼 릴을 밀어 그 숫자가 창에 걸리게 한다 — 자리올림은 몫이 바뀌면서 자연히 생긴다
function placeDigit(v, place) { return Math.floor(v / Math.pow(10, place)) % 10; }
function paint() {
var ones = placeDigit(value, 0), tens = placeDigit(value, 1);
onesStrip.style.transform = 'translateY(-' + (ones * DIGIT_H) + 'px)';
tensStrip.style.transform = 'translateY(-' + (tens * DIGIT_H) + 'px)';
display.setAttribute('aria-valuenow', String(value));
out.textContent = String(value);
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function set(v) { value = Math.max(MIN, Math.min(MAX, v)); paint(); }
document.getElementById('nsoPlus').addEventListener('click', function () { human(); set(value + 1); });
document.getElementById('nsoMinus').addEventListener('click', function () { human(); set(value - 1); });
display.addEventListener('keydown', function (e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); human(); set(value + 1); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); human(); set(value - 1); }
});
var sequence = [9, 10, 3, 12, 1, 7];
var si = 0;
function tick() { set(sequence[si % sequence.length]); si++; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 850);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorNumericStepperOdometer.module.scss';
const MIN = 1, MAX = 12, DIGIT_H = 26;
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const SEQUENCE = [9, 10, 3, 12, 1, 7];
const TICK_MS = 850;
export default function DifficultySelectorNumericStepperOdometer() {
const rootRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const tensRef = useRef<HTMLDivElement>(null);
const onesRef = useRef<HTMLDivElement>(null);
const outRef = useRef<HTMLSpanElement>(null);
const plusRef = useRef<HTMLButtonElement>(null);
const minusRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const display = displayRef.current;
const tensStrip = tensRef.current;
const onesStrip = onesRef.current;
const out = outRef.current;
const plus = plusRef.current;
const minus = minusRef.current;
if (!root || !display || !tensStrip || !onesStrip || !out || !plus || !minus) return undefined;
let value = 5;
// 계산 겹: 값을 10^자리로 나눈 몫의 일의 자리(placeDigit)로 각 릴이 보여줄 숫자를 구하고,
// 그 자릿값 * 칸 높이만큼 릴을 밀어 그 숫자가 창에 걸리게 한다 — 자리올림은 몫이 바뀌면서 자연히 생긴다
function placeDigit(v: number, place: number) { return Math.floor(v / Math.pow(10, place)) % 10; }
function paint() {
const ones = placeDigit(value, 0), tens = placeDigit(value, 1);
onesStrip!.style.transform = 'translateY(-' + (ones * DIGIT_H) + 'px)';
tensStrip!.style.transform = 'translateY(-' + (tens * DIGIT_H) + 'px)';
display!.setAttribute('aria-valuenow', String(value));
out!.textContent = String(value);
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function set(v: number) { value = Math.max(MIN, Math.min(MAX, v)); paint(); }
function onPlus() { human(); set(value + 1); }
function onMinus() { human(); set(value - 1); }
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); human(); set(value + 1); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); human(); set(value - 1); }
}
plus.addEventListener('click', onPlus);
minus.addEventListener('click', onMinus);
display.addEventListener('keydown', onKey);
let si = 0;
function tick() { set(SEQUENCE[si % SEQUENCE.length]); si++; }
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => {
plus!.removeEventListener('click', onPlus);
minus!.removeEventListener('click', onMinus);
display!.removeEventListener('keydown', onKey);
window.clearInterval(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.nso + ' ' + styles['is-demo']} ref={rootRef}>
<div
className={styles.nsoDisplay}
ref={displayRef}
role="spinbutton"
tabIndex={0}
aria-label="난이도 점수"
aria-valuemin={1}
aria-valuemax={12}
aria-valuenow={5}
>
<div className={styles.nsoCol}>
<div className={styles.nsoStrip} ref={tensRef}>
{DIGITS.map((d) => <span key={d}>{d}</span>)}
</div>
</div>
<div className={styles.nsoCol}>
<div className={styles.nsoStrip} ref={onesRef}>
{DIGITS.map((d) => <span key={d}>{d}</span>)}
</div>
</div>
</div>
<div className={styles.nsoRow}>
<button type="button" className={styles.nsoBtn} ref={minusRef} aria-label="점수 낮추기">−</button>
<button type="button" className={styles.nsoBtn} ref={plusRef} aria-label="점수 높이기">+</button>
</div>
<p className={styles.nsoLabel}>난이도 점수 <span ref={outRef}>5</span></p>
</div>
</main>
);
}08 · 数字が走行距離計のように回る難易度ステッパー — 計測 累積面積 0.688% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 値を10のべき乗で割った商の一の位(placeDigit)で各リールが見せる数字を求め、
// その桁の値 × 段の高さの分だけリールを押し出して窓にその数字を合わせる — 繰り上がりは商が変わることで自然に起きる
function placeDigit(v, place) { return Math.floor(v / Math.pow(10, place)) % 10; }
function paint() {
var ones = placeDigit(value, 0), tens = placeDigit(value, 1);
onesStrip.style.transform = 'translateY(-' + (ones * DIGIT_H) + 'px)';
tensStrip.style.transform = 'translateY(-' + (tens * DIGIT_H) + 'px)';
display.setAttribute('aria-valuenow', String(value));
out.textContent = String(value);
}
09ドラッグ角度で値が決まる半円型難易度ゲージ
針を指で引くと中心から指までの角度を求めてその角度を難易度の値へ変換し、弧の中の色分けされた区間もその値の範囲に合わせて描き直します。設定ダッシュボードで指先で難易度を微調整する場所を想定しており、08のステッパーが1段ずつ動くのに対しこちらはドラッグ中に連続した角度から値を求める点が違います。

<main class="stage">
<div class="gad is-demo" id="gad">
<div class="gad__face" id="gadFace" role="slider" tabindex="0"
aria-label="難易度" aria-valuemin="0" aria-valuemax="4" aria-valuenow="2" aria-valuetext="難しい">
<div class="gad__zones" aria-hidden="true">
<span class="gad__zone" data-i="0"></span>
<span class="gad__zone" data-i="1"></span>
<span class="gad__zone" data-i="2"></span>
<span class="gad__zone" data-i="3"></span>
<span class="gad__zone" data-i="4"></span>
</div>
<div class="gad__needle" id="gadNeedle" aria-hidden="true"></div>
<div class="gad__pivot" aria-hidden="true"></div>
</div>
<p class="gad__label">難易度 <span id="gadOut">難しい</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-pink;
// 계산 겹: angleToValue() 가 포인터-피벗 각도(atan2)를 0~4 값으로 매핑해 게이지를 직접 끌어서 정한다(JS)
@include stage($stage-ink);
.gad { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.gad__face {
position: relative; width: 200px; height: 108px; cursor: grab; touch-action: none;
}
.gad__face:focus-visible { outline: 2px solid $subject-cream; outline-offset: 4px; }
.gad__zones { position: absolute; inset: 0; }
.gad__zone {
position: absolute; left: 50%; bottom: 0; width: 4px; height: 44px;
margin-left: -2px; transform-origin: bottom center; border-radius: $r-xs;
background: rgba($subject-cream, .28);
transition: background $duration $easing, height $duration $easing, width $duration $easing;
}
.gad__zone[data-i="0"] { background: rgba($subject-mint, .5); }
.gad__zone[data-i="1"] { background: rgba($subject-sky, .5); }
.gad__zone[data-i="2"] { background: rgba($subject-lilac, .5); }
.gad__zone[data-i="3"] { background: rgba($subject-orange-deep, .5); }
.gad__zone[data-i="4"] { background: rgba($color, .5); }
.gad__zone.is-active { height: 52px; width: 6px; margin-left: -3px; }
.gad__zone[data-i="0"].is-active { background: $subject-mint; }
.gad__zone[data-i="1"].is-active { background: $subject-sky; }
.gad__zone[data-i="2"].is-active { background: $subject-lilac; }
.gad__zone[data-i="3"].is-active { background: $subject-orange-deep; }
.gad__zone[data-i="4"].is-active { background: $color; }
.gad__needle {
position: absolute; left: 50%; bottom: 0; width: 3px; height: 58px; margin-left: -1.5px;
background: $subject-cream; border-radius: $r-xs; transform-origin: bottom center;
transition: transform $duration $easing;
}
.gad__pivot {
position: absolute; left: 50%; bottom: -5px; width: 12px; height: 12px; margin-left: -6px;
border-radius: 50%; background: $subject-cream;
}
.gad__label { font-size: 12px; color: $subject-cream; }
@media (max-width: 340px) {
.gad__face { width: 168px; height: 92px; }
.gad__zone { height: 36px; }
.gad__needle { height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 드래그 중엔 원래도 매 프레임 즉시 반영이라 트랜지션이 관여하지 않지만,
// 키보드로 값이 바뀔 때는 바늘·구역이 트랜지션 없이 즉시 목표 각도로 이동한다
.gad__needle, .gad__zone { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gad {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.gad__face {
position: relative;
width: 200px;
height: 108px;
cursor: grab;
touch-action: none;
}
.gad__face:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 4px;
}
.gad__zones {
position: absolute;
inset: 0;
}
.gad__zone {
position: absolute;
left: 50%;
bottom: 0;
width: 4px;
height: 44px;
margin-left: -2px;
transform-origin: bottom center;
border-radius: 4px;
background: rgba(255, 247, 230, 0.28);
transition: background 220ms ease-out, height 220ms ease-out, width 220ms ease-out;
}
.gad__zone[data-i="0"] {
background: rgba(76, 212, 166, 0.5);
}
.gad__zone[data-i="1"] {
background: rgba(110, 200, 255, 0.5);
}
.gad__zone[data-i="2"] {
background: rgba(185, 160, 255, 0.5);
}
.gad__zone[data-i="3"] {
background: rgba(201, 48, 0, 0.5);
}
.gad__zone[data-i="4"] {
background: rgba(255, 158, 187, 0.5);
}
.gad__zone.is-active {
height: 52px;
width: 6px;
margin-left: -3px;
}
.gad__zone[data-i="0"].is-active {
background: #4cd4a6;
}
.gad__zone[data-i="1"].is-active {
background: #6ec8ff;
}
.gad__zone[data-i="2"].is-active {
background: #b9a0ff;
}
.gad__zone[data-i="3"].is-active {
background: #c93000;
}
.gad__zone[data-i="4"].is-active {
background: #ff9ebb;
}
.gad__needle {
position: absolute;
left: 50%;
bottom: 0;
width: 3px;
height: 58px;
margin-left: -1.5px;
background: #fff7e6;
border-radius: 4px;
transform-origin: bottom center;
transition: transform 220ms ease-out;
}
.gad__pivot {
position: absolute;
left: 50%;
bottom: -5px;
width: 12px;
height: 12px;
margin-left: -6px;
border-radius: 50%;
background: #fff7e6;
}
.gad__label {
font-size: 12px;
color: #fff7e6;
}
@media (max-width: 340px) {
.gad__face {
width: 168px;
height: 92px;
}
.gad__zone {
height: 36px;
}
.gad__needle {
height: 48px;
}
}
@media (prefers-reduced-motion: reduce) {
.gad__needle, .gad__zone {
transition: none;
}
}(function () {
var LEVELS = ['簡単', '普通', '難しい', 'とても難しい', '最高'];
var ZONE_DEG = 36; // 180deg / 5구역
var root = document.getElementById('gad');
var face = document.getElementById('gadFace');
var needle = document.getElementById('gadNeedle');
var zones = Array.prototype.slice.call(face.querySelectorAll('.gad__zone'));
var out = document.getElementById('gadOut');
var value = 2; // 0..4 연속값
var dragging = false;
function zoneCenterDeg(i) { return -90 + ZONE_DEG / 2 + i * ZONE_DEG; }
zones.forEach(function (z, i) { z.style.transform = 'rotate(' + zoneCenterDeg(i) + 'deg)'; });
// 계산 겹: 포인터 좌표와 피벗(바닥 중앙) 사이의 각도를 atan2 로 구해 -90~90도 구간을 0~4 값으로 매핑한다
function angleToValue(clientX, clientY) {
var r = face.getBoundingClientRect();
var px = r.left + r.width / 2;
var py = r.bottom;
var dx = clientX - px;
var dy = Math.min(clientY - py, -1); // 피벗 위쪽으로 강제해 좌측 경계에서 각도가 튀지 않게 한다
var theta = Math.atan2(dy, dx) * 180 / Math.PI; // -180..0
var t = (theta + 180) / 180;
t = Math.max(0, Math.min(1, t));
return t * 4;
}
function paint() {
var deg = -90 + (value / 4) * 180;
needle.style.transform = 'rotate(' + deg + 'deg)';
var zi = Math.round(value);
zones.forEach(function (z, i) { z.classList.toggle('is-active', i === zi); });
face.setAttribute('aria-valuenow', String(zi));
face.setAttribute('aria-valuetext', LEVELS[zi]);
out.textContent = LEVELS[zi];
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function setValue(v) { value = Math.max(0, Math.min(4, v)); paint(); }
face.addEventListener('pointerdown', function (e) {
human(); dragging = true; face.setPointerCapture(e.pointerId);
setValue(angleToValue(e.clientX, e.clientY));
});
face.addEventListener('pointermove', function (e) {
if (!dragging) return;
setValue(angleToValue(e.clientX, e.clientY));
});
face.addEventListener('pointerup', function (e) {
dragging = false; setValue(Math.round(value));
});
face.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); setValue(Math.round(value) + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); setValue(Math.round(value) - 1); }
else if (e.key === 'Home') { e.preventDefault(); human(); setValue(0); }
else if (e.key === 'End') { e.preventDefault(); human(); setValue(4); }
});
var sequence = [4, 0, 3, 1, 2];
var si = 0;
function tick() { setValue(sequence[si % sequence.length]); si++; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 780);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorGaugeArcDrag.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const ZONE_DEG = 36;
const SEQUENCE = [4, 0, 3, 1, 2];
const TICK_MS = 780;
export default function DifficultySelectorGaugeArcDrag() {
const rootRef = useRef<HTMLDivElement>(null);
const faceRef = useRef<HTMLDivElement>(null);
const needleRef = useRef<HTMLDivElement>(null);
const zoneRefs = useRef<HTMLSpanElement[]>([]);
const outRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const face = faceRef.current;
const needle = needleRef.current;
const out = outRef.current;
if (!root || !face || !needle || !out || !zoneRefs.current) return undefined;
const zones = zoneRefs.current;
if (zones.length !== LEVELS.length) return undefined;
let value = 2;
let dragging = false;
function zoneCenterDeg(i: number) { return -90 + ZONE_DEG / 2 + i * ZONE_DEG; }
zones.forEach((z, i) => { z.style.transform = 'rotate(' + zoneCenterDeg(i) + 'deg)'; });
// 계산 겹: 포인터 좌표와 피벗(바닥 중앙) 사이의 각도를 atan2 로 구해 -90~90도 구간을 0~4 값으로 매핑한다
function angleToValue(clientX: number, clientY: number) {
const r = face!.getBoundingClientRect();
const px = r.left + r.width / 2;
const py = r.bottom;
const dx = clientX - px;
const dy = Math.min(clientY - py, -1);
const theta = Math.atan2(dy, dx) * 180 / Math.PI;
let t = (theta + 180) / 180;
t = Math.max(0, Math.min(1, t));
return t * 4;
}
function paint() {
const deg = -90 + (value / 4) * 180;
needle!.style.transform = 'rotate(' + deg + 'deg)';
const zi = Math.round(value);
zones.forEach((z, i) => { z.classList.toggle(styles['is-active'], i === zi); });
face!.setAttribute('aria-valuenow', String(zi));
face!.setAttribute('aria-valuetext', LEVELS[zi]);
out!.textContent = LEVELS[zi];
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function setValue(v: number) { value = Math.max(0, Math.min(4, v)); paint(); }
function onDown(e: PointerEvent) {
human(); dragging = true; face!.setPointerCapture(e.pointerId);
setValue(angleToValue(e.clientX, e.clientY));
}
function onMove(e: PointerEvent) {
if (!dragging) return;
setValue(angleToValue(e.clientX, e.clientY));
}
function onUp() {
dragging = false; setValue(Math.round(value));
}
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); setValue(Math.round(value) + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); setValue(Math.round(value) - 1); }
else if (e.key === 'Home') { e.preventDefault(); human(); setValue(0); }
else if (e.key === 'End') { e.preventDefault(); human(); setValue(4); }
}
face.addEventListener('pointerdown', onDown);
face.addEventListener('pointermove', onMove);
face.addEventListener('pointerup', onUp);
face.addEventListener('keydown', onKey);
let si = 0;
function tick() { setValue(SEQUENCE[si % SEQUENCE.length]); si++; }
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => {
face!.removeEventListener('pointerdown', onDown);
face!.removeEventListener('pointermove', onMove);
face!.removeEventListener('pointerup', onUp);
face!.removeEventListener('keydown', onKey);
window.clearInterval(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.gad + ' ' + styles['is-demo']} ref={rootRef}>
<div
className={styles.gadFace}
ref={faceRef}
role="slider"
tabIndex={0}
aria-label="난이도"
aria-valuemin={0}
aria-valuemax={4}
aria-valuenow={2}
aria-valuetext="어려움"
>
<div className={styles.gadZones} aria-hidden="true">
{LEVELS.map((_, i) => (
<span key={i} className={styles.gadZone} data-i={i} ref={(el: HTMLSpanElement | null) => { if (el && zoneRefs.current) zoneRefs.current[i] = el; }} />
))}
</div>
<div className={styles.gadNeedle} ref={needleRef} aria-hidden="true" />
<div className={styles.gadPivot} aria-hidden="true" />
</div>
<p className={styles.gadLabel}>난이도 <span ref={outRef}>어려움</span></p>
</div>
</main>
);
}09 · ドラッグ角度で値が決まる半円型難易度ゲージ — 計測 累積面積 2.203% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function zoneCenterDeg(i) { return -90 + ZONE_DEG / 2 + i * ZONE_DEG; }
zones.forEach(function (z, i) { z.style.transform = 'rotate(' + zoneCenterDeg(i) + 'deg)'; });
// 計算層: ポインタ座標とピボット(底の中央)の間の角度を atan2 で求め、-90〜90度の区間を0〜4の値に写す
function angleToValue(clientX, clientY) {
var r = face.getBoundingClientRect();
var px = r.left + r.width / 2;
var py = r.bottom;
var dx = clientX - px;
var dy = Math.min(clientY - py, -1); // ピボットの上方向に強制し、左端で角度が跳ねないようにする
var theta = Math.atan2(dy, dx) * 180 / Math.PI; // -180..0
var t = (theta + 180) / 180;
t = Math.max(0, Math.min(1, t));
return t * 4;
}
値がUIを変える場所 — 段階数・色相角の間隔・ばね減衰
同じ計算式でも定数を変えるだけで感触が変わります。値を変えるとどう見えるかを実測込みで説明します。
03 SPRING_D — ばねの減衰の強さ
springTo()のforce = -SPRING_K*(pos-target) - SPRING_D*velという式で、SPRING_Dは速度に比例するブレーキ役です。数値を上げるほど同じSPRING_Kに対して振動が早く収まり、つまみが段階を行き過ぎる度合いが小さくなります。
07 HUE_SPACING — バッジ間の色相角の間隔
badgeHue(i) = (BASE_HUE + i*HUE_SPACING) % 360という式で、HUE_SPACINGはバッジ番号1つ分あたり色相角がどれだけ離れるかを決めます。数値を広げると5つのバッジの色がより離れ、隣同士の区別がつきやすくなります。
06 TRAIL_LERP — フォーカスリングの追いつく速さ
ringX += (targetX - ringX) * TRAIL_LERPという式で、TRAIL_LERPは毎フレーム残り距離のうち何割を縮めるかを決めます。数値を下げるとリングが1フレームに進む距離が減り、目標までゆっくり滑らかに追いつくようになります。
| 変形 | 値 | 効果 |
|---|---|---|
| ばねの減衰値を上げてオーバーシュートを減らす | 03 SPRING_D 18 → 26 | SPRING_D を18から26に上げると同じSPRING_K=210に対して振動がより早く収まり、スライダーのつまみが段階を行き過ぎる度合いが減って落ち着いて止まる — Dが作ったspringTo()の数式(force = -SPRING_K(pos-target) - SPRING_Dvel)で確認済み。 |
| バッジの色相角の間隔を広げる | 07 HUE_SPACING 46 → 64 | HUE_SPACING を46から64に広げるとbadgeHue(i) = (BASE_HUE + i*HUE_SPACING) % 360の計算値が互いに離れ、5つのバッジの色がはっきり区別できる — Dが作ったbadgeHue()の数式で確認済み。 |
| フォーカスリングの慣性lerp値を下げる | 06 TRAIL_LERP 0.18 → 0.10 | TRAIL_LERP を0.18から0.10に下げるとフォーカスリングが毎フレーム縮める距離の割合が減り、目標の段階へよりゆっくり滑らかに追いつく — Dが作ったringX += (targetX-ringX)*TRAIL_LERPの数式で確認済み。 |
どこで壊れるか — アクセシビリティ・reduced-motion・キーボードフォーカス
9つともprefers-reduced-motion: reduceをwindow.matchMediaで読み取り、条件が真ならアニメーションの経路を切って最終状態へ直接切り替えます。デモの自動再生だけを止めるものと、ユーザー操作時のトランジションまで止めるものがあるので、項目ごとに実際のふるまいを挙げます。
- 01 ドット式:
pointerenter・pointerleave・click・keydownを扱い、CSSの.dr__fill, .dr__dot { transition: none; }でreduced-motion時は色の遷移を切ります。 - 02 カード型:
click・keydownを扱い、CSSの.rc__cardにreduced-motion用の遷移解除ルールがあります。 - 03 スライダー:
keydown・pointerdown/pointermove/pointerupを扱い、reduceが真ならspringTo()はばね計算を飛ばして目標値へ即座に移ります。 - 04 バーゲージ:
click・keydown・pointerdownを扱い、reduceが真ならsweep()は光沢を走らせず不透明度を0のままにします。 - 05 アイコン変形:
click・keydownを扱い、CSSの.ims__dotにreduced-motion用の遷移解除ルールがあります。 - 06 キーボードフォーカス:
click・keydownを扱い、reduceが真ならmoveRingTo()はlerpのループを回さずリングを目標位置へ直接置きます。 - 07 バッジ型:
click・keydownを扱い、CSSの.bhs__badgeにreduced-motion用の遷移解除ルールがあります。 - 08 数字ステッパー:
click・keydownを扱い、CSSの.nso__stripにreduced-motion用の遷移解除ルールがあります。 - 09 半円ゲージ:
keydown・pointerdown/pointermove/pointerupを扱い、CSSの.gad__needle, .gad__zoneにreduced-motion用の遷移解除ルールがあります。
zipにはHTML・CSS・JSがそのまま入っていて、パスワード m4ceuurv で解凍するとどの項目のソースもすぐコピーできます。
3つのバリエーションとアクセシビリティ
上の3つの変形は数値を書き換えるだけで試せます。SPRING_D・HUE_SPACING・TRAIL_LERPのどれも1か所の定数なので、自分のプロジェクトの雰囲気に合わせて調整してからそのまま使ってください。
難易度選択 ui の実装例はどこで見られる?
「ゲームの難易度設定」で検索すると、ゲーム内設定の見た目だけを真似たキャプチャ画像が多く、実際に動くコードごと持ち帰れる例は多くありません。この記事の9つはどれもライブラリ・CDNなしのHTML・CSS・JSだけで完結していて、デモの右上からコードをコピーするか、zipを解凍してそのままプロジェクトに置けます。
FAQ
なぜ06のキーボードフォーカスリングは平均強度が9種の中で最も高いのですか?
moveRingTo()がrequestAnimationFrameのループで毎フレームringXとringWをTRAIL_LERPの割合だけ動かし続けるため、リングが目標位置に落ち着くまでの間ずっとピクセル差が発生し続けます。この間続くわずかな差の積み重ねが、実測で平均強度144.7という9種中最高の値になっています。
03のスライダーはばねのオーバーシュートで有効な段階の外まで動くことがありますか?
springTo()はposを段階の百分率位置(tierPct()が返す値)に向けて動かしますが、step()内で位置の許容誤差(0.15)まで収束させてからpos = targetで確定させるため、表示上の見た目のオーバーシュートはあっても最終的に選択される段階そのものはsettle()が受け取った整数の段階番号から外れません。
このような計算で作った選択UIを実際の設定画面にそのまま使っても安全ですか?
9つともrole・aria-checked・aria-valuenowなどのARIA属性とキーボード操作、prefers-reduced-motionへの対応をソースに含めていますが、実際のプロダクトで使う前には自分のフォーム構造やラベルの読み上げ順に合わせて調整し、実データでのキーボード・スクリーンリーダー確認を必ず行ってください。
出典: MDN — role="radiogroup" · MDN — role="slider" · MDN — prefers-reduced-motion · MDN — requestAnimationFrame · MDN — Math.atan2()
関連記事: セグメントコントロールUI 9選 — コピペOK · 星評価 UI 9選 — 塗りの値が状態に応じて変わる仕組み · カレンダー 選択 UI 9選 — コピペOK









