星評価 UI 9選 — 塗りの値が状態に応じて変わる仕組み
9つのうち1つは指が動いた横方向の値に沿って塗りの値がすぐ変わり、1つは平均値の小数点部分が星1つの塗り幅の値として重なって現れ、もう1つは同じ星がもう一度押された状態になると塗りの値が0に向けて弾むように減ります。星評価 ui を9パターン分けて実装しました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ホバー先読み塗り — 指している星の順番の値まで塗りの状態が先に見え、外れると元の値に戻る方式
- 02 半星精密塗り — 星1つ分の幅の値のどちら半分にポインターの横位置の値があるかで塗りの値が整数か半星の値に分かれる方式
- 03 矢印キー値移動 — 押された方向の値だけ選ばれた星の順番の値が1つ増減する方式
- 04 平均値重ね塗り — 平均値の小数点部分が星1つ分の塗り幅の値として重なって現れる方式
- 05 件数バー分解 — 星の点数ごとの回答件数の値が横棒の長さの値に分かれて現れる方式
- 06 ふきだしラベル切替 — 指している星の順番の値に合わせてラベル文言の値が変わって現れる方式
- 07 値別色変化 — 選ばれた値の大きさに応じて星の色の値が段階的に変わる方式
- 08 戻しバネ初期化 — 同じ星がもう一度押された状態になると塗りの値が0に向けて弾むように減る方式
- 09 タッチドラッグ操作 — 指が動いた横方向の値に沿って塗りの値がリアルタイムで変わる方式
9つのどれも実際のサーバーには触れていません。画面に見える評価や件数はすべてスクリプトの中で宣言されたローカルな数値で、fetch の呼び出しはどこにもありません。9つとも自動再生だけで動くのではなく、必ず本物のユーザー入力に応答します。2個はホバー(pointerenter・pointerleave)に、2個はポインター移動(pointermove、うち1個は Pointer Events によるドラッグ捕捉つき)に、1個は矢印キーの keydown に、4個は click に応答します — 2足す2足す1足す4で、9つとも実際のトリガーです。
9つのパターンの解剖 — どの値がいつ変わるか
9つは、入力が実際にどの値を書き換えるかで分かれます。ホバーされた順番の値か、横方向の座標の値か、キーボードの目標値か、確定する評価の値か — 評価 星 デザインの集まりが「同じ5つの星が9回並んでいるだけ」に見えずに済むのは、この分かれ方があるためです。
なぜ2つは確定値ではなく先読みの値を書くのか
01と06はどちらも pointerenter を聞いていますが、入っただけでは何も確定しません。01の previewTo() は指している星の順番の値までの一時的な塗りを描くだけで、revertToValue() が pointerleave のときに確定済みの値へ戻します。06の requestSwap() はさらにその先を行き、ラベルの切替自体を40msの setTimeout の奥に置いています。列を素早く横切るカーソルにはふきだしが一切出ず、遅延を超えて留まったホバーだけが commit() を得ます。どちらも、クリックか離脱が最終状態を決めます。
なぜ2つは位置の値そのものを評価に変えるのか
02と09はどちらも生の横座標を読みますが、解像度が正反対です。02の valueFromX() は各星の getBoundingClientRect() を順に調べ、ポインターが幅のどちら半分にあるかで整数か半星かに値をスナップし、paint() がその値を星ごとの塗り幅へ変換します。09の setPos() は代わりにトラック上の任意のピクセル位置を受け取り、小数点以下1桁の連続した0〜5の値へ変換して、指が押されている間は pointermove のたびに塗り直し続けます — 指を離すまでスナップはありません。
なぜ5つは1つのイベントで離散的な値を確定するのか
03・04・05・07・08は、どれも1回の操作で状態をちょうど1回変えます。03は矢印キー1回ごとに target の値を1動かし、tick() が表示値をそこへ滑らかに寄せます。04の addReview() は待ち行列の次の評価を counts に1つ加えて加重平均を計算し直します。05はクリックされた行の件数を全体に対する比率へ変えます。07はクリックされた順番の値を mix() に通して色を作ります。08はクリックされた星の順番の値を現在の値と比べ、一致すれば0へ初期化します。1つの入力が入り、1つの塗られた状態が出ます。
パターン別コード
01ホバー先読み塗り — 指している星の順番の値まで塗りの状態が先に見え、外れると元の値に戻る方式
ポインターが星の上に置かれた状態になるとその星までの順番の値だけ塗りの状態が先に現れ、ポインターが外れた状態になると塗りの値は実際に選ばれた値に戻ります。商品のレビューフォームやレシピカードはこの形で、クリックが確定する前にその結果を先に見せます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、01は21フレームで動き、累積面積3.94%・平均強度75.6を記録しました — フレーム覆盖率がほぼ最大なのに面積が最小なのは、自動再生ループが先読みの値を列の端から端まで動かすため、常に何かが動いていながら、5つ並んだ星の行は480×300のステージのごく一部しか占めないためです。
// 計算の層: 塗りの個数 n を星の総数で割った比率(%)をそのまま画面の数値に
// 使う — 塗りはこの計算から出た整数で、参照ではない。
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 ? 'プレビュー ' : '選択 ';
}
02半星精密塗り — 星1つ分の幅の値のどちら半分にポインターの横位置の値があるかで塗りの値が整数か半星の値に分かれる方式
ポインターの横位置の値が星1つ分の幅の値のどちらの半分にあるかによって塗りの値が整数の値か半星の値のどちらかに決まります。3.5が意味を持つホテルの詳細ページや書評ウィジェットが、この精度を使います。

<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 · 半星精密塗り — 星1つ分の幅の値のどちら半分にポインターの横位置の値があるかで塗りの値が整数か半星の値に分かれる方式 — 計測 累積面積 4.027% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、02は全23フレームで動き、累積面積4.03%・平均強度153.2を記録しました — 被覆率が満杯で強度は9つの中で2番目に高いのは、塗りの端が星の幅のどこにでも置ける(境界にしか置けないのではなく)ため、ポインターが動いたぶんだけ毎回切り取り幅が塗り直されるためです。
// 計算の層: 各星の実際の描画幅(getBoundingClientRect)を基準に、ポインターが
// 左半分と右半分のどちらにあるかを分け、値は整数か半星かの単位にだけスナップする。
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);
}
03矢印キー値移動 — 押された方向の値だけ選ばれた星の順番の値が1つ増減する方式
右か左の矢印キーが押されるたびに選ばれた星の順番の値が1つ増減し、その値が範囲の両端を超えた状態になると端の値で止まります。マウスに頼れない精算フローは、この方式だけで完結します。

<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 · 矢印キー値移動 — 押された方向の値だけ選ばれた星の順番の値が1つ増減する方式 — 計測 累積面積 3.694% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、03は16フレームで動き、累積面積3.69%・平均強度127.6を記録しました — キーを押したあとに1回の跳躍ではなく連続した動きフレームが続くのは、tick() が表示値を目標に追いつくまで数フレームにわたって動かし続けるためで、イージングがフレーム数として現れています。
// 計算の層: 毎フレーム、表示値を目標の値へ向かって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);
}
04平均値重ね塗り — 平均値の小数点部分が星1つ分の塗り幅の値として重なって現れる方式
平均値の整数部分の分だけ星が塗られた状態になり、残った小数点部分の値が次の星1つの塗り幅の値として重なって現れます。十分な人数が評価を終えたレビュー概要が到達する、読み取り専用の最終状態です。ストアの一覧やアプリの詳細ページに映るのはまさにこの形です。

<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 · 平均値重ね塗り — 平均値の小数点部分が星1つ分の塗り幅の値として重なって現れる方式 — 計測 累積面積 0.5% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、04は18フレームで動き、累積面積0.50%・平均強度73.1を記録しました — 面積は9つの中で断トツに小さいのは、レビューが1件増えるたびに平均の動きは小さくなり、4.1台に乗った棒は星1つの切り取り幅の薄い一部を塗り直すだけで、ほかはほとんど変わらないためです。
// 計算の層: 点数ごとの件数(counts)から加重平均(合計 / 個数)を求め、整数部分の分だけ
// 星を塗り、残った小数点部分を次の星1つの塗り幅の値として重ねて描く。
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);
}
05件数バー分解 — 星の点数ごとの回答件数の値が横棒の長さの値に分かれて現れる方式
星5から1までの各点数の回答件数の値が全体の回答件数の値に対する比率の値に変わり、その行の棒の長さの値になります。商品ページの評価分布ブロックは、平均値の横にこの行の並びと同じ形で置かれます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、05は15フレームで動き、累積面積22.70%・平均強度34.0を記録しました — 面積は9つの中で抜けて大きいのは、ステージの幅いっぱいに広がった5本の棒がすべて塗り直しのたびに伸びるためです。その代わり強度は最も低く、同じイベントが少数の画素を鋭くではなく、多数の画素を穏やかに変えています。
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));
});
}
06ふきだしラベル切替 — 指している星の順番の値に合わせてラベル文言の値が変わって現れる方式
指している星の順番の値が変わるたびにその値に対応するラベル文言の値が一緒に変わって星の横に現れ、外れた状態になるとラベルは消えます。裸の3や5ではなく「普通」「最高」と言う評価の入力欄は、数値に意味をくっつけたままにします。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、06は9フレームで動き、累積面積6.52%・平均強度143.6を記録しました — 動くフレームは少ないのに1回ごとは鋭いのは、40msのデバウンスがカーソルが横切っただけの星をすべて飛ばし、ふきだしが実際に確定した星だけが塗り直されるためです。ふきだしのポップ自体がコントラストの高い変化です。
// 計算の層: 星の実際の描画位置(getBoundingClientRect の中心 x)をふきだしの translateX の値に
// そのまま移し、インデックスの変更は短い遅延(デバウンス)を通したあとにだけラベル文言を
// 替える — 素早く横切るときに文言が星ごとに点滅しないようにするタイミングの計算である。
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);
}
07値別色変化 — 選ばれた値の大きさに応じて星の色の値が段階的に変わる方式
選ばれた星の順番の値が低い範囲にあると色の値が一方に、高い範囲にあると別の色の値に変わり、その間の値は2色の間を連続してつながります。満足度調査やサービス評価では、個数だけでなく色のほうに温度を持たせられます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、07は全23フレームで動き、累積面積3.59%・平均強度25.7を記録しました — 強度は9つの中で最も低いのは、2つの固定された端点の間の色の変化が1ステップごとに各画素をわずかにしか変えないためです。位置や幅の変化が全コントラストで端を描き直すのとは対照的です。
// 計算の層: 選ばれた値(1〜5)を0〜1の比率 t に変え、2つのトークン色の R・G・B チャンネルを
// それぞれ t だけ線形補間(mix)して中間色を作る — HSL の回転ではなく、チャンネルごとの
// 算術的な混色である。
var LOW = [0xc2, 0x36, 0x1a];
var HIGH = [0x4c, 0xd4, 0xa6];
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;
}
08戻しバネ初期化 — 同じ星がもう一度押された状態になると塗りの値が0に向けて弾むように減る方式
すでに選ばれた値と同じ星がもう一度押された状態になると塗りの値が0に向かって弾む動きの値を経て減り、選択の値も一緒に消えます。別の「クリア」ボタンを置かずに、初期化の操作を星の並びの中に組み込む形です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、08は9フレームで動き、累積面積4.79%・平均強度108.7を記録しました — 動きが連続ではなく突発的なのは、バネのキーフレームが初期化の瞬間にだけ発火し、サイクルの残りは静止しているためです。その代わり、塗られた星の行全体のスケール兼フェードは、乗ったときの変化として強いものです。
// 計算の層: 初期化が起こると塗りの値を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);
}
09タッチドラッグ操作 — 指が動いた横方向の値に沿って塗りの値がリアルタイムで変わる方式
指が星の列の上を押したまま動いている間、その横位置の値が変わるたびに塗りの値が同じ画面上ですぐ追従して変わり、指を離した状態になるとその値が選択の値として固定されます。親指1本で進めるモバイルの評価ステップは、5つの別々のタップ目標よりもこちらを選びます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームの追跡のうち、09は17フレームで動き、累積面積4.96%・平均強度173.2を記録しました — 強度は9つの中で最も高いのは、指を離したあとも慣性が値を流し続け、その1ステップごとに塗りの端とトラックのつまみが全コントラストで一緒に塗り直されるためです。面積ではなく精度で測れば、表示値が入力に最も忠実に追う方式です。
function onUp() {
if (!dragging) return;
dragging = false;
runInertia();
}
// 計算の層: 指を離した瞬間の速度(px/ms)をそのまま引き継ぎ、毎フレーム減衰(摩擦)させながら
// 位置を動かし続ける慣性スクラブ — 離した瞬間に値が止まらず、流れてから止まる。
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;
}
}
値がパターンを変える場所 — 位置・比率・時間の境界
なぜ03番の lerp 係数は値の着地地点ではなく道のりを決めるのか
display += (target - display) * 0.2 の 0.2 は、目標そのものを一切変えません — animateTo() のクランプはどちらの値でも同じ0〜5の範囲のままです。0.2では表示値が数フレームをかけて目標へ滑らか寄せていくため、方向の反転が曲線として見えます。0.4では同じ矢印キー1回で値がほぼ1フレームで届き、即応に感じられる代わりに、動きの慣性は一瞬にかわります。着地点はどちらでも同じです。
なぜ06番の40msのデバウンスはふきだしの見た目ではなく存在そのものを決めるのか
遅延は requestSwap() の中だけにあります — 40ms 未満のホバーは、次の clearTimeout によって commit() が走る前に取り消されるため、そのふきだしは最初から存在しなかったことになります。90msではさらに多くのかすりが消え、その代わり、意図して留まったホバーも1拍長く待つことになります。ふきだしの位置の計算(positionTip())も文言も、この数値には触れられません。
なぜ09番の摩擦0.945は止まる位置ではなく流れる距離を決めるのか
velocity *= 0.945 は、離した瞬間に残っていた速度に毎フレーム掛かる値であって、指が実際に到達した位置を書き換えるものではありません — 決めるのは、その先に値がどれだけ流れるかだけです。0.975では同じ離し方でもはるかに長く滑り、確定する値は指を離した場所よりだいぶ先に着きます。0.945では流れは短く尽き、値は離した位置の近くで収まります。
どこで壊れるか — 連続クリック・狭い画面・タッチとマウスが混ざるとき
バネがまだ動いているうちに連続クリックすると何が起こるか
08の reset() は620ms後に sr__star--bounce を外す setTimeout を予定しますが、新しいクリックが先に星を塗り直しても何もこれを取り消しません — 待機中のタイマーは塗りの途中で発火し、塗られているはずの星からクラスを剥がして、バネを途中で断ち切ります。09は自分のアニメーションの世話をちゃんとしています。onDown() は新しいドラッグが始まる前に待機中の inertiaRaf を取り消すため、連続クリックで慣性ループが同じ値に2重に積まれることはありません。
行が狭い画面の端に来ると何が起こるか
9つとも @media (max-width: 340px) のブロックで星を縮め、02・06・09はイベントの時点で getBoundingClientRect() を測るため、値の計算はキャッシュした幅を信じることなく自動で追従します — 例外は06のふきだしです。その translateX の位置は行の両端に対してクランプされていないため、狭い画面では、ラベルが端の星の上に留まらずに行の外側へはみ出すことがあります。
タッチとマウスの入力が同じ要素に混ざると何が起こるか
01と06はホバーで動く(pointerenter・pointerleave)ため、タッチ画面で星をタップした指は、余分な enter と leave の組を1回発火させ、2回目のタップでしか消えない先読みやふきだしの状態を残すことがあります。02は行の pointermove を setPointerCapture なしで聞いているため、指が行から外れたタッチドラッグは操作の途中で更新を止めます。09はこれを意図的に扱っています。onDown() の track.setPointerCapture(e.pointerId) が、指が離れるまでのすべての移動をトラックへ届け続けます — まさに Pointer Events がそのために存在する統一的な扱いです。
このセットが CSS ハート アニメーション 9選 — 一行でコピペOK・セグメントコントロールUI 9選 — コピペOK・タイムピッカー ui 9選 — 一点から区間まで と違うのは、操作がどの値を書くかです。ハートのセットは「いいね」ボタン自身の状態変化を動かすため、値は入か切の2値だけで、工夫はすべてイージングにあります。セグメントコントロールのセットは固定された選択肢の行を1つの選択が横切る — 形はこのセットに最も近いですが、位置の値は区分の所有物であって、小数の塗りには決してなりません。タイムピッカーのセットは点や入力を区間の値に変えるため、評価が1つで済むところに、値は両端を持っています。この星評価 デザインのセットのソースはすべて、パスワードhaq93vzhで解除すると、デモの自動再生ループを外すだけで実際のレビューフォームにそのまま組み込めます。
3つのバリエーションとアクセシビリティ
9つのうち、出荷するバリエーションとして定数を公開する価値があるのは3つだけです — ほかの6つに調整できる数値がないのではなく、lerp 係数・デバウンスの遅延・摩擦の掛け算の3つは、1つの値を変えるだけで操作の大目さ・慎重さ・なめらかさが最も目に見えて変わるためです。
| バリエーション | 何が変わるか | トレードオフ |
|---|---|---|
| 03の lerp 係数 0.2 → 0.4 | 表示値が矢印キーの目標に追いつく速さ | 0.2では反転が見えるイージングの曲線を描き、0.4では値がほぼ即時に届いて機敏に感じられる代わりに、動きに物理感を与えていた慣性が短くなります |
| 06の切替遅延 40ms → 90ms | どのホバーがふきだしのラベルを替えるほど長いか | 40msでは星を素早くかすってもラベルが出て、90msではかすりは黙ったままですが、意図して留まったホバーも1拍長く待つことになります |
| 09の慣性摩擦 0.945 → 0.975 | 指を離したあと値がどれだけ流れるか | 0.945では離した直後に流れが尽きて値は離した位置の近くで収まり、0.975では同じ離し方がはるかに先まで滑り、なめらかだが指が触れていない値で確定します |
アクセシビリティは、このデモの作りとしては一様ではありません。どこまで整っているかは、ソースが正確に語っています。01が最も完備しています。行に aria-label つきの role="radiogroup"、星ごとに role="radio" と aria-checked を置き、commit() の中で同期を取っています。03がその次です。role="slider" に tabindex="0"・aria-valuemin/aria-valuemax を備え、animateTo() のたびに aria-valuenow を書き込みます。05は role="list" に行ごとの role="listitem" と aria-pressed を持っています。残る6つ — 02・04・06・07・08・09 — は ARIA のロールも状態も一切設定しておらず、視覚的な状態だけを描いています。各項目の @media (prefers-reduced-motion: reduce) は CSS トランジションを切り(08はさらにバウンドアニメーションも0にします)、各スクリプト自身の reduce チェックが止めるのは自動再生の setInterval だけです — 実際の入力はどちらの設定でもまったく同じに動きます。
FAQ
css 星評価のパーセント表示に JavaScript は必須ですか
01のパーセント表示は ratioOf() — Math.round((n / TOTAL) * 100) — から出ていて、塗りと同じ個数の値から作られるため、常に一致します。固定幅を直接書けば css だけのバーも作れますが、入力に応じて値が変わる瞬間から再計算する何かが必要になり、ここではそれが4行のスクリプトです。
半星の方式とドラッグの方式は同じ値の目盛りを共有できますか
すでに共有しています — 02は valueFromX() で0.5刻みにスナップし、09は paintFromPos() で小数点以下1桁を保ち、どちらも同じ Math.max/Math.min の形で0〜5の中に収めています。違うのは範囲ではなく解像度です。09の連続した目盛りを02の行に持ち込むことは、ドラッグ位置を近いほうの半星に丸めることと同じことです。
この9つは評価をどこかに送信しますか
いいえ。評価も件数も平均もスクリプトの中で宣言された数値で、04の「レビュー追加」ボタンはローカルの queue 配列の次の値を counts に加えるだけで、9つのどれにも fetch・XHR・フォーム送信は存在しません。javascript 星評価 表示としてこの形を使う場合は、貼り付けたあとに実際の送信先を自分で組み込む前提です。
Sources: MDN — Element.getBoundingClientRect() · MDN — Pointer events · MDN — requestAnimationFrame() · MDN — prefers-reduced-motion · MDN — ARIA slider role









