01호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식
포인터가 별 위에 놓인 상태가 되면 그 별까지의 순서 값만큼 채움 상태가 미리 나타나고, 포인터가 벗어난 상태가 되면 채움 값이 실제 선택된 값으로 되돌아갑니다.
쓰임상품 리뷰 작성 폼의 별점 입력, 만족도 설문의 점수 선택
ratioOf(n)previewTo(i) / revertToValue()value = 3

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow" role="radiogroup" aria-label="별점">
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="1">
<svg viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="2">
<svg viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="3">
<svg viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="4">
<svg viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg>
</button>
<button class="sr__star" type="button" role="radio" aria-checked="false" data-i="5">
<svg viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg>
</button>
</div>
<div class="sr__readout" id="srReadout">선택 <b id="srNum">3</b>/5 · <span id="srPct">60</span>%</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 채움 개수를 전체 별 개수로 나눈 비율(ratioOf())을 정수로 그대로 화면에 보여준다 —
// 채움 클래스와 별개로 숫자 자체가 계산 결과다.
@include stage($stage-ink);
.sr {
display: grid;
gap: $sp-5;
place-items: center;
}
.sr__row {
display: flex;
gap: $sp-2;
}
.sr__star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: $subject-blue-light;
transition: transform $dur-quick $ease-out, color $dur-quick $ease-out;
svg { display: block; width: 100%; height: 100%; fill: currentColor; }
&--filled {
color: $subject-pink;
transform: scale(1.08);
}
}
.sr__readout {
font: 600 15px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
letter-spacing: .01em;
b { color: $subject-pink; }
&--preview { color: $subject-blue-light; b { color: $subject-blue-light; } }
}
@media (max-width: 340px) {
.sr__star { width: 34px; height: 34px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__star { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr {
display: grid;
gap: 20px;
place-items: center;
}
.sr__row {
display: flex;
gap: 8px;
}
.sr__star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: #5b8cf8;
transition: transform 220ms ease-out, color 220ms ease-out;
}
.sr__star svg {
display: block;
width: 100%;
height: 100%;
fill: currentColor;
}
.sr__star--filled {
color: #ff9ebb;
transform: scale(1.08);
}
.sr__readout {
font: 600 15px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
letter-spacing: 0.01em;
}
.sr__readout b {
color: #ff9ebb;
}
.sr__readout--preview {
color: #5b8cf8;
}
.sr__readout--preview b {
color: #5b8cf8;
}
@media (max-width: 340px) {
.sr__star {
width: 34px;
height: 34px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__star {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var row = document.getElementById('srRow');
var stars = Array.prototype.slice.call(row.querySelectorAll('.sr__star'));
var numEl = document.getElementById('srNum');
var pctEl = document.getElementById('srPct');
var readout = document.getElementById('srReadout');
var TOTAL = stars.length;
var value = 3;
// 계산 겹: 채움 개수 n 을 전체 별 개수로 나눈 비율(산술)을 그대로 화면 숫자로 보여준다 —
// 채움은 CSS 클래스가 아니라 이 비율 계산에서 나온 정수를 그대로 쓴다.
function ratioOf(n) { return Math.round((n / TOTAL) * 100); }
function paint(n, isPreview) {
stars.forEach(function (s, idx) {
s.classList.toggle('sr__star--filled', idx < n);
});
numEl.textContent = String(n);
pctEl.textContent = String(ratioOf(n));
readout.classList.toggle('sr__readout--preview', !!isPreview);
readout.firstChild.textContent = isPreview ? '미리보기 ' : '선택 ';
}
function previewTo(i) { paint(i, true); }
function revertToValue() { paint(value, false); }
function commit(i) {
value = i;
stars.forEach(function (s, idx) { s.setAttribute('aria-checked', String(idx < value)); });
paint(value, false);
}
stars.forEach(function (s) {
var i = Number(s.getAttribute('data-i'));
s.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); stopAutoplay(); previewTo(i); });
s.addEventListener('click', function () { commit(i); });
});
row.addEventListener('pointerleave', function () { revertToValue(); });
paint(value, false);
// 자동 루프: 별 순서대로 미리보기 값을 올렸다 원래 값으로 되돌리는 것을 반복해 보여준다.
var step = 0;
var autoTimer = null;
function autoTick() {
if (step < TOTAL) { previewTo(step + 1); }
else { revertToValue(); }
step = (step + 1) % (TOTAL + 2);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 280); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./HoverPreviewFill.module.scss";
const STAR_PATH =
"M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z";
export default function HoverPreviewFill() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const row = root.querySelector<HTMLDivElement>(`.${styles.row}`);
const readout = root.querySelector<HTMLDivElement>(`.${styles.readout}`);
const numEl = root.querySelector<HTMLElement>(`.${styles.num}`);
const pctEl = root.querySelector<HTMLElement>(`.${styles.pct}`);
if (!row || !readout || !numEl || !pctEl) return;
const stars = Array.prototype.slice.call(
row.querySelectorAll(`.${styles.star}`)
) as HTMLElement[];
const TOTAL = stars.length;
let value = 3;
// 계산 겹: 채움 개수 n 을 전체 별 개수로 나눈 비율(산술)을 그대로 화면 숫자로 보여준다 —
// 채움은 CSS 클래스가 아니라 이 비율 계산에서 나온 정수를 그대로 쓴다.
function ratioOf(n: number) {
return Math.round((n / TOTAL) * 100);
}
function paint(n: number, isPreview?: boolean) {
stars.forEach((s, idx) => {
s.classList.toggle(styles.filled, idx < n);
});
numEl!.textContent = String(n);
pctEl!.textContent = String(ratioOf(n));
readout!.classList.toggle(styles.preview, !!isPreview);
readout!.firstChild!.textContent = isPreview ? "미리보기 " : "선택 ";
}
function previewTo(i: number) { paint(i, true); }
function revertToValue() { paint(value, false); }
function commit(i: number) {
value = i;
stars.forEach((s, idx) => s.setAttribute("aria-checked", String(idx < value)));
paint(value, false);
}
function onEnter(i: number) {
return () => { stopAutoplay(); previewTo(i); };
}
function onClick(i: number) {
return () => commit(i);
}
const enterHandlers: Array<() => void> = [];
const clickHandlers: Array<() => void> = [];
stars.forEach((s, idx) => {
const i = idx + 1;
const eh = onEnter(i);
const ch = onClick(i);
enterHandlers.push(eh);
clickHandlers.push(ch);
s.addEventListener("pointerenter", eh);
s.addEventListener("click", ch);
});
function onLeave() { revertToValue(); }
row.addEventListener("pointerleave", onLeave);
paint(value, false);
// 자동 루프: 별 순서대로 미리보기 값을 올렸다 원래 값으로 되돌리는 것을 반복해 보여준다.
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step < TOTAL) { previewTo(step + 1); }
else { revertToValue(); }
step = (step + 1) % (TOTAL + 2);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 280); autoTick(); }
return () => {
stopAutoplay();
stars.forEach((s, idx) => {
s.removeEventListener("pointerenter", enterHandlers[idx]);
s.removeEventListener("click", clickHandlers[idx]);
});
row.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row} role="radiogroup" aria-label="별점">
{[1, 2, 3, 4, 5].map((i) => (
<button key={i} type="button" className={styles.star} role="radio" aria-checked="false">
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</button>
))}
</div>
<div className={styles.readout}>
선택 <b className={styles.num}>3</b>/5 · <span className={styles.pct}>60</span>%
</div>
</div>
</div>
);
}01 호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식 — 측정 누적면적 3.944% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 별점 UI 9종 — 채움 값이 상태에 따라 바뀌는 방식
이 데모가 속한 글
별점 UI 9종 — 채움 값이 상태에 따라 바뀌는 방식별점 UI는 별 아이콘으로 점수를 매기거나 매겨진 평균 점수를 보여주는 입력 장치인데, 아홉 개 중 하나는 손가락이 움직인 가로 값을 따라 채움 값이 바로 바뀌고, 하나는 평균 값의 소수점 부분이 별 하나의 채움 너 이 글의 데모 전부 보기


