별점 UI 9종 — 채움 값이 상태에 따라 바뀌는 방식
별점 UI는 별 아이콘으로 점수를 매기거나 매겨진 평균 점수를 보여주는 입력 장치인데, 아홉 개 중 하나는 손가락이 움직인 가로 값을 따라 채움 값이 바로 바뀌고, 하나는 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나며, 또 하나는 같은 별을 다시 누른 상태가 되면
별 모양을 그리는 화면들(05 를 뺀 여덟 개)은 유니코드 글자 ★가 아니라 SVG path로 그린 도형이고, 05 는 별 모양 대신 점수 숫자 라벨과 막대로만 값을 보여줍니다. 평점·리뷰 개수는 데모용으로 미리 채워 둔 값이라 실제 브랜드나 인물을 가리키지 않습니다. 04의 "리뷰 추가" 버튼도 실 서버로 fetch 를 보내지 않고 로컬 배열(queue)에서 다음 값을 하나씩 꺼내 더할 뿐입니다. 그래도 아홉 개 전부 리스너는 pointerenter·pointerleave·pointermove·keydown·click·pointerdown·pointerup 같은 실제 사용자 입력 이벤트로 등록돼 있어, 자동 재생 중이라도 직접 별을 가리키거나 눌러 보는 순간 그 자리에서 자동 루프가 멎고 손으로 넣은 값으로 같은 함수가 다시 계산합니다. 자동 루프가 반복하는 값도 무작위가 아니라 고정된 배열을 순서대로 도는 결정적 시퀀스입니다.
아홉 개를 가르는 기준은 채움 값이 어디서 나오느냐입니다 — 포인터의 화면 위치 값이 채움 값을 그대로 정하는 것(01·02·09), 응답 개수를 집계한 값에서 채움 값이 나오는 것(04·05), 클릭이나 방향키로 상태가 바뀔 때마다 채움 값이 다시 계산되는 것(03·06·07·08)으로 나눴습니다.
01 star-rating-hover-preview-fill
02 star-rating-half-step-precision
03 star-rating-keyboard-arrow-select
04 star-rating-average-fill-overlay
05 star-rating-count-breakdown-bars
06 star-rating-tooltip-label-swap
07 star-rating-value-color-shift
08 star-rating-reset-clear-spring
09 star-rating-touch-drag-scrub
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식
- 02 반 칸 정밀 채움 — 가로 위치 값이 별 너비의 절반을 넘었는지에 따라 채움 값이 정수·반칸 단위로 갈리는 방식
- 03 방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식
- 04 평균 값 겹침 채움 — 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나는 방식
- 05 개수 막대 분해 — 별점별 응답 개수 값이 가로 막대의 길이 값으로 나뉘어 나타나는 방식
- 06 말풍선 이름표 교체 — 가리키는 별의 순서 값에 맞는 이름표 문구 값이 바뀌어 나타나는 방식
- 07 값별 색 전환 — 선택된 값의 크기에 따라 별의 색상 값이 단계적으로 바뀌는 방식
- 08 되돌림 스프링 초기화 — 같은 별을 다시 누른 상태가 되면 채움 값이 0으로 튕기듯 줄어드는 방식
- 09 터치 드래그 훑기 — 손가락이 움직인 가로 값을 따라 채움 값이 실시간으로 바뀌는 방식
아래 표의 실측 값을 먼저 보면, css 별점 중에서도 05(개수 막대 분해)처럼 화면 전체가 함께 움직이는 항목과 04(평균 값 겹침 채움)처럼 별 한두 개만 조용히 바뀌는 항목이 격자 미리보기 단계에서부터 뚜렷이 갈립니다(정확한 수치는 아래 실측 표).
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
실측 한눈에
| 항목 | 초점 | 누적면적% | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 호버 미리보기 채움 | 순서 값 → ratioOf() 재계산 | 3.94 | 75.6 | 21/23 |
| 02 반 칸 정밀 채움 | 가로 위치 값 → valueFromX() 재계산 | 4.03 | 153.2 | 23/23 |
| 03 방향키 값 이동 | 순환 값 → animateTo() 재계산 | 3.69 | 127.6 | 16/23 |
| 04 평균 값 겹침 채움 | 가중 평균 값 → average() 재계산 | 0.50 | 73.1 | 18/23 |
| 05 개수 막대 분해 | 비율 값 → total() 재계산 | 22.70 | 34.0 | 15/23 |
| 06 말풍선 이름표 교체 | 순서 값 → positionTip() 재계산 | 6.52 | 143.6 | 9/23 |
| 07 값별 색 전환 | 비율 값 → mix() 재계산 | 3.59 | 25.7 | 23/23 |
| 08 되돌림 스프링 초기화 | 채움 값 → reset() 재계산 | 4.79 | 108.7 | 9/23 |
| 09 터치 드래그 훑기 | 속도 값 → runInertia() 재계산 | 4.96 | 173.2 | 17/23 |
05 가 22.70%로 아홉 중 누적면적이 가장 커 막대 다섯 줄이 한 번에 길이 값을 다시 그린다는 뜻이고, 04 는 0.50%로 가장 작아 별 하나의 채움 너비만 좁게 바뀐다는 뜻입니다. 강도 평균은 09 가 173.2로 가장 높은 축에 들어 손가락이 지나간 자리와 배경의 픽셀 값 차이가 크고, 07 은 25.7로 가장 낮아 색이 서서히 섞이는 보간이라 픽셀 값 차이가 완만합니다. 움직인 프레임은 02·07 이 23/23으로 매 프레임 값을 다시 그려 손을 대는 내내 살아 있고, 06·08 은 9/23으로 가장 낮아 짧은 지연이 끝난 순간에만 값이 바뀝니다.
포인터 위치 값이 채움을 정하는 것 — 01·02·09
01 은 ratioOf()가 채움 개수 n을 전체 별 개수로 나눈 비율을 정수로 반올림해 화면 숫자로 그대로 보여주고, 포인터가 별 위에 놓인 동안만 previewTo()가 이 값을 미리 칠합니다. 02 는 valueFromX()가 각 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터의 가로 좌표가 별 너비의 왼쪽 절반에 있는지 오른쪽 절반에 있는지를 갈라 정수 또는 0.5 단위로만 값을 스냅합니다. 09 는 onMove가 매 이동마다 속도(px/ms)를 재고, 손을 뗀 뒤에는 runInertia()가 그 속도를 감쇠시키며 위치 값을 계속 옮깁니다.
집계 값에서 채움이 나오는 것 — 04·05
04 는 average()가 별점별 응답 개수(counts)의 가중 평균을 구해 정수 부분만큼 별을 채우고, 남은 소수점 부분 값을 다음 별 하나의 채움 너비로 겹쳐 그립니다. 05 는 total()이 전체 응답 개수 합을 구한 뒤 각 점수의 개수를 그 합으로 나눈 비율 값을 막대 길이로 바꿔, 별점 다섯 줄이 각자 다른 길이로 늘어섭니다.
상태·순서 값이 바뀌며 다시 계산되는 것 — 03·06·07·08
03 은 방향키가 눌릴 때마다 목표 값(target)만 먼저 바뀌고, 화면에 보이는 값(display)은 매 프레임 그 차이의 20%씩만 따라가는 선형보간으로 뒤늦게 목표에 붙습니다. 06 은 가리키는 별의 순서 값이 바뀔 때마다 requestSwap()이 40ms 지연을 두고서야 이름표 문구를 교체합니다. 07 은 선택된 값(1~5)을 0~1 비율로 바꿔 두 색 토큰의 RGB 채널을 각각 선형보간하는 산술 혼합이라 HSL 색상환을 돌리는 방식과는 다릅니다. 08 은 같은 별이 다시 눌린 상태가 되면 채움 값이 0으로 떨어지는 동시에, 이미 채워졌던 별에만 스프링 이징이 걸린 클래스를 씌워 튕기듯 사라지게 합니다.
패턴별 코드
01호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식
포인터가 별 위에 놓인 상태가 되면 그 별까지의 순서 값만큼 채움 상태가 미리 나타나고, 포인터가 벗어난 상태가 되면 채움 값이 실제 선택된 값으로 되돌아갑니다. 상품 리뷰 작성 폼의 별점 입력이나 만족도 설문의 점수 선택에 맞는 형태입니다.
채움 개수를 비율 값으로 바꾸는 ratioOf() 와 paint()
// 계산 겹: 채움 개수 n 을 전체 별 개수로 나눈 비율(산술)을 그대로 화면 숫자로 보여준다 —
// 채움은 CSS 클래스가 아니라 이 비율 계산에서 나온 정수를 그대로 쓴다.
function ratioOf(n) { return Math.round((n / TOTAL) * 100); }
function paint(n, isPreview) {
stars.forEach(function (s, idx) {
s.classList.toggle('sr__star--filled', idx < n);
});
numEl.textContent = String(n);
pctEl.textContent = String(ratioOf(n));
readout.classList.toggle('sr__readout--preview', !!isPreview);
readout.firstChild.textContent = isPreview ? '미리보기 ' : '선택 ';
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow" role="radiogroup" aria-label="별점">
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="1">
<svg 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>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="2">
<svg 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>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="3">
<svg 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>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="4">
<svg 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>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="5">
<svg 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>
</button>
</div>
<div class="sr__readout" id="srReadout">선택 <b id="srNum">3</b>/5 · <span id="srPct">60</span>%</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 채움 개수를 전체 별 개수로 나눈 비율(ratioOf())을 정수로 그대로 화면에 보여준다 —
// 채움 클래스와 별개로 숫자 자체가 계산 결과다.
@include stage($stage-ink);
.sr {
display: grid;
gap: $sp-5;
place-items: center;
}
.sr__row {
display: flex;
gap: $sp-2;
}
.sr__star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: $subject-blue-light;
transition: transform $dur-quick $ease-out, color $dur-quick $ease-out;
svg { display: block; width: 100%; height: 100%; fill: currentColor; }
&--filled {
color: $subject-pink;
transform: scale(1.08);
}
}
.sr__readout {
font: 600 15px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
letter-spacing: .01em;
b { color: $subject-pink; }
&--preview { color: $subject-blue-light; b { color: $subject-blue-light; } }
}
@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__star { 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;
}
.sr__star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: #5b8cf8;
transition: transform 220ms ease-out, color 220ms ease-out;
}
.sr__star svg {
display: block;
width: 100%;
height: 100%;
fill: currentColor;
}
.sr__star--filled {
color: #ff9ebb;
transform: scale(1.08);
}
.sr__readout {
font: 600 15px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
letter-spacing: 0.01em;
}
.sr__readout b {
color: #ff9ebb;
}
.sr__readout--preview {
color: #5b8cf8;
}
.sr__readout--preview b {
color: #5b8cf8;
}
@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__star {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var row = document.getElementById('srRow');
var stars = Array.prototype.slice.call(row.querySelectorAll('.sr__star'));
var numEl = document.getElementById('srNum');
var pctEl = document.getElementById('srPct');
var readout = document.getElementById('srReadout');
var TOTAL = stars.length;
var value = 3;
// 계산 겹: 채움 개수 n 을 전체 별 개수로 나눈 비율(산술)을 그대로 화면 숫자로 보여준다 —
// 채움은 CSS 클래스가 아니라 이 비율 계산에서 나온 정수를 그대로 쓴다.
function ratioOf(n) { return Math.round((n / TOTAL) * 100); }
function paint(n, isPreview) {
stars.forEach(function (s, idx) {
s.classList.toggle('sr__star--filled', idx < n);
});
numEl.textContent = String(n);
pctEl.textContent = String(ratioOf(n));
readout.classList.toggle('sr__readout--preview', !!isPreview);
readout.firstChild.textContent = isPreview ? '미리보기 ' : '선택 ';
}
function previewTo(i) { paint(i, true); }
function revertToValue() { paint(value, false); }
function commit(i) {
value = i;
stars.forEach(function (s, idx) { s.setAttribute('aria-checked', String(idx < value)); });
paint(value, false);
}
stars.forEach(function (s) {
var i = Number(s.getAttribute('data-i'));
s.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); stopAutoplay(); previewTo(i); });
s.addEventListener('click', function () { commit(i); });
});
row.addEventListener('pointerleave', function () { revertToValue(); });
paint(value, false);
// 자동 루프: 별 순서대로 미리보기 값을 올렸다 원래 값으로 되돌리는 것을 반복해 보여준다.
var step = 0;
var autoTimer = null;
function autoTick() {
if (step < TOTAL) { previewTo(step + 1); }
else { revertToValue(); }
step = (step + 1) % (TOTAL + 2);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 280); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./HoverPreviewFill.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 HoverPreviewFill() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const row = root.querySelector<HTMLDivElement>(`.${styles.row}`);
const readout = root.querySelector<HTMLDivElement>(`.${styles.readout}`);
const numEl = root.querySelector<HTMLElement>(`.${styles.num}`);
const pctEl = root.querySelector<HTMLElement>(`.${styles.pct}`);
if (!row || !readout || !numEl || !pctEl) return;
const stars = Array.prototype.slice.call(
row.querySelectorAll(`.${styles.star}`)
) as HTMLElement[];
const TOTAL = stars.length;
let value = 3;
// 계산 겹: 채움 개수 n 을 전체 별 개수로 나눈 비율(산술)을 그대로 화면 숫자로 보여준다 —
// 채움은 CSS 클래스가 아니라 이 비율 계산에서 나온 정수를 그대로 쓴다.
function ratioOf(n: number) {
return Math.round((n / TOTAL) * 100);
}
function paint(n: number, isPreview?: boolean) {
stars.forEach((s, idx) => {
s.classList.toggle(styles.filled, idx < n);
});
numEl!.textContent = String(n);
pctEl!.textContent = String(ratioOf(n));
readout!.classList.toggle(styles.preview, !!isPreview);
readout!.firstChild!.textContent = isPreview ? "미리보기 " : "선택 ";
}
function previewTo(i: number) { paint(i, true); }
function revertToValue() { paint(value, false); }
function commit(i: number) {
value = i;
stars.forEach((s, idx) => s.setAttribute("aria-checked", String(idx < value)));
paint(value, false);
}
function onEnter(i: number) {
return () => { stopAutoplay(); previewTo(i); };
}
function onClick(i: number) {
return () => commit(i);
}
const enterHandlers: Array<() => void> = [];
const clickHandlers: Array<() => void> = [];
stars.forEach((s, idx) => {
const i = idx + 1;
const eh = onEnter(i);
const ch = onClick(i);
enterHandlers.push(eh);
clickHandlers.push(ch);
s.addEventListener("pointerenter", eh);
s.addEventListener("click", ch);
});
function onLeave() { revertToValue(); }
row.addEventListener("pointerleave", onLeave);
paint(value, false);
// 자동 루프: 별 순서대로 미리보기 값을 올렸다 원래 값으로 되돌리는 것을 반복해 보여준다.
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step < TOTAL) { previewTo(step + 1); }
else { revertToValue(); }
step = (step + 1) % (TOTAL + 2);
}
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, 280); autoTick(); }
return () => {
stopAutoplay();
stars.forEach((s, idx) => {
s.removeEventListener("pointerenter", enterHandlers[idx]);
s.removeEventListener("click", clickHandlers[idx]);
});
row.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row} role="radiogroup" aria-label="별점">
{[1, 2, 3, 4, 5].map((i) => (
<button key={i} type="button" className={styles.star} role="radio" aria-checked="false">
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</button>
))}
</div>
<div className={styles.readout}>
선택 <b className={styles.num}>3</b>/5 · <span className={styles.pct}>60</span>%
</div>
</div>
</div>
);
}01 · 호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식 — 측정 누적면적 3.944% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02반 칸 정밀 채움 — 가로 위치 값이 별 너비의 절반을 넘었는지에 따라 채움 값이 정수·반칸 단위로 갈리는 방식
포인터의 가로 위치 값이 별 하나의 너비 값 중 어느 절반에 있는지에 따라 채움 값이 정수 값 또는 반 칸 값으로 나뉘어 정해집니다. 세밀한 평점을 남기는 리뷰 폼이나 별 다섯 개 기준 소수점 평점 입력에 맞는 형태입니다.
왼쪽·오른쪽 절반을 가르는 valueFromX()
// 계산 겹: 각 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터가 왼쪽 절반에 있는지
// 오른쪽 절반에 있는지를 나눠, 정수 또는 .5 단위로만 값을 스냅한다.
function valueFromX(clientX) {
for (var i = 0; i < stars.length; i++) {
var r = stars[i].getBoundingClientRect();
if (clientX < r.left) return i;
if (clientX <= r.right) {
var half = r.left + r.width / 2;
return clientX < half ? i + 0.5 : i + 1;
}
}
return stars.length;
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow">
<span class="sr__star" data-i="0"><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" id="fc0"><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" data-i="1"><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" id="fc1"><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" data-i="2"><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" id="fc2"><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" data-i="3"><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" id="fc3"><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" data-i="4"><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" id="fc4"><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" id="srReadout"><b id="srNum">0.0</b> / 5</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터 위치가 왼쪽/오른쪽 절반
// 어디에 있는지를 나눠 정수 또는 .5 단위로만 값을 스냅한다 (valueFromX()).
@include stage($stage-ink);
.sr {
display: grid;
gap: $sp-5;
place-items: center;
}
.sr__row {
display: flex;
gap: $sp-2;
cursor: pointer;
}
.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;
transition: width $dur-instant $ease-out;
}
.sr__fill { fill: $subject-pink; }
.sr__readout {
font: 600 20px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
b { color: $subject-pink; font-variant-numeric: tabular-nums; }
}
@media (max-width: 340px) {
.sr__star { width: 34px; height: 34px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip { 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;
cursor: pointer;
}
.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;
transition: width 140ms ease-out;
}
.sr__fill {
fill: #ff9ebb;
}
.sr__readout {
font: 600 20px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
}
.sr__readout b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.sr__star {
width: 34px;
height: 34px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var row = document.getElementById('srRow');
var stars = Array.prototype.slice.call(row.querySelectorAll('.sr__star'));
var clips = stars.map(function (s) { return s.querySelector('.sr__fillclip'); });
var numEl = document.getElementById('srNum');
var STAR_W = 44;
var value = 0;
// 계산 겹: 각 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터가 왼쪽 절반에 있는지
// 오른쪽 절반에 있는지를 나눠, 정수 또는 .5 단위로만 값을 스냅한다.
function valueFromX(clientX) {
for (var i = 0; i < stars.length; i++) {
var r = stars[i].getBoundingClientRect();
if (clientX < r.left) return i;
if (clientX <= r.right) {
var half = r.left + r.width / 2;
return clientX < half ? i + 0.5 : i + 1;
}
}
return stars.length;
}
function paint(v) {
stars.forEach(function (s, idx) {
var starVal = Math.max(0, Math.min(1, v - idx));
clips[idx].style.width = (starVal * 100).toFixed(0) + '%';
});
numEl.textContent = v.toFixed(1);
}
function setFromPointer(clientX) {
value = valueFromX(clientX);
paint(value);
}
row.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
setFromPointer(e.clientX);
});
row.addEventListener('click', function (e) { setFromPointer(e.clientX); });
paint(value);
// 자동 루프: 반 칸 단위(0.5)로 값을 끝까지 올렸다 처음으로 되돌리는 것을 반복한다.
var steps = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
var step = 0;
var autoTimer = null;
function autoTick() { paint(steps[step]); value = steps[step]; step = (step + 1) % steps.length; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 260); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./HalfStepPrecision.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 HalfStepPrecision() {
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 stars = Array.prototype.slice.call(
row.querySelectorAll(`.${styles.star}`)
) as HTMLElement[];
const clips = stars.map((s) => s.querySelector<HTMLElement>(`.${styles.fillclip}`)!);
let value = 0;
// 계산 겹: 각 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터가 왼쪽 절반에 있는지
// 오른쪽 절반에 있는지를 나눠, 정수 또는 .5 단위로만 값을 스냅한다.
function valueFromX(clientX: number) {
for (let i = 0; i < stars.length; i++) {
const r = stars[i].getBoundingClientRect();
if (clientX < r.left) return i;
if (clientX <= r.right) {
const half = r.left + r.width / 2;
return clientX < half ? i + 0.5 : i + 1;
}
}
return stars.length;
}
function paint(v: number) {
stars.forEach((_s, idx) => {
const starVal = Math.max(0, Math.min(1, v - idx));
clips[idx].style.width = (starVal * 100).toFixed(0) + "%";
});
numEl!.textContent = v.toFixed(1);
}
function setFromPointer(clientX: number) {
value = valueFromX(clientX);
paint(value);
}
function onMove(e: PointerEvent) {
stopAutoplay();
setFromPointer(e.clientX);
}
function onClick(e: MouseEvent) { setFromPointer(e.clientX); }
row.addEventListener("pointermove", onMove);
row.addEventListener("click", onClick);
paint(value);
// 자동 루프: 반 칸 단위(0.5)로 값을 끝까지 올렸다 처음으로 되돌리는 것을 반복한다.
const steps = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
let step = 0;
let autoTimer: number | null = null;
function autoTick() { paint(steps[step]); value = steps[step]; step = (step + 1) % steps.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, 260); autoTick(); }
return () => {
stopAutoplay();
row.removeEventListener("pointermove", onMove);
row.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row}>
{[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}><b className={styles.num}>0.0</b> / 5</div>
</div>
</div>
);
}02 · 반 칸 정밀 채움 — 가로 위치 값이 별 너비의 절반을 넘었는지에 따라 채움 값이 정수·반칸 단위로 갈리는 방식 — 측정 누적면적 4.027% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식
오른쪽이나 왼쪽 방향키가 눌릴 때마다 선택된 별의 순서 값이 하나씩 늘거나 줄고, 그 값이 범위의 양 끝을 넘어선 상태가 되면 끝 값에서 멈춥니다. 마우스 없이 쓰는 접근성 별점 입력이나 키보드로만 조작하는 설문 폼에 맞는 형태입니다.
목표 값을 20%씩 따라가는 tick() 과 animateTo()
// 계산 겹: 매 프레임 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);
}

<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 포함 · 비밀번호 보기
04평균 값 겹침 채움 — 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나는 방식
평균 값에서 정수 부분만큼 별이 채워진 상태가 되고, 남은 소수점 부분 값이 그다음 별 하나의 채움 너비 값으로 겹쳐서 나타납니다. 상품 목록의 평균 평점 표시나 후기 요약 카드의 점수 표시에 맞는 형태입니다.
가중 평균을 구하는 average()
// 계산 겹: 별점별 응답 개수(counts)로 가중 평균(합 / 개수)을 구하고, 정수 부분만큼 별을 채운 뒤
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다.
var counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 };
var queue = [5, 3, 5, 4, 2, 5, 4, 3, 5, 1];
var qi = 0;
function average() {
var sum = 0, n = 0;
for (var k in counts) { sum += Number(k) * counts[k]; n += counts[k]; }
return { avg: sum / n, n: n };
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow">
<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"><b id="srAvg">4.16</b> <span class="sr__count">(<span id="srN">25</span>건)</span></div>
<button class="sr__btn" type="button" id="srAdd">리뷰 추가</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 별점별 응답 개수 합을 전체 개수로 나눈 가중 평균을 구해, 정수 부분만큼 별을 채우고
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다 (average()).
@include stage($stage-yellow);
.sr {
display: grid;
gap: $sp-4;
place-items: center;
}
.sr__row { display: flex; gap: $sp-2; }
.sr__star {
position: relative;
width: 40px;
height: 40px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base { fill: $subject-ink; opacity: .18; }
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
transition: width $dur-base $ease-out;
}
.sr__fill { fill: $subject-blue-deep; }
.sr__readout {
font: 700 22px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
b { font-variant-numeric: tabular-nums; }
}
.sr__count {
font-size: 14px;
font-weight: 500;
opacity: .7;
}
.sr__btn {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
background: $subject-ink;
color: $stage-paper;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.94); }
}
@media (max-width: 340px) {
.sr__star { width: 30px; height: 30px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip, .sr__btn { 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);
}
.sr {
display: grid;
gap: 16px;
place-items: center;
}
.sr__row {
display: flex;
gap: 8px;
}
.sr__star {
position: relative;
width: 40px;
height: 40px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base {
fill: #17141a;
opacity: 0.18;
}
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
transition: width 300ms ease-out;
}
.sr__fill {
fill: #0a4fe8;
}
.sr__readout {
font: 700 22px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.sr__readout b {
font-variant-numeric: tabular-nums;
}
.sr__count {
font-size: 14px;
font-weight: 500;
opacity: 0.7;
}
.sr__btn {
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #17141a;
color: #ffffff;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sr__btn:active {
transform: scale(0.94);
}
@media (max-width: 340px) {
.sr__star {
width: 30px;
height: 30px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip, .sr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var clips = Array.prototype.slice.call(document.querySelectorAll('[data-fc]'));
var avgEl = document.getElementById('srAvg');
var nEl = document.getElementById('srN');
var btn = document.getElementById('srAdd');
// 계산 겹: 별점별 응답 개수(counts)로 가중 평균(합 / 개수)을 구하고, 정수 부분만큼 별을 채운 뒤
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다.
var counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 };
var queue = [5, 3, 5, 4, 2, 5, 4, 3, 5, 1];
var qi = 0;
function average() {
var sum = 0, n = 0;
for (var k in counts) { sum += Number(k) * counts[k]; n += counts[k]; }
return { avg: sum / n, n: n };
}
function paint() {
var r = average();
clips.forEach(function (el, idx) {
var starVal = Math.max(0, Math.min(1, r.avg - idx));
el.style.width = (starVal * 100).toFixed(1) + '%';
});
avgEl.textContent = r.avg.toFixed(2);
nEl.textContent = String(r.n);
}
function addReview() {
var v = queue[qi % queue.length];
qi++;
counts[v] = (counts[v] || 0) + 1;
paint();
}
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); addReview(); });
paint();
// 자동 루프: 대기열의 다음 평점을 하나씩 추가해 평균 값이 실시간으로 옮겨가는 것을 반복해 보여준다.
var autoTimer = null;
function autoTick() { addReview(); if (qi % queue.length === 0) { counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 }; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 480); }
})();import { useEffect, useRef } from "react";
import styles from "./AverageFillOverlay.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 AverageFillOverlay() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const clips = Array.prototype.slice.call(
root.querySelectorAll(`.${styles.fillclip}`)
) as HTMLElement[];
const avgEl = root.querySelector<HTMLElement>(`.${styles.avg}`);
const nEl = root.querySelector<HTMLElement>(`.${styles.n}`);
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
if (!avgEl || !nEl || !btn) return;
// 계산 겹: 별점별 응답 개수(counts)로 가중 평균(합 / 개수)을 구하고, 정수 부분만큼 별을 채운 뒤
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다.
let counts: Record<number, number> = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 };
const queue = [5, 3, 5, 4, 2, 5, 4, 3, 5, 1];
let qi = 0;
function average() {
let sum = 0, n = 0;
for (const k in counts) { sum += Number(k) * counts[Number(k)]; n += counts[Number(k)]; }
return { avg: sum / n, n };
}
function paint() {
const r = average();
clips.forEach((el, idx) => {
const starVal = Math.max(0, Math.min(1, r.avg - idx));
el.style.width = (starVal * 100).toFixed(1) + "%";
});
avgEl!.textContent = r.avg.toFixed(2);
nEl!.textContent = String(r.n);
}
function addReview() {
const v = queue[qi % queue.length];
qi++;
counts[v] = (counts[v] || 0) + 1;
paint();
}
function onClick() { stopAutoplay(); addReview(); }
btn.addEventListener("click", onClick);
paint();
// 자동 루프: 대기열의 다음 평점을 하나씩 추가해 평균 값이 실시간으로 옮겨가는 것을 반복해 보여준다.
let autoTimer: number | null = null;
function autoTick() {
addReview();
if (qi % queue.length === 0) { counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 }; }
}
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, 480); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row}>
{[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}>
<b className={styles.avg}>4.16</b> <span className={styles.count}>(<span className={styles.n}>25</span>건)</span>
</div>
<button className={styles.btn} type="button">리뷰 추가</button>
</div>
</div>
);
}04 · 평균 값 겹침 채움 — 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나는 방식 — 측정 누적면적 0.5% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05개수 막대 분해 — 별점별 응답 개수 값이 가로 막대의 길이 값으로 나뉘어 나타나는 방식
별 다섯 개부터 하나까지 각 점수의 응답 개수 값이 전체 응답 개수 값 대비 비율 값으로 바뀌어 각 줄 막대의 길이 값이 됩니다. 평점 상세 통계를 보여주는 리뷰 페이지나 설문 결과 요약 화면에 맞는 형태입니다.
비율 값을 막대 길이로 바꾸는 total() 과 paint()
// 계산 겹: 각 별점의 응답 개수를 전체 응답 개수 합으로 나눈 비율(%)을 막대 길이 값으로 쓴다.
function total() {
var t = 0;
for (var k in counts) t += counts[k];
return t;
}
function paint() {
var t = total();
rows.forEach(function (row) {
var star = Number(row.getAttribute('data-star'));
var pct = t ? (counts[star] / t) * 100 : 0;
row.querySelector('[data-bar]').style.width = pct.toFixed(1) + '%';
row.querySelector('[data-pct]').textContent = Math.round(pct) + '% (' + counts[star] + ')';
row.classList.toggle('sr__row--on', star === selected);
row.setAttribute('aria-pressed', String(star === selected));
});
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__list" id="srList" role="list">
<button class="sr__row" type="button" role="listitem" data-star="5" aria-pressed="false">
<span class="sr__label">5</span>
<span class="sr__track"><span class="sr__bar" data-bar></span></span>
<span class="sr__pct" data-pct>0%</span>
</button>
<button class="sr__row" type="button" role="listitem" data-star="4" aria-pressed="false">
<span class="sr__label">4</span>
<span class="sr__track"><span class="sr__bar" data-bar></span></span>
<span class="sr__pct" data-pct>0%</span>
</button>
<button class="sr__row" type="button" role="listitem" data-star="3" aria-pressed="false">
<span class="sr__label">3</span>
<span class="sr__track"><span class="sr__bar" data-bar></span></span>
<span class="sr__pct" data-pct>0%</span>
</button>
<button class="sr__row" type="button" role="listitem" data-star="2" aria-pressed="false">
<span class="sr__label">2</span>
<span class="sr__track"><span class="sr__bar" data-bar></span></span>
<span class="sr__pct" data-pct>0%</span>
</button>
<button class="sr__row" type="button" role="listitem" data-star="1" aria-pressed="false">
<span class="sr__label">1</span>
<span class="sr__track"><span class="sr__bar" data-bar></span></span>
<span class="sr__pct" data-pct>0%</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 별점별 응답 개수를 전체 응답 개수 합으로 나눈 비율(%)을 막대 길이 값으로 쓴다 (paint()).
@include stage($stage-ink);
.sr__list {
display: grid;
gap: $sp-2;
width: clamp(220px, 70vw, 320px);
}
.sr__row {
display: grid;
grid-template-columns: 16px 1fr 68px;
align-items: center;
gap: $sp-3;
border: 0;
background: none;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
cursor: pointer;
transition: background $dur-quick $ease-out;
&--on { background: rgba($subject-blue-light, .18); }
}
.sr__label {
color: $stage-paper;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.sr__track {
height: 10px;
border-radius: $r-pill;
background: rgba($stage-paper, .14);
overflow: hidden;
}
.sr__bar {
display: block;
height: 100%;
width: 0%;
border-radius: $r-pill;
background: $subject-mint;
transition: width $dur-base $ease-out;
}
.sr__row--on .sr__bar { background: $subject-pink; }
.sr__pct {
color: $stage-paper;
font: 500 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.sr__list { width: 100%; gap: $sp-1; }
.sr__row { grid-template-columns: 14px 1fr 56px; gap: $sp-2; }
.sr__pct { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__row, .sr__bar { 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__list {
display: grid;
gap: 8px;
width: clamp(220px, 70vw, 320px);
}
.sr__row {
display: grid;
grid-template-columns: 16px 1fr 68px;
align-items: center;
gap: 12px;
border: 0;
background: none;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
transition: background 220ms ease-out;
}
.sr__row--on {
background: rgba(91, 140, 248, 0.18);
}
.sr__label {
color: #ffffff;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.sr__track {
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.sr__bar {
display: block;
height: 100%;
width: 0%;
border-radius: 999px;
background: #4cd4a6;
transition: width 300ms ease-out;
}
.sr__row--on .sr__bar {
background: #ff9ebb;
}
.sr__pct {
color: #ffffff;
font: 500 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.sr__list {
width: 100%;
gap: 4px;
}
.sr__row {
grid-template-columns: 14px 1fr 56px;
gap: 8px;
}
.sr__pct {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__row, .sr__bar {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var rows = Array.prototype.slice.call(document.querySelectorAll('.sr__row'));
var counts = { 5: 42, 4: 21, 3: 9, 2: 4, 1: 3 };
var selected = null;
// 계산 겹: 각 별점의 응답 개수를 전체 응답 개수 합으로 나눈 비율(%)을 막대 길이 값으로 쓴다.
function total() {
var t = 0;
for (var k in counts) t += counts[k];
return t;
}
function paint() {
var t = total();
rows.forEach(function (row) {
var star = Number(row.getAttribute('data-star'));
var pct = t ? (counts[star] / t) * 100 : 0;
row.querySelector('[data-bar]').style.width = pct.toFixed(1) + '%';
row.querySelector('[data-pct]').textContent = Math.round(pct) + '% (' + counts[star] + ')';
row.classList.toggle('sr__row--on', star === selected);
row.setAttribute('aria-pressed', String(star === selected));
});
}
rows.forEach(function (row) {
row.addEventListener('click', function () {
root.classList.remove('is-demo'); stopAutoplay();
var star = Number(row.getAttribute('data-star'));
selected = selected === star ? null : star;
paint();
});
});
paint();
// 자동 루프: 별점 5→1 순서로 한 줄씩 선택 상태를 옮기고, 한 바퀴마다 응답 개수 하나를
// 늘려 막대 길이 비율이 실시간으로 바뀌는 것을 보여준다.
var order = [5, 4, 3, 2, 1];
var oi = 0;
var bump = [5, 3, 4, 5, 2, 5, 1, 4, 5, 3];
var bi = 0;
var autoTimer = null;
function autoTick() {
selected = order[oi];
oi = (oi + 1) % order.length;
if (oi === 0) { var v = bump[bi % bump.length]; bi++; counts[v] = counts[v] + 1; }
paint();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 380); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./CountBreakdownBars.module.scss";
const STARS = [5, 4, 3, 2, 1];
export default function CountBreakdownBars() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const rows = Array.prototype.slice.call(
root.querySelectorAll(`.${styles.row}`)
) as HTMLElement[];
let counts: Record<number, number> = { 5: 42, 4: 21, 3: 9, 2: 4, 1: 3 };
let selected: number | null = null;
// 계산 겹: 각 별점의 응답 개수를 전체 응답 개수 합으로 나눈 비율(%)을 막대 길이 값으로 쓴다.
function total() {
let t = 0;
for (const k in counts) t += counts[Number(k)];
return t;
}
function paint() {
const t = total();
rows.forEach((row, idx) => {
const star = STARS[idx];
const pct = t ? (counts[star] / t) * 100 : 0;
row.querySelector<HTMLElement>(`.${styles.bar}`)!.style.width = pct.toFixed(1) + "%";
row.querySelector<HTMLElement>(`.${styles.pct}`)!.textContent =
Math.round(pct) + "% (" + counts[star] + ")";
row.classList.toggle(styles.on, star === selected);
row.setAttribute("aria-pressed", String(star === selected));
});
}
const clickHandlers: Array<() => void> = [];
rows.forEach((row, idx) => {
const handler = () => {
stopAutoplay();
const star = STARS[idx];
selected = selected === star ? null : star;
paint();
};
clickHandlers.push(handler);
row.addEventListener("click", handler);
});
paint();
// 자동 루프: 별점 5→1 순서로 한 줄씩 선택 상태를 옮기고, 한 바퀴마다 응답 개수 하나를
// 늘려 막대 길이 비율이 실시간으로 바뀌는 것을 보여준다.
const order = [5, 4, 3, 2, 1];
let oi = 0;
const bump = [5, 3, 4, 5, 2, 5, 1, 4, 5, 3];
let bi = 0;
let autoTimer: number | null = null;
function autoTick() {
selected = order[oi];
oi = (oi + 1) % order.length;
if (oi === 0) { const v = bump[bi % bump.length]; bi++; counts[v] = counts[v] + 1; }
paint();
}
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, 380); autoTick(); }
return () => {
stopAutoplay();
rows.forEach((row, idx) => row.removeEventListener("click", clickHandlers[idx]));
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.list} role="list">
{STARS.map((star) => (
<button key={star} type="button" className={styles.row} role="listitem" aria-pressed="false">
<span className={styles.label}>{star}</span>
<span className={styles.track}><span className={styles.bar} /></span>
<span className={styles.pct}>0%</span>
</button>
))}
</div>
</div>
);
}05 · 개수 막대 분해 — 별점별 응답 개수 값이 가로 막대의 길이 값으로 나뉘어 나타나는 방식 — 측정 누적면적 22.699% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06말풍선 이름표 교체 — 가리키는 별의 순서 값에 맞는 이름표 문구 값이 바뀌어 나타나는 방식
가리키고 있는 별의 순서 값이 바뀔 때마다 그 값에 대응하는 이름표 문구 값이 함께 바뀌어 별 옆에 나타나고, 벗어난 상태가 되면 이름표가 사라집니다. 만족도 단계를 말로 확인시켜주는 평가 폼이나 리뷰 작성 전 점수 미리보기에 맞는 형태입니다.
위치를 옮기고 문구를 지연 교체하는 positionTip()·commit()·requestSwap()
// 계산 겹: 별의 실제 렌더 위치(getBoundingClientRect 중심 x)를 말풍선의 translateX 값으로
// 그대로 옮기고, 인덱스 변경은 짧은 지연(debounce)을 거친 뒤에만 이름표 문구를 바꾼다 —
// 빠르게 스쳐 지나갈 때 문구가 매 별마다 깜빡이지 않게 하는 타이밍 계산이다.
function positionTip(i) {
var star = stars[i - 1];
var sr = star.getBoundingClientRect();
var rr = row.getBoundingClientRect();
var cx = sr.left + sr.width / 2 - rr.left;
tip.style.transform = 'translateX(' + cx.toFixed(1) + 'px) translateX(-50%)';
}
function commit(i) {
shownIndex = i;
tip.textContent = LABELS[i];
positionTip(i);
tip.classList.add('sr__tip--show');
stars.forEach(function (s, idx) { s.classList.toggle('sr__star--filled', idx < i); });
}
function requestSwap(i) {
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(function () { commit(i); }, 40);
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__tip" id="srTip">보통</div>
<div class="sr__row" id="srRow">
<span class="sr__star" data-i="1"><svg 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 class="sr__star" data-i="2"><svg 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 class="sr__star" data-i="3"><svg 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 class="sr__star" data-i="4"><svg 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 class="sr__star" data-i="5"><svg 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>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 별의 실제 렌더 중심 좌표(getBoundingClientRect)를 말풍선 translateX 값으로 옮기고,
// 인덱스 변경은 40ms 지연(debounce) 뒤에만 문구를 바꿔 스쳐 지나갈 때의 깜빡임을 막는다.
@include stage($stage-orange);
.sr {
position: relative;
display: grid;
place-items: center;
gap: $sp-8;
}
.sr__tip {
position: absolute;
top: 2px;
left: 0;
transform: translateX(0) translateX(-50%);
background: $subject-ink;
color: $stage-paper;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
white-space: nowrap;
opacity: 0;
transition: opacity $dur-instant $ease-out, transform $dur-quick $ease-out;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: $subject-ink;
}
&--show { opacity: 1; }
}
.sr__row { display: flex; gap: $sp-2; margin-top: 40px; }
.sr__star {
width: 40px;
height: 40px;
color: $subject-orange-deep;
opacity: .45;
transition: color $dur-quick $ease-out, opacity $dur-quick $ease-out;
svg { display: block; width: 100%; height: 100%; fill: currentColor; }
&--filled { color: $subject-blue-deep; opacity: 1; }
}
@media (max-width: 340px) {
.sr__star { width: 32px; height: 32px; }
.sr__row { gap: $sp-1; }
.sr__tip { font-size: 12px; top: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sr__tip, .sr__star { 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);
}
.sr {
position: relative;
display: grid;
place-items: center;
gap: 32px;
}
.sr__tip {
position: absolute;
top: 2px;
left: 0;
transform: translateX(0) translateX(-50%);
background: #17141a;
color: #ffffff;
padding: 4px 12px;
border-radius: 8px;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
white-space: nowrap;
opacity: 0;
transition: opacity 140ms ease-out, transform 220ms ease-out;
}
.sr__tip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #17141a;
}
.sr__tip--show {
opacity: 1;
}
.sr__row {
display: flex;
gap: 8px;
margin-top: 40px;
}
.sr__star {
width: 40px;
height: 40px;
color: #c93000;
opacity: 0.45;
transition: color 220ms ease-out, opacity 220ms ease-out;
}
.sr__star svg {
display: block;
width: 100%;
height: 100%;
fill: currentColor;
}
.sr__star--filled {
color: #0a4fe8;
opacity: 1;
}
@media (max-width: 340px) {
.sr__star {
width: 32px;
height: 32px;
}
.sr__row {
gap: 4px;
}
.sr__tip {
font-size: 12px;
top: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__tip, .sr__star {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var row = document.getElementById('srRow');
var tip = document.getElementById('srTip');
var stars = Array.prototype.slice.call(row.querySelectorAll('.sr__star'));
var LABELS = { 1: '매우 별로', 2: '별로', 3: '보통', 4: '좋음', 5: '최고' };
var pendingTimer = null;
var shownIndex = 0;
// 계산 겹: 별의 실제 렌더 위치(getBoundingClientRect 중심 x)를 말풍선의 translateX 값으로
// 그대로 옮기고, 인덱스 변경은 짧은 지연(debounce)을 거친 뒤에만 이름표 문구를 바꾼다 —
// 빠르게 스쳐 지나갈 때 문구가 매 별마다 깜빡이지 않게 하는 타이밍 계산이다.
function positionTip(i) {
var star = stars[i - 1];
var sr = star.getBoundingClientRect();
var rr = row.getBoundingClientRect();
var cx = sr.left + sr.width / 2 - rr.left;
tip.style.transform = 'translateX(' + cx.toFixed(1) + 'px) translateX(-50%)';
}
function commit(i) {
shownIndex = i;
tip.textContent = LABELS[i];
positionTip(i);
tip.classList.add('sr__tip--show');
stars.forEach(function (s, idx) { s.classList.toggle('sr__star--filled', idx < i); });
}
function requestSwap(i) {
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(function () { commit(i); }, 40);
}
function hide() {
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
tip.classList.remove('sr__tip--show');
stars.forEach(function (s) { s.classList.remove('sr__star--filled'); });
}
stars.forEach(function (s) {
var i = Number(s.getAttribute('data-i'));
s.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); stopAutoplay(); requestSwap(i); });
});
row.addEventListener('pointerleave', hide);
// 자동 루프: 1부터 5까지 순서대로 머무르며 이름표 문구가 바뀌는 것을 보여주고 끝에서 감춘다.
var step = 0;
var autoTimer = null;
function autoTick() {
if (step < 5) { commit(step + 1); } else { hide(); }
step = (step + 1) % 7;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 340); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./TooltipLabelSwap.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";
const LABELS: Record<number, string> = { 1: "매우 별로", 2: "별로", 3: "보통", 4: "좋음", 5: "최고" };
export default function TooltipLabelSwap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const row = root.querySelector<HTMLDivElement>(`.${styles.row}`);
const tip = root.querySelector<HTMLDivElement>(`.${styles.tip}`);
if (!row || !tip) return;
const stars = Array.prototype.slice.call(
row.querySelectorAll(`.${styles.star}`)
) as HTMLElement[];
let pendingTimer: number | null = null;
// 계산 겹: 별의 실제 렌더 위치(getBoundingClientRect 중심 x)를 말풍선의 translateX 값으로
// 그대로 옮기고, 인덱스 변경은 짧은 지연(debounce)을 거친 뒤에만 이름표 문구를 바꾼다 —
// 빠르게 스쳐 지나갈 때 문구가 매 별마다 깜빡이지 않게 하는 타이밍 계산이다.
function positionTip(i: number) {
const star = stars[i - 1];
const sr = star.getBoundingClientRect();
const rr = row!.getBoundingClientRect();
const cx = sr.left + sr.width / 2 - rr.left;
tip!.style.transform = "translateX(" + cx.toFixed(1) + "px) translateX(-50%)";
}
function commit(i: number) {
tip!.textContent = LABELS[i];
positionTip(i);
tip!.classList.add(styles.show);
stars.forEach((s, idx) => s.classList.toggle(styles.filled, idx < i));
}
function requestSwap(i: number) {
if (pendingTimer !== null) window.clearTimeout(pendingTimer);
pendingTimer = window.setTimeout(() => commit(i), 40);
}
function hide() {
if (pendingTimer !== null) { window.clearTimeout(pendingTimer); pendingTimer = null; }
tip!.classList.remove(styles.show);
stars.forEach((s) => s.classList.remove(styles.filled));
}
const enterHandlers: Array<() => void> = [];
stars.forEach((s, idx) => {
const i = idx + 1;
const handler = () => { stopAutoplay(); requestSwap(i); };
enterHandlers.push(handler);
s.addEventListener("pointerenter", handler);
});
row.addEventListener("pointerleave", hide);
// 자동 루프: 1부터 5까지 순서대로 머무르며 이름표 문구가 바뀌는 것을 보여주고 끝에서 감춘다.
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step < 5) { commit(step + 1); } else { hide(); }
step = (step + 1) % 7;
}
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, 340); autoTick(); }
return () => {
stopAutoplay();
if (pendingTimer !== null) window.clearTimeout(pendingTimer);
stars.forEach((s, idx) => s.removeEventListener("pointerenter", enterHandlers[idx]));
row.removeEventListener("pointerleave", hide);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.tip}>보통</div>
<div className={styles.row}>
{[1, 2, 3, 4, 5].map((i) => (
<span key={i} className={styles.star}>
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</span>
))}
</div>
</div>
</div>
);
}06 · 말풍선 이름표 교체 — 가리키는 별의 순서 값에 맞는 이름표 문구 값이 바뀌어 나타나는 방식 — 측정 누적면적 6.522% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07값별 색 전환 — 선택된 값의 크기에 따라 별의 색상 값이 단계적으로 바뀌는 방식
선택된 별의 순서 값이 낮은 범위에 있으면 색상 값이 한쪽으로, 높은 범위에 있으면 다른 쪽 색상 값으로 바뀌고 그 사이 값은 두 색 사이에서 이어집니다. 만족도를 색으로도 구분하는 평가 위젯이나 낮은 점수를 눈에 띄게 하는 내부 검수 폼에 맞는 형태입니다.
두 토큰 색을 채널별로 섞는 mix()
// 토큰 사이 보간 — demo-tokens 의 $error(낮은 값)와 $subject-mint(높은 값) hex 를 그대로 두 끝점으로 쓴다.
var LOW = [0xc2, 0x36, 0x1a];
var HIGH = [0x4c, 0xd4, 0xa6];
// 계산 겹: 선택된 값(1~5)을 0~1 비율 t 로 바꾼 뒤, 두 토큰 색의 R·G·B 채널을 각각
// t 만큼 선형보간(mix)해 중간 색을 만든다 — HSL 회전이 아니라 채널별 산술 혼합이다.
function mix(t) {
var c = LOW.map(function (v, i) { return Math.round(v + (HIGH[i] - v) * t); });
return 'rgb(' + c.join(',') + ')';
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow">
<button class="sr__star" type="button" data-i="1"><svg 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></button>
<button class="sr__star" type="button" data-i="2"><svg 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></button>
<button class="sr__star" type="button" data-i="3"><svg 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></button>
<button class="sr__star" type="button" data-i="4"><svg 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></button>
<button class="sr__star" type="button" data-i="5"><svg 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></button>
</div>
<div class="sr__readout" id="srReadout">값 <b id="srNum">3</b></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 선택된 값(1~5)을 0~1 비율로 바꾼 뒤 두 토큰 색의 R·G·B 채널을 각각 그 비율만큼
// 선형보간(mix())해 중간 색을 만든다 — HSL 회전이 아니라 채널별 산술 혼합이다.
@include stage($stage-ink);
.sr {
display: grid;
gap: $sp-5;
place-items: center;
}
.sr__row { display: flex; gap: $sp-2; }
.sr__star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: rgba($stage-paper, .25);
transition: color $dur-base $ease-out, transform $dur-quick $ease-out;
svg { display: block; width: 100%; height: 100%; fill: currentColor; }
&--filled { transform: scale(1.05); }
}
.sr__readout {
font: 700 20px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
transition: color $dur-base $ease-out;
b { font-variant-numeric: tabular-nums; }
}
@media (max-width: 340px) {
.sr__star { width: 34px; height: 34px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__star, .sr__readout { 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;
}
.sr__star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: rgba(255, 255, 255, 0.25);
transition: color 300ms ease-out, transform 220ms ease-out;
}
.sr__star svg {
display: block;
width: 100%;
height: 100%;
fill: currentColor;
}
.sr__star--filled {
transform: scale(1.05);
}
.sr__readout {
font: 700 20px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
transition: color 300ms ease-out;
}
.sr__readout b {
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.sr__star {
width: 34px;
height: 34px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__star, .sr__readout {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var stars = Array.prototype.slice.call(document.querySelectorAll('.sr__star'));
var numEl = document.getElementById('srNum');
var readout = document.getElementById('srReadout');
// 토큰 사이 보간 — demo-tokens 의 $error(낮은 값)와 $subject-mint(높은 값) hex 를 그대로 두 끝점으로 쓴다.
var LOW = [0xc2, 0x36, 0x1a];
var HIGH = [0x4c, 0xd4, 0xa6];
// 계산 겹: 선택된 값(1~5)을 0~1 비율 t 로 바꾼 뒤, 두 토큰 색의 R·G·B 채널을 각각
// t 만큼 선형보간(mix)해 중간 색을 만든다 — HSL 회전이 아니라 채널별 산술 혼합이다.
function mix(t) {
var c = LOW.map(function (v, i) { return Math.round(v + (HIGH[i] - v) * t); });
return 'rgb(' + c.join(',') + ')';
}
function paint(value) {
var t = (value - 1) / 4;
var color = mix(t);
stars.forEach(function (s, idx) {
s.style.color = idx < value ? color : '';
s.classList.toggle('sr__star--filled', idx < value);
});
numEl.textContent = String(value);
readout.style.color = color;
}
stars.forEach(function (s) {
var i = Number(s.getAttribute('data-i'));
s.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); paint(i); });
});
paint(3);
// 자동 루프: 값을 1→5→1 로 오가며 두 토큰 색 사이를 잇는 보간 색을 보여준다.
var seq = [1, 2, 3, 4, 5, 4, 3, 2];
var si = 0;
var autoTimer = null;
function autoTick() { paint(seq[si]); si = (si + 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, 340); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ValueColorShift.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 ValueColorShift() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const stars = Array.prototype.slice.call(
root.querySelectorAll(`.${styles.star}`)
) as HTMLElement[];
const numEl = root.querySelector<HTMLElement>(`.${styles.num}`);
const readout = root.querySelector<HTMLElement>(`.${styles.readout}`);
if (!numEl || !readout) return;
// 토큰 사이 보간 — demo-tokens 의 $error(낮은 값)와 $subject-mint(높은 값) hex 를 그대로 두 끝점으로 쓴다.
const LOW = [0xc2, 0x36, 0x1a];
const HIGH = [0x4c, 0xd4, 0xa6];
// 계산 겹: 선택된 값(1~5)을 0~1 비율 t 로 바꾼 뒤, 두 토큰 색의 R·G·B 채널을 각각
// t 만큼 선형보간(mix)해 중간 색을 만든다 — HSL 회전이 아니라 채널별 산술 혼합이다.
function mix(t: number) {
const c = LOW.map((v, i) => Math.round(v + (HIGH[i] - v) * t));
return "rgb(" + c.join(",") + ")";
}
function paint(value: number) {
const t = (value - 1) / 4;
const color = mix(t);
stars.forEach((s, idx) => {
s.style.color = idx < value ? color : "";
s.classList.toggle(styles.filled, idx < value);
});
numEl!.textContent = String(value);
readout!.style.color = color;
}
const clickHandlers: Array<() => void> = [];
stars.forEach((s, idx) => {
const i = idx + 1;
const handler = () => { stopAutoplay(); paint(i); };
clickHandlers.push(handler);
s.addEventListener("click", handler);
});
paint(3);
// 자동 루프: 값을 1→5→1 로 오가며 두 토큰 색 사이를 잇는 보간 색을 보여준다.
const seq = [1, 2, 3, 4, 5, 4, 3, 2];
let si = 0;
let autoTimer: number | null = null;
function autoTick() { paint(seq[si]); si = (si + 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, 340); autoTick(); }
return () => {
stopAutoplay();
stars.forEach((s, idx) => s.removeEventListener("click", clickHandlers[idx]));
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row}>
{[1, 2, 3, 4, 5].map((i) => (
<button key={i} type="button" className={styles.star}>
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</button>
))}
</div>
<div className={styles.readout}>값 <b className={styles.num}>3</b></div>
</div>
</div>
);
}07 · 값별 색 전환 — 선택된 값의 크기에 따라 별의 색상 값이 단계적으로 바뀌는 방식 — 측정 누적면적 3.591% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08되돌림 스프링 초기화 — 같은 별을 다시 누른 상태가 되면 채움 값이 0으로 튕기듯 줄어드는 방식
이미 선택된 값과 같은 별이 다시 눌린 상태가 되면 채움 값이 0을 향해 튕기는 움직임 값을 거쳐 줄어들고, 선택 값도 함께 지워집니다. 실수로 매긴 점수를 지우는 리뷰 폼이나 선택을 취소할 수 있는 평가 위젯에 맞는 형태입니다.
채움을 0으로 보내고 튕김 클래스를 씌우는 reset()
// 계산 겹: 초기화가 일어나면 채움 값을 0으로 보내는 동시에, 이미 채워졌던 별에만
// 스프링 이징(퇴장 전 오버슛)이 걸린 팝 애니메이션 클래스를 씌운다.
function reset() {
var wasFilled = stars.filter(function (s, idx) { return idx < value; });
value = 0;
paint(value);
wasFilled.forEach(function (s) { s.classList.add('sr__star--bounce'); });
setTimeout(function () {
wasFilled.forEach(function (s) { s.classList.remove('sr__star--bounce'); });
}, 620);
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow">
<button class="sr__star" type="button" data-i="1"><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></button>
<button class="sr__star" type="button" data-i="2"><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></button>
<button class="sr__star" type="button" data-i="3"><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></button>
<button class="sr__star" type="button" data-i="4"><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></button>
<button class="sr__star" type="button" data-i="5"><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></button>
</div>
<div class="sr__readout"><span id="srHint">같은 별 다시 클릭 → 초기화</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 초기화 순간 이미 채워져 있던 별에만 스프링 이징($ease-elastic, 목표를 넘겼다가
// 되돌아오는 오버슛 곡선)으로 팝 애니메이션을 걸어 "튕기며 사라지는" 느낌을 만든다.
@include stage($stage-paper);
.sr {
display: grid;
gap: $sp-5;
place-items: center;
}
.sr__row { display: flex; gap: $sp-2; }
.sr__star {
position: relative;
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
&--bounce { animation: srBounce $dur-enter $ease-elastic; }
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base { fill: $subject-ink; opacity: .18; }
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
transition: width $dur-base $ease-out;
}
.sr__fill { fill: $subject-blue-deep; }
.sr__readout {
font: 500 13px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
opacity: .7;
}
@keyframes srBounce {
0% { transform: scale(1); }
35% { transform: scale(1.3); }
100% { transform: scale(.85); opacity: 0; }
}
@media (max-width: 340px) {
.sr__star { width: 34px; height: 34px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip { transition: none; }
.sr__star--bounce { animation: 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);
}
.sr {
display: grid;
gap: 20px;
place-items: center;
}
.sr__row {
display: flex;
gap: 8px;
}
.sr__star {
position: relative;
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.sr__star--bounce {
animation: srBounce 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base {
fill: #17141a;
opacity: 0.18;
}
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
transition: width 300ms ease-out;
}
.sr__fill {
fill: #0a4fe8;
}
.sr__readout {
font: 500 13px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
opacity: 0.7;
}
@keyframes srBounce {
0% {
transform: scale(1);
}
35% {
transform: scale(1.3);
}
100% {
transform: scale(0.85);
opacity: 0;
}
}
@media (max-width: 340px) {
.sr__star {
width: 34px;
height: 34px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip {
transition: none;
}
.sr__star--bounce {
animation: none;
}
}(function () {
var root = document.getElementById('sr');
var stars = Array.prototype.slice.call(document.querySelectorAll('.sr__star'));
var value = 4;
function paint(v) {
stars.forEach(function (s, idx) {
var starVal = Math.max(0, Math.min(1, v - idx));
s.querySelector('[data-fc]').style.width = (starVal * 100).toFixed(1) + '%';
});
}
// 계산 겹: 초기화가 일어나면 채움 값을 0으로 보내는 동시에, 이미 채워졌던 별에만
// 스프링 이징(퇴장 전 오버슛)이 걸린 팝 애니메이션 클래스를 씌운다.
function reset() {
var wasFilled = stars.filter(function (s, idx) { return idx < value; });
value = 0;
paint(value);
wasFilled.forEach(function (s) { s.classList.add('sr__star--bounce'); });
setTimeout(function () {
wasFilled.forEach(function (s) { s.classList.remove('sr__star--bounce'); });
}, 620);
}
stars.forEach(function (s) {
var i = Number(s.getAttribute('data-i'));
s.addEventListener('click', function () {
root.classList.remove('is-demo'); stopAutoplay();
if (i === value) reset(); else { value = i; paint(value); }
});
});
paint(value);
// 자동 루프: 값을 채운 뒤 같은 별을 다시 누른 상태를 흉내내 스프링 초기화를 반복해 보여준다.
var phase = 0;
var autoTimer = null;
function autoTick() {
if (phase === 0) { value = 4; paint(value); }
else if (phase === 1) { reset(); }
phase = (phase + 1) % 3;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 700); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ResetClearSpring.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 ResetClearSpring() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const stars = Array.prototype.slice.call(
root.querySelectorAll(`.${styles.star}`)
) as HTMLElement[];
let value = 4;
function paint(v: number) {
stars.forEach((s, idx) => {
const starVal = Math.max(0, Math.min(1, v - idx));
s.querySelector<HTMLElement>(`.${styles.fillclip}`)!.style.width = (starVal * 100).toFixed(1) + "%";
});
}
// 계산 겹: 초기화가 일어나면 채움 값을 0으로 보내는 동시에, 이미 채워졌던 별에만
// 스프링 이징(퇴장 전 오버슛)이 걸린 팝 애니메이션 클래스를 씌운다.
function reset() {
const wasFilled = stars.filter((_s, idx) => idx < value);
value = 0;
paint(value);
wasFilled.forEach((s) => s.classList.add(styles.bounce));
window.setTimeout(() => {
wasFilled.forEach((s) => s.classList.remove(styles.bounce));
}, 620);
}
const clickHandlers: Array<() => void> = [];
stars.forEach((s, idx) => {
const i = idx + 1;
const handler = () => {
stopAutoplay();
if (i === value) reset(); else { value = i; paint(value); }
};
clickHandlers.push(handler);
s.addEventListener("click", handler);
});
paint(value);
// 자동 루프: 값을 채운 뒤 같은 별을 다시 누른 상태를 흉내내 스프링 초기화를 반복해 보여준다.
let phase = 0;
let autoTimer: number | null = null;
function autoTick() {
if (phase === 0) { value = 4; paint(value); }
else if (phase === 1) { reset(); }
phase = (phase + 1) % 3;
}
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, 700); autoTick(); }
return () => {
stopAutoplay();
stars.forEach((s, idx) => s.removeEventListener("click", clickHandlers[idx]));
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row}>
{[1, 2, 3, 4, 5].map((i) => (
<button key={i} type="button" 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>
</button>
))}
</div>
<div className={styles.readout}>같은 별 다시 클릭 → 초기화</div>
</div>
</div>
);
}08 · 되돌림 스프링 초기화 — 같은 별을 다시 누른 상태가 되면 채움 값이 0으로 튕기듯 줄어드는 방식 — 측정 누적면적 4.791% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09터치 드래그 훑기 — 손가락이 움직인 가로 값을 따라 채움 값이 실시간으로 바뀌는 방식
손가락이 별 목록 위를 누른 채 움직인 상태에서 그 가로 위치 값이 바뀔 때마다 채움 값이 같은 화면에서 바로 따라 바뀌고, 손을 뗀 상태가 되면 그 값이 선택 값으로 고정됩니다. 모바일 화면의 빠른 별점 입력이나 터치로 훑어 점수를 매기는 리뷰 폼에 맞는 형태입니다.
손을 뗀 속도 값을 감쇠시키는 runInertia()
function onUp() {
if (!dragging) return;
dragging = false;
runInertia();
}
// 계산 겹: 손을 뗀 시점의 속도(px/ms)를 그대로 이어받아 매 프레임 감쇠(마찰 0.945배)시키며
// 위치를 계속 옮기는 관성 스크럽 — 손을 뗀 순간 값이 뚝 멈추지 않고 흐르다 멈춘다.
function runInertia() {
velocity *= 0.945;
setPos(pos + velocity * 16);
if (Math.abs(velocity) > 0.015 && pos > 0 && pos < width()) {
inertiaRaf = requestAnimationFrame(runInertia);
} else {
inertiaRaf = null;
}
}

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow">
<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__track" id="srTrack"><span class="sr__thumb" id="srThumb"></span></div>
<div class="sr__readout"><b id="srNum">0.0</b> / 5</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 손을 뗀 시점의 속도(px/ms)를 매 프레임 0.92배로 감쇠시키며 위치를 계속 옮기는
// 관성(runInertia()) — 드래그가 끝나도 값이 즉시 멈추지 않고 흐르다 멈춘다.
@include stage($stage-ink);
.sr {
display: grid;
gap: $sp-4;
place-items: center;
width: 100%;
}
.sr__row { display: flex; gap: $sp-2; }
.sr__star {
position: relative;
width: 36px;
height: 36px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base { fill: $subject-blue-light; opacity: .3; }
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
}
.sr__fill { fill: $subject-pink; }
.sr__track {
position: relative;
width: clamp(200px, 60vw, 260px);
height: 10px;
border-radius: $r-pill;
background: rgba($stage-paper, .16);
touch-action: none;
cursor: grab;
}
.sr__thumb {
position: absolute;
top: 50%;
left: 0;
width: 20px;
height: 20px;
margin-top: -10px;
margin-left: -10px;
border-radius: 50%;
background: $subject-pink;
box-shadow: $shadow-press;
}
.sr__readout {
font: 600 15px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
b { color: $subject-pink; font-variant-numeric: tabular-nums; }
}
@media (max-width: 340px) {
.sr__star { width: 28px; height: 28px; }
.sr__row { gap: $sp-1; }
.sr__track { width: 84vw; }
}
@media (prefers-reduced-motion: reduce) {
.sr__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr {
display: grid;
gap: 16px;
place-items: center;
width: 100%;
}
.sr__row {
display: flex;
gap: 8px;
}
.sr__star {
position: relative;
width: 36px;
height: 36px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base {
fill: #5b8cf8;
opacity: 0.3;
}
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
}
.sr__fill {
fill: #ff9ebb;
}
.sr__track {
position: relative;
width: clamp(200px, 60vw, 260px);
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
touch-action: none;
cursor: grab;
}
.sr__thumb {
position: absolute;
top: 50%;
left: 0;
width: 20px;
height: 20px;
margin-top: -10px;
margin-left: -10px;
border-radius: 50%;
background: #ff9ebb;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sr__readout {
font: 600 15px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
}
.sr__readout b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.sr__star {
width: 28px;
height: 28px;
}
.sr__row {
gap: 4px;
}
.sr__track {
width: 84vw;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__thumb {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var track = document.getElementById('srTrack');
var thumb = document.getElementById('srThumb');
var clips = Array.prototype.slice.call(document.querySelectorAll('[data-fc]'));
var numEl = document.getElementById('srNum');
var pos = 0;
var velocity = 0;
var lastX = 0, lastT = 0;
var dragging = false;
var inertiaRaf = null;
function width() { return track.getBoundingClientRect().width; }
function paintFromPos(p) {
var w = width();
var v = Math.max(0, Math.min(5, (p / w) * 5));
clips.forEach(function (el, idx) {
var starVal = Math.max(0, Math.min(1, v - idx));
el.style.width = (starVal * 100).toFixed(1) + '%';
});
numEl.textContent = v.toFixed(1);
thumb.style.transform = 'translateX(' + p.toFixed(1) + 'px)';
}
function setPos(p) { pos = Math.max(0, Math.min(width(), p)); paintFromPos(pos); }
function onDown(e) {
root.classList.remove('is-demo'); stopAutoplay();
if (inertiaRaf) { cancelAnimationFrame(inertiaRaf); inertiaRaf = null; }
dragging = true;
track.setPointerCapture(e.pointerId);
lastX = e.clientX; lastT = performance.now();
var r = track.getBoundingClientRect();
setPos(e.clientX - r.left);
}
function onMove(e) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(1, now - lastT);
velocity = (e.clientX - lastX) / dt;
lastX = e.clientX; lastT = now;
var r = track.getBoundingClientRect();
setPos(e.clientX - r.left);
}
// 계산 겹: 손을 뗀 시점의 속도(px/ms)를 그대로 이어받아 매 프레임 감쇠(마찰 0.945배)시키며
// 위치를 계속 옮기는 관성 스크럽 — 손을 뗀 순간 값이 뚝 멈추지 않고 흐르다 멈춘다.
function onUp() {
if (!dragging) return;
dragging = false;
runInertia();
}
function runInertia() {
velocity *= 0.945;
setPos(pos + velocity * 16);
if (Math.abs(velocity) > 0.015 && pos > 0 && pos < width()) {
inertiaRaf = requestAnimationFrame(runInertia);
} else {
inertiaRaf = null;
}
}
track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
setPos(0);
// 자동 루프: 드래그로 오른쪽까지 훑은 뒤 손을 뗀 상태를 흉내내 관성으로 더 흐르다 멈추는 것을
// 반복한다 — 매 구간마다 방향을 바꿔 찔러 넣어(impulse) 감쇠 구간이 항상 겹쳐 흐르게 한다.
var phase = 0;
var autoTimer = null;
function autoTick() {
if (phase === 0) { setPos(width() * 0.08); velocity = 0.85; }
else if (phase === 1) { velocity = 0.7; }
else if (phase === 2) { velocity = -0.75; }
else if (phase === 3) { velocity = -0.4; }
runInertia();
phase = (phase + 1) % 4;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 460); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./TouchDragScrub.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 TouchDragScrub() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const thumb = root.querySelector<HTMLElement>(`.${styles.thumb}`);
const numEl = root.querySelector<HTMLElement>(`.${styles.num}`);
if (!track || !thumb || !numEl) return;
const clips = Array.prototype.slice.call(
root.querySelectorAll(`.${styles.fillclip}`)
) as HTMLElement[];
let pos = 0;
let velocity = 0;
let lastX = 0, lastT = 0;
let dragging = false;
let inertiaRaf: number | null = null;
function width() { return track!.getBoundingClientRect().width; }
function paintFromPos(p: number) {
const w = width();
const v = Math.max(0, Math.min(5, (p / w) * 5));
clips.forEach((el, idx) => {
const starVal = Math.max(0, Math.min(1, v - idx));
el.style.width = (starVal * 100).toFixed(1) + "%";
});
numEl!.textContent = v.toFixed(1);
thumb!.style.transform = "translateX(" + p.toFixed(1) + "px)";
}
function setPos(p: number) { pos = Math.max(0, Math.min(width(), p)); paintFromPos(pos); }
function onDown(e: PointerEvent) {
stopAutoplay();
if (inertiaRaf !== null) { cancelAnimationFrame(inertiaRaf); inertiaRaf = null; }
dragging = true;
track!.setPointerCapture(e.pointerId);
lastX = e.clientX; lastT = performance.now();
const r = track!.getBoundingClientRect();
setPos(e.clientX - r.left);
}
function onMove(e: PointerEvent) {
if (!dragging) return;
const now = performance.now();
const dt = Math.max(1, now - lastT);
velocity = (e.clientX - lastX) / dt;
lastX = e.clientX; lastT = now;
const r = track!.getBoundingClientRect();
setPos(e.clientX - r.left);
}
// 계산 겹: 손을 뗀 시점의 속도(px/ms)를 그대로 이어받아 매 프레임 감쇠(마찰 0.945배)시키며
// 위치를 계속 옮기는 관성 스크럽 — 손을 뗀 순간 값이 뚝 멈추지 않고 흐르다 멈춘다.
function onUp() {
if (!dragging) return;
dragging = false;
runInertia();
}
function runInertia() {
velocity *= 0.945;
setPos(pos + velocity * 16);
if (Math.abs(velocity) > 0.015 && pos > 0 && pos < width()) {
inertiaRaf = requestAnimationFrame(runInertia);
} else {
inertiaRaf = null;
}
}
track.addEventListener("pointerdown", onDown);
track.addEventListener("pointermove", onMove);
track.addEventListener("pointerup", onUp);
setPos(0);
// 자동 루프: 드래그로 오른쪽까지 훑은 뒤 손을 뗀 상태를 흉내내 관성으로 더 흐르다 멈추는 것을
// 반복한다 — 매 구간마다 방향을 바꿔 찔러 넣어(impulse) 감쇠 구간이 항상 겹쳐 흐르게 한다.
let phase = 0;
let autoTimer: number | null = null;
function autoTick() {
if (phase === 0) { setPos(width() * 0.08); velocity = 0.85; }
else if (phase === 1) { velocity = 0.7; }
else if (phase === 2) { velocity = -0.75; }
else if (phase === 3) { velocity = -0.4; }
runInertia();
phase = (phase + 1) % 4;
}
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, 460); autoTick(); }
return () => {
stopAutoplay();
if (inertiaRaf !== null) cancelAnimationFrame(inertiaRaf);
track.removeEventListener("pointerdown", onDown);
track.removeEventListener("pointermove", onMove);
track.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row}>
{[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.track}><span className={styles.thumb} /></div>
<div className={styles.readout}><b className={styles.num}>0.0</b> / 5</div>
</div>
</div>
);
}09 · 터치 드래그 훑기 — 손가락이 움직인 가로 값을 따라 채움 값이 실시간으로 바뀌는 방식 — 측정 누적면적 4.96% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 위치·비율·시간 값의 경계
실제로 별점 만들기를 손으로 할 때는 값 세 개만 옮겨 봐도 손에 잡히는 인상이 크게 갈립니다.
따라가는 속도 기준 — 03 lerp 계수
03 의 lerp 계수(0.2)는 매 프레임 표시 값이 목표 값 쪽으로 좁혀지는 비율이라, 0.2 면 방향키를 눌러도 표시 값이 목표 값 쪽으로 천천히 곡선을 그리며 따라가 방향 전환이 부드럽게 보이고, 0.4 로 올리면 같은 방향키라도 값이 목표에 훨씬 빨리 붙어 반응이 즉각적으로 느껴지는 대신 관성 곡선은 짧아집니다.
이름표 지연 기준 — 06 requestSwap 지연
06 의 requestSwap 지연(40ms)은 가리키는 별이 바뀐 뒤 실제로 이름표 문구를 바꾸기까지 기다리는 시간 값이라, 40ms 면 별 사이를 빠르게 스쳐도 이름표 문구가 거의 즉시 따라오고, 90ms 로 늘리면 짧게 스쳐 지나가는 호버에는 문구가 아예 안 바뀌어 실수로 스치는 깜빡임은 줄지만 의도적으로 멈춘 호버에도 문구가 살짝 늦게 나타납니다.
관성 마찰 기준 — 09 runInertia 마찰
09 의 runInertia 마찰 값(0.945)은 손을 뗀 뒤 매 프레임 속도에 곱해지는 감쇠 비율이라, 0.945 면 손을 뗀 뒤 관성이 짧게만 흐르다 금방 멈춰 값이 손을 뗀 위치에 가깝게 고정되고, 0.975 로 올리면 같은 속도로 손을 떼도 훨씬 오래 미끄러지듯 흘러가 뗀 지점보다 값이 한참 더 이동한 뒤에 멈춥니다.
어디서 깨지는가 — 빠른 연속 클릭·좁은 화면·터치와 마우스가 섞일 때
아홉 개 다 같은 값을 넣으면 매번 같은 결과가 나오는 계산이지만, 값을 극단으로 밀어붙이는 순간 그 계산의 경계가 화면에 그대로 드러나는 자리가 셋 있습니다.
빠른 연속 클릭 — 08
08 은 reset()이 채움 값을 0으로 되돌리는 동시에 이미 채워져 있던 별에 sr__star--bounce 클래스를 씌우고, 그 클래스를 지우는 setTimeout을 620ms 뒤로 걸어 둡니다. 이 620ms 가 다 지나기 전에 같은 별을 빠르게 다시 눌러 값을 채우면, 방금 값을 0으로 보낸 클릭이 걸어 둔 지연 타이머가 아직 살아 있는 채로 그 별에 채움 값이 다시 올라가 — 튕겨 나가는 애니메이션 클래스가 남아 있는 별 위에서 채움 값이 곧바로 다시 차오르는 장면이 겹쳐 보입니다.
좁은 화면 — 06
06 은 positionTip()이 별의 중심 x 좌표만 계산해 말풍선의 translateX 값으로 쓸 뿐, 화면 경계로 clamp 하는 로직이 이 함수 안에는 없습니다. haq93vzh 화면 폭을 좁혀 첫째 별을 직접 가리켜 보면 이 값이 재현됩니다 — 별이 화면 왼쪽 가장자리에 가까울수록 말풍선의 절반이 화면 밖으로 나가는데도, 계산은 별의 중심 값만 볼 뿐 말풍선 자체의 폭 값은 한 번도 재지 않기 때문입니다.
터치와 마우스가 섞일 때 — 09
09 는 onDown이 눌릴 때마다 dragging을 항상 true로 세우고 그 포인터의 좌표로 lastX·lastT를 새로 씁니다. 마우스로 누른 채 드래그하는 중에 다른 포인터(터치)가 같은 트랙 위에서 또 pointerdown을 일으키면, dragging이 이미 참인지 확인하는 값 비교 없이 onDown이 다시 실행돼 위치 값이 나중 포인터의 좌표로 순간 이동합니다 — 두 입력이 한 트랙을 같이 누르는 순간 값이 계산상 하나의 손가락처럼 다뤄지기 때문입니다.
변형 세 가지와 접근성
별점 만들기를 직접 해 보면 아래 값 세 개만 옮겨도 완성된 느낌이 눈에 띄게 갈립니다.
| 이름 | 바뀐 값 | 느낌 |
|---|---|---|
| 방향키 값 이동의 따라가는 속도 | 03 lerp계수 0.2 → 0.4 | 0.2 면 방향키를 눌러도 표시 값이 목표 값 쪽으로 천천히 곡선을 그리며 따라가 방향 전환이 부드럽게 보이고, 0.4 면 같은 방향키라도 값이 목표에 훨씬 빨리 붙어 반응이 즉각적으로 느껴지는 대신 관성 곡선은 짧아진다 |
| 말풍선 이름표 교체의 debounce 지연 | 06 requestSwap 지연 40ms → 90ms | 40ms 면 별 사이를 빠르게 스쳐도 이름표 문구가 거의 즉시 따라오고, 90ms 면 짧게 스쳐 지나가는 호버에는 문구가 아예 안 바뀌어 실수로 스치는 깜빡임은 줄지만 의도적으로 멈춘 호버에도 문구가 살짝 늦게 나타난다 |
| 터치 드래그 훑기의 관성 마찰 | 09 runInertia 마찰 0.945 → 0.975 | 0.945 면 손을 뗀 뒤 관성이 짧게만 흐르다 금방 멈춰 값이 손을 뗀 위치에 가깝게 고정되고, 0.975 면 같은 속도로 손을 떼도 훨씬 오래 미끄러지듯 흘러가 뗀 지점보다 값이 한참 더 이동한 뒤에 멈춘다 |
접근성 값은 아홉 개가 고르지 않습니다. 01 은 role="radiogroup"과 role="radio"·aria-checked를 별마다 매기고, 03 은 슬라이더 역할(role="slider")에 tabindex="0"과 aria-valuemin·aria-valuemax·aria-valuenow 값을 실제로 갱신하며, 05 는 role="list"·role="listitem"과 aria-pressed 값을 행마다 매깁니다. 나머지 02·04·06·07·08·09 는 별도 role·aria 속성 없이 포인터나 클릭에만 반응합니다. 운영체제 값이 동작 줄임으로 맞춰지면 아홉 개 다 자동 재생 setInterval 루프부터 멈추고 각자 정한 CSS 트랜지션 하나씩(sr__star·sr__fillclip·sr__row·sr__tip·sr__thumb 등)이 함께 꺼지지만, 03 은 예외입니다 — 방향키를 직접 눌러 animateTo()를 부르면 reduce 값과 무관하게 requestAnimationFrame lerp 가 그대로 돌아 표시 값이 매 프레임 다시 그려집니다. prefers-reduced-motion 문서가 다루는 것은 자동 재생 쪽뿐이라, 사람이 직접 조작해 만드는 값 계산까지 막지는 않는다는 뜻입니다.
FAQ
별점 UI에서 반 칸(half star) 값은 어떻게 계산하나요?
02 처럼 각 별의 실제 렌더 폭을 getBoundingClientRect로 재고, 포인터의 가로 좌표가 그 폭의 왼쪽 절반에 있는지 오른쪽 절반에 있는지로 정수 값과 0.5 값을 가릅니다. 별 이미지 자체를 반으로 자른 그림을 따로 준비할 필요 없이, 채움 클립의 너비 값만 50%로 맞추면 됩니다.
css 별점 표시를 코드 하나로 옮겨서 그대로 쓸 수 있나요?
네, 아홉 개 다 별도 라이브러리나 아이콘 폰트 없이 SVG path 하나와 값 계산 함수만으로 동작합니다. 채움 값을 만드는 함수(paint·average·mix 등)와 이벤트 리스너 등록부만 그대로 옮기면 되고, 값의 이름(변수명)만 프로젝트 사정에 맞게 바꾸면 됩니다.
평균 평점에 소수점이 있을 때 별을 어떻게 채우나요?
04 처럼 평균 값에서 정수 부분만큼 별을 온전히 채우고, 남은 소수점 부분 값을 그다음 별 하나의 채움 너비로 겹쳐 그리면 됩니다. 예를 들어 평균이 4.16 이면 별 넷은 완전히 채우고 다섯째 별만 16% 너비로 겹쳐 채우는 식입니다.
더 읽을거리
- CSS 하트 애니메이션 좋아요·별점 9종 — 한 줄 복붙으로 — 037 은 좋아요·박수·북마크 같은 반응 아이콘이 눌리는 순간 채워지는 값을 다루는 데 비해, 이 편은 리뷰 입력 위젯 자체의 채움 값 계산을 다룹니다.
- 세그먼트 컨트롤 선택 UI 9종 — 복붙하면 되는 — 209 는 여러 구간 중 하나를 고르는 컨트롤의 선택 값 이동을 다루는 데 비해, 이 편은 별 다섯 개 위에서 순서·비율·속도 값이 채움으로 바뀌는 계산을 다룹니다.
- 시간 선택 ui 9종 — 시각 하나가 아니라 구간까지 — 262 는 시각이나 구간 값을 고르는 계산을 다루는 데 비해, 이 편은 리뷰 점수라는 하나의 값을 여러 입력 방식으로 계산하는 위젯을 다룹니다.









