05Half-star gradient display
This one isn't an input — it's an average-rating badge. A single --rating custom property drives a width clipped by overflow: hidden to fill five stars down to the decimal, and the 0-to-target reveal repeats every 2 seconds.
Best forAverage ratings in product listings, review-summary badges
--rating 커스텀 속성overflow 폭 클리핑루프 리빌

<main class="stage">
<div class="avg" style="--rating:3.5" role="img" aria-label="3.5 out of 5 stars">
<span class="avg__stars" aria-hidden="true">
<span class="avg__base">★★★★★</span>
<span class="avg__fill"><span class="avg__fill-text">★★★★★</span></span>
<span class="avg__fx" aria-hidden="true"><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span></span>
</span>
<span class="avg__num">3.5</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 차오르는 리빌 시간
$easing: $ease-out; // 채워질 때는 감속
$color: $subject-cream; // 채워진 별 색 (어두운 무대라 크림)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
// 입력이 아니라 읽기전용 평균 평점 배지. --rating(0~5, 소수 가능) 이 채움 폭을 정한다.
// 별 두 벌을 겹친다: 흐린 바탕 별 다섯 개 + 그 위에 overflow:hidden 로 폭만큼만 보이는 채움 별 다섯 개.
// custom property(--*) 자체를 애니메이션하면 보간이 안 돼 한 프레임 만에 뚝 끊긴다(실측) → width 는 표준 속성이라 부드럽게 보간된다.
.avg {
--pct: calc(var(--rating) / 5 * 100%);
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 12px);
font-family: inherit;
&__stars {
position: relative;
display: inline-block;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1;
letter-spacing: .06em;
white-space: nowrap;
}
&__base { display: block; color: rgba(255, 247, 230, .72); } // AA: 바탕 별도 큰 글자 3:1 — 잉크 위 9.21:1
&__fill {
position: absolute; inset: 0;
display: block;
width: 0%;
overflow: hidden;
color: $color;
white-space: nowrap;
}
&__fill-text { display: block; }
&__num { font-weight: 700; font-size: clamp(16px, 4.6vw, 20px); line-height: 1; font-family: inherit; color: $subject-cream; }
// 한 겹: 물리 스파크 파티클(미니 별) — 채움 앞가장자리(--pct)에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: var(--pct); pointer-events: none; }
&__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;
}
}
// 처음 보일 때 0 → 목표치까지 한 번 차오르고, 그 뒤로는 숨쉬듯 살짝 반복한다 (실제 사용자 클릭이 없는 표시용이라 항상 데모 상태)
@keyframes reveal-fill {
0% { width: 0%; }
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.avg__fill { animation: none !important; width: var(--pct); }
.avg__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.avg {
--pct: calc(var(--rating) / 5 * 100%);
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 12px);
font-family: inherit;
}
.avg__stars {
position: relative;
display: inline-block;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1;
letter-spacing: 0.06em;
white-space: nowrap;
}
.avg__base {
display: block;
color: rgba(255, 247, 230, 0.72);
}
.avg__fill {
position: absolute;
inset: 0;
display: block;
width: 0%;
overflow: hidden;
color: #fff7e6;
white-space: nowrap;
}
.avg__fill-text {
display: block;
}
.avg__num {
font-weight: 700;
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.avg__fx {
position: absolute;
top: 50%;
left: var(--pct);
pointer-events: none;
}
.avg__spark {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #fff7e6;
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;
}
@keyframes reveal-fill {
0% {
width: 0%;
}
55%, 80% {
width: var(--pct);
}
100% {
width: var(--pct);
}
}
.avg__fill {
animation: reveal-fill 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.avg__fill {
animation: none !important;
width: var(--pct);
}
.avg__spark {
display: none;
}
}// 한 겹: 채움 앞가장자리(--pct)에서 질량-감쇠 물리 스파크 버스트 — 미니 별이 차오르는 지점에서 흩어진다.
// 각 파티클은 초기 속도(넓은 위쪽 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var stars = document.querySelector(".avg__stars");
if (!stars) return;
var sparks = [].slice.call(stars.querySelectorAll(".avg__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .7 + Math.random() * .8; // 개별 질량 0.7~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.4; // 넓은 위쪽 부채꼴 ±97°
var v0 = (130 + Math.random() * 80) * (1.15 - m * .2); // 무거울수록 느게 출발
sparks[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.4, g: 280, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 920,
s: .4 + m * .3, born: now + i * 26, life: 700 + Math.random() * 400 };
}
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; // 전부 정지하면 루프 종료
}
// 데모 루프: reveal-fill 이 2s 루프의 55%(=1100ms)에 --pct 에 도달 — 채움 앞가장자리가 다 차는 순간 발사
function cycle() { setTimeout(fire, 1100); }
setInterval(cycle, 2000);
cycle(); // 첫 사이클(t=0)부터 녹화 창 안에 잡힌다
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HalfStarGradient.module.scss";
export type HalfStarGradientProps = {
/** 5점 만점 평균 평점, 소수 가능 */
rating: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pct"?: 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;
};
// 읽는 순서가 언어마다 달라 라벨 전체를 한 문구로 둔다 (en 은 "4.3 out of 5")
const RATING_LABEL = "5점 만점에 {r}점";
const SPARKS = [0, 1, 2, 3, 4, 5];
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.7 + Math.random() * 0.8; // 개별 질량 0.7~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.4; // 넓은 위쪽 부채꼴 ±97°
const v0 = (130 + 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.4, g: 280, s: 0.4 + m * 0.3,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 920,
born: now + i * 26, life: 700 + Math.random() * 400, // 순차 분사 + 수명 랜덤
};
}
// 입력이 아니라 읽기전용 평균 평점 배지. 클릭으로 값을 바꾸는 별점은 04 StarFill 을 쓴다.
export default function HalfStarGradient({ rating, duration = 600, easing = "ease-out", color = "#fff7e6" }: HalfStarGradientProps) {
const pct = `${Math.max(0, Math.min(5, rating)) / 5 * 100}%`;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--pct": pct };
const [burstKey, setBurstKey] = useState(0);
const starsRef = useRef<HTMLSpanElement>(null);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const stars = starsRef.current;
if (!stars) return;
const els = Array.from(stars.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]);
// 값이 바뀌면(및 최초 마운트) 채움이 다 차는 duration 뒤에 앞가장자리에서 발사
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = setTimeout(() => setBurstKey((k) => k + 1), duration);
return () => clearTimeout(timer);
}, [rating, duration]);
return (
<div className={styles.avg} style={vars} role="img" aria-label={RATING_LABEL.replace("{r}", String(rating))}>
<span className={styles.stars} aria-hidden="true" ref={starsRef}>
<span className={styles.base}>★★★★★</span>
<span className={styles.fill}><span className={styles.fillText}>★★★★★</span></span>
<span className={styles.fx}>
{SPARKS.map((i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} />
))}
</span>
</span>
<span className={styles.num}>{rating}</span>
</div>
);
}05 Half-star gradient display — Measured area 1.199% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Heart Animations — One Line, Copy-Paste
The post this demo lives in
9 CSS Heart Animations — One Line, Copy-PasteThese nine css heart animations turn a checkbox or a radio input into a like button, a star rating, or a reaction picker See all demos in this post


