03방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식
오른쪽이나 왼쪽 방향키가 눌릴 때마다 선택된 별의 순서 값이 하나씩 늘거나 줄고, 그 값이 범위의 양 끝을 넘어선 상태가 되면 끝 값에서 멈춥니다.
쓰임마우스 없이 쓰는 접근성 별점 입력, 키보드로만 조작하는 설문 폼
tick() / animateTo(t)display += (target - display) * 0.2seq = [4, 5, 3, 1, 0, 2, 3]

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow" role="slider" tabindex="0" aria-label="별점" aria-valuemin="0" aria-valuemax="5" aria-valuenow="3">
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
</div>
<div class="sr__readout"><span class="sr__hint">← → 방향키</span> · <b id="srNum">3</b> / 5</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 방향키가 바뀔 때마다 목표 값을 갱신하고, 화면 값은 매 프레임 목표 쪽으로 20%씩만
// 다가가는 선형보간(lerp)으로 따라가 즉시 스냅이 아니라 관성 있는 곡선으로 채워진다.
@include stage($stage-ink);
.sr {
display: grid;
gap: $sp-5;
place-items: center;
}
.sr__row {
display: flex;
gap: $sp-2;
border-radius: $r-sm;
padding: $sp-2;
outline: none;
&:focus-visible { box-shadow: 0 0 0 3px $subject-blue-light; }
}
.sr__star {
position: relative;
width: 44px;
height: 44px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base { fill: $subject-blue-light; opacity: .35; }
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
}
.sr__fill { fill: $subject-pink; }
.sr__readout {
font: 600 15px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
b { color: $subject-pink; font-variant-numeric: tabular-nums; }
}
.sr__hint { opacity: .7; font-weight: 500; }
@media (max-width: 340px) {
.sr__star { width: 34px; height: 34px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__row { 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);
}
.sr {
display: grid;
gap: 20px;
place-items: center;
}
.sr__row {
display: flex;
gap: 8px;
border-radius: 8px;
padding: 8px;
outline: none;
}
.sr__row:focus-visible {
box-shadow: 0 0 0 3px #5b8cf8;
}
.sr__star {
position: relative;
width: 44px;
height: 44px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base {
fill: #5b8cf8;
opacity: 0.35;
}
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
}
.sr__fill {
fill: #ff9ebb;
}
.sr__readout {
font: 600 15px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
}
.sr__readout b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
}
.sr__hint {
opacity: 0.7;
font-weight: 500;
}
@media (max-width: 340px) {
.sr__star {
width: 34px;
height: 34px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__row {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var row = document.getElementById('srRow');
var clips = Array.prototype.slice.call(row.querySelectorAll('[data-fc]'));
var numEl = document.getElementById('srNum');
var TOTAL = clips.length;
var target = 3;
var display = 3;
var raf = null;
function paint(v) {
clips.forEach(function (el, idx) {
var starVal = Math.max(0, Math.min(1, v - idx));
el.style.width = (starVal * 100).toFixed(1) + '%';
});
}
// 계산 겹: 매 프레임 display 값을 target 값 쪽으로 20% 씩만 이동시키는 선형보간(lerp) —
// 방향키를 눌러도 값이 즉시 튀지 않고 관성 있는 곡선으로 따라간다.
function tick() {
display += (target - display) * 0.2;
if (Math.abs(target - display) < 0.01) {
display = target;
paint(display);
raf = null;
return;
}
paint(display);
raf = requestAnimationFrame(tick);
}
function animateTo(t) {
target = Math.max(0, Math.min(TOTAL, t));
row.setAttribute('aria-valuenow', String(target));
numEl.textContent = String(target);
if (raf === null) raf = requestAnimationFrame(tick);
}
row.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); animateTo(target + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); animateTo(target - 1); }
});
paint(display);
// 자동 루프: 방향키 입력을 흉내내 값을 0→5→0 으로 오가며 lerp 이동을 보여준다.
var seq = [4, 5, 3, 1, 0, 2, 3];
var seqI = 0;
var autoTimer = null;
function autoTick() { animateTo(seq[seqI]); seqI = (seqI + 1) % seq.length; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 620); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./KeyboardArrowSelect.module.scss";
const STAR_PATH =
"M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z";
export default function KeyboardArrowSelect() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const row = root.querySelector<HTMLDivElement>(`.${styles.row}`);
const numEl = root.querySelector<HTMLElement>(`.${styles.num}`);
if (!row || !numEl) return;
const clips = Array.prototype.slice.call(
row.querySelectorAll(`.${styles.fillclip}`)
) as HTMLElement[];
const TOTAL = clips.length;
let target = 3;
let display = 3;
let raf: number | null = null;
function paint(v: number) {
clips.forEach((el, idx) => {
const starVal = Math.max(0, Math.min(1, v - idx));
el.style.width = (starVal * 100).toFixed(1) + "%";
});
}
// 계산 겹: 매 프레임 display 값을 target 값 쪽으로 20% 씩만 이동시키는 선형보간(lerp) —
// 방향키를 눌러도 값이 즉시 튀지 않고 관성 있는 곡선으로 따라간다.
function tick() {
display += (target - display) * 0.2;
if (Math.abs(target - display) < 0.01) {
display = target;
paint(display);
raf = null;
return;
}
paint(display);
raf = requestAnimationFrame(tick);
}
function animateTo(t: number) {
target = Math.max(0, Math.min(TOTAL, t));
row!.setAttribute("aria-valuenow", String(target));
numEl!.textContent = String(target);
if (raf === null) raf = requestAnimationFrame(tick);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowRight") { e.preventDefault(); stopAutoplay(); animateTo(target + 1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); stopAutoplay(); animateTo(target - 1); }
}
row.addEventListener("keydown", onKeyDown);
paint(display);
// 자동 루프: 방향키 입력을 흉내내 값을 0→5→0 으로 오가며 lerp 이동을 보여준다.
const seq = [4, 5, 3, 1, 0, 2, 3];
let seqI = 0;
let autoTimer: number | null = null;
function autoTick() { animateTo(seq[seqI]); seqI = (seqI + 1) % seq.length; }
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 620); autoTick(); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
row.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div
className={styles.row}
role="slider"
tabIndex={0}
aria-label="별점"
aria-valuemin={0}
aria-valuemax={5}
aria-valuenow={3}
>
{[0, 1, 2, 3, 4].map((i) => (
<span key={i} className={styles.star}>
<svg className={styles.base} viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
<span className={styles.fillclip}>
<svg className={styles.fill} viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</span>
</span>
))}
</div>
<div className={styles.readout}>
<span className={styles.hint}>← → 방향키</span> · <b className={styles.num}>3</b> / 5
</div>
</div>
</div>
);
}03 방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식 — 측정 누적면적 3.694% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 별점 UI 9종 — 채움 값이 상태에 따라 바뀌는 방식
이 데모가 속한 글
별점 UI 9종 — 채움 값이 상태에 따라 바뀌는 방식별점 UI는 별 아이콘으로 점수를 매기거나 매겨진 평균 점수를 보여주는 입력 장치인데, 아홉 개 중 하나는 손가락이 움직인 가로 값을 따라 채움 값이 바로 바뀌고, 하나는 평균 값의 소수점 부분이 별 하나의 채움 너 이 글의 데모 전부 보기


