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 원작 · zip 에 SCSS·React 포함 · 난이도 선택 ui 9종 — 드래그 각도로 값이 바뀌는 계산까지
이 데모가 속한 글
난이도 선택 ui 9종 — 드래그 각도로 값이 바뀌는 계산까지난이도 선택 ui 아홉 가지 가운데 하나는 손을 떼면 슬라이더 손잡이가 단계를 살짝 지나쳤다가 스프링 계산으로 되튕겨 멈추고, 하나는 배지 다섯 개의 색을 고정값이 아니라 단계 번호를 넣은 색상각 수식으로 계산하며, 이 글의 데모 전부 보기


