02選ぶほど影が深くなるカード型難易度選択
カードを選ぶとその影の高さとぼかしが段階番号に比例して計算され大きくなるため、上級カードほど浮いて見える。
使いどころアプリの開始画面で上・中・下の難易度を選ぶ場所
shadowForpaintselect

<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 に同梱 · 難易度選択 ui 9種 — 角度と数式で決まる選択まで
このデモが属する記事
難易度選択 ui 9種 — 角度と数式で決まる選択まで難易度選択 ui 9種のうち、1つは手を離すとスライダーのつまみが段階を少し行き過ぎてからばね計算で戻って止まり、1つは5つのバッジの色を固定値ではなく段階番号を入れた色相角の数式で計算し、1つは半円ゲージの針を引く指の角度を値に変換します この記事のデモをすべて見る


