04星の塗りつぶし
逆順に並べたラジオ5個と兄弟セレクタで選んだ星まで前の星も塗りつぶされ、選んだ星だけさらにscaleで跳ねて選択を知らせる。
使いどころレビューの星評価入力、商品満足度評価
~ 형제 선택자fieldset/legendscale 바운스

<main class="stage">
<fieldset class="rating is-demo">
<legend class="rating__legend">評価</legend>
<div class="rating__stars">
<input type="radio" name="rating" id="r5" class="rating__input" style="--i:4"><label for="r5" class="rating__star" style="--i:4" aria-label="5点">★<span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span></label>
<input type="radio" name="rating" id="r4" class="rating__input" style="--i:3"><label for="r4" class="rating__star" style="--i:3" aria-label="4点">★<span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span></label>
<input type="radio" name="rating" id="r3" class="rating__input" style="--i:2"><label for="r3" class="rating__star" style="--i:2" aria-label="3点">★<span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span></label>
<input type="radio" name="rating" id="r2" class="rating__input" style="--i:1"><label for="r2" class="rating__star" style="--i:1" aria-label="2点">★<span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span></label>
<input type="radio" name="rating" id="r1" class="rating__input" style="--i:0"><label for="r1" class="rating__star" style="--i:0" aria-label="1点">★<span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span><span class="rating__spark" aria-hidden="true"></span></label>
</div>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 선택된 별이 튀는 시간
$easing: $ease-pop; // 튕기는 이징
$color: $subject-blue; // 채워진 별 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.rating {
border: 0; padding: 0; margin: 0;
&__legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } // 화면엔 안 보여도 스크린리더는 읽는다
&__stars { display: flex; flex-direction: row-reverse; gap: clamp(4px, 1.6vw, 8px); }
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__star {
position: relative; // 스파크 파티클의 좌표 기준 (레이아웃 불변)
font-size: clamp(28px, 8vw, 36px);
line-height: 1;
color: rgba(23, 20, 26, .72); // AA: 빈 별도 큰 글자 3:1 — 페이퍼 위 7.27:1
cursor: pointer;
transition: color $duration $easing;
}
// 한 겹: 물리 스파크 파티클(미니 별) — 별 글자 중심에서 발사. 좌표·페이드는 JS 가 계산한다.
&__spark {
position: absolute; top: 50%; left: 50%;
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
// :checked 뒤(형제)의 모든 별이 함께 채워진다 (DOM 순서가 5·4·3·2·1 이라 뒤 = 낮은 점수까지)
&__input:checked ~ &__star { color: $color; }
&__input:checked + &__star { animation: star-pick $duration $easing; }
&.is-demo &__star { animation: star-wave $dur-loop $ease-pop infinite; animation-delay: calc(var(--i) * .12s); }
}
@keyframes star-pick {
0% { transform: scale(1); }
45% { transform: scale(1.35); }
100% { transform: scale(1); }
}
@keyframes star-wave {
0%, 100% { color: rgba(23, 20, 26, .72); transform: scale(1); } // AA: 빈 별 키프레임도 7.27:1
40% { color: $color; transform: scale(1.2); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.rating__star { transition: color 1ms; }
.rating__input:checked + .rating__star { animation: none !important; }
.rating.is-demo .rating__star { animation: none !important; }
.rating__spark { display: 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);
}
.rating {
border: 0;
padding: 0;
margin: 0;
}
.rating__legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.rating__stars {
display: flex;
flex-direction: row-reverse;
gap: clamp(4px, 1.6vw, 8px);
}
.rating__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.rating__star {
position: relative;
font-size: clamp(28px, 8vw, 36px);
line-height: 1;
color: rgba(23, 20, 26, 0.72);
cursor: pointer;
transition: color 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rating__spark {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #2f6df6;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.rating__input:checked ~ .rating__star {
color: #2f6df6;
}
.rating__input:checked + .rating__star {
animation: star-pick 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rating.is-demo .rating__star {
animation: star-wave 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 0.12s);
}
@keyframes star-pick {
0% {
transform: scale(1);
}
45% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
@keyframes star-wave {
0%, 100% {
color: rgba(23, 20, 26, 0.72);
transform: scale(1);
}
40% {
color: #2f6df6;
transform: scale(1.2);
}
}
@media (prefers-reduced-motion: reduce) {
.rating__star {
transition: color 1ms;
}
.rating__input:checked + .rating__star {
animation: none !important;
}
.rating.is-demo .rating__star {
animation: none !important;
}
.rating__spark {
display: none;
}
}// 한 겹: 고른 별에서 질량-감쇠 물리 스파크 버스트 — 미니 별이 별 글자 위에서 흩어진다.
// 각 파티클은 초기 속도(위쪽 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var root = document.querySelector(".rating");
if (!root) return;
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var groups = [].slice.call(root.querySelectorAll(".rating__star"));
var sparks = [].slice.call(root.querySelectorAll(".rating__spark")); // 5그룹×4개 평면 목록 — 매 프레임 DOM 질의 없음
var alive = false, raf = 0, last = 0;
function fireGroup(label) {
if (reduce) return;
var own = [].slice.call(label.querySelectorAll(".rating__spark"));
if (!own.length) return;
var now = performance.now();
for (var i = 0; i < own.length; i++) {
var m = .6 + Math.random() * .8; // 개별 질량 0.6~1.4
var ang = -Math.PI / 2 + (Math.random() - .5) * 3; // 위쪽 부채꼴 ±86°
var v0 = (120 + Math.random() * 80) * (1.15 - m * .2); // 무거울수록 느게 출발
own[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.6, g: 300, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 840,
s: .4 + m * .3, born: now + i * 24, life: 650 + Math.random() * 300 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < sparks.length; i++) {
var el = sparks[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 체크된 라디오 옆 라벨의 그룹만 발사
root.addEventListener("change", function (e) {
root.classList.remove("is-demo");
var inp = e.target;
if (inp && inp.type === "radio" && inp.checked) {
var label = root.querySelector('label[for="' + inp.id + '"]');
if (label) fireGroup(label);
}
});
// 기존: 데모 해제 포인터다운 보존
root.querySelectorAll(".rating__input").forEach(function (i) {
i.addEventListener("pointerdown", function () { root.classList.remove("is-demo"); });
});
// 트리거 2: 데모 루프 — star-wave(2s) 40% 정점 + --i*0.12s 지연에 맞춰 별마다 순서대로 발사.
// groups 는 DOM 순(r5→r1, --i:4→0)이라 뒤에서부터 — 파도가 --i:0 부터 차오르는 방향.
function wave() {
if (!root.classList.contains("is-demo")) return;
for (var i = 0; i < groups.length; i++) {
(function (idx) { setTimeout(function () { fireGroup(groups[idx]); }, 800 + i * 120); })(groups.length - 1 - i);
}
}
setInterval(wave, 2000);
wave(); // 첫 사이클(t=0)부터 녹화 창 안에 잡힌다
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./StarFill.module.scss";
export type StarFillProps = {
duration?: number;
easing?: string;
color?: string;
defaultValue?: number;
onChange?: (value: number) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number; s: number;
rot: number; spin: number;
born: number; life: number;
};
const STARS = [5, 4, 3, 2, 1]; // 역순 DOM — 형제 선택자로 앞쪽 별까지 채우는 CSS 데모와 순서를 맞춘다
const SPARKS = [0, 1, 2, 3];
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.8; // 개별 질량 0.6~1.4
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3; // 위쪽 부채꼴 ±86°
const v0 = (120 + Math.random() * 80) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.6, g: 300, s: 0.4 + m * 0.3,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 840,
born: now + i * 24, life: 650 + Math.random() * 300, // 순차 분사 + 수명 랜덤
};
}
export default function StarFill({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6", defaultValue = 0, onChange }: StarFillProps) {
const [value, setValue] = useState(defaultValue);
const [burstKey, setBurstKey] = useState(0);
const starRef = useRef<HTMLLabelElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const star = starRef.current;
if (!star) return;
const els = Array.from(star.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform = `translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
return (
<fieldset className={styles.rating} style={vars}>
<legend className={styles.legend}>평점</legend>
<div className={styles.stars}>
{STARS.map((n) => (
<label key={n} className={styles.star} data-picked={n === value || undefined} ref={n === value ? starRef : undefined}>
<input
type="radio"
name="rating"
className={styles.input}
checked={value === n}
onChange={(e: ChangeEvent<HTMLInputElement>) => { setValue(n); if (e.target.checked) fire(); onChange?.(n); }}
aria-label={`${n}점`}
/>
<span className={n <= value ? styles.filled : ""}>★</span>
{n === value && SPARKS.map((i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} aria-hidden="true" />
))}
</label>
))}
</div>
</fieldset>
);
}04 星の塗りつぶし — 計測 累積面積 2.926% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS ハート アニメーション 9選 — 一行でコピペOK
このデモが属する記事
CSS ハート アニメーション 9選 — 一行でコピペOKcss ハート アニメーションを9種類まとめました。いいねボタンのハートから星評価、拍手カウントや絵文字リアクションまで、押した瞬間に画面がはっきり反応するものだけを選び、ほとんどはtransformとopacityだけで動き、05だけwi この記事のデモをすべて見る


