CSS 하트 애니메이션 좋아요·별점 9종 — 한 줄 복붙으로
CSS 하트 애니메이션 아홉 개를 한 번에 모았습니다. 좋아요 버튼의 하트부터 별점 입력, 박수·이모지 리액션까지 누르고 고르는 순간 화면이 확실히 반응하는 장면만 골랐고, 전부 transform과 opacity만 움직입니다.
01 heart-like-rating-heart-pop
02 heart-like-rating-particle-burst
03 heart-like-rating-like-count-sync
04 heart-like-rating-star-fill
05 heart-like-rating-star-half
06 heart-like-rating-clap-count
07 heart-like-rating-bookmark-fold
09 heart-like-rating-emoji-react
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기순이 아니라 누른 뒤 화면이 반응하는 순서로 놓았습니다. 먼저 하트가 튀어 오르고(01~02) 옆 숫자가 따라가고(03), 그다음은 값을 매기는 별점(04~05), 반복해서 누르는 박수(06), 나중에 다시 찾을 저장(07), 확인 피드백(08), 마지막은 여러 개 중 하나를 고르는 리액션(09)입니다. 아래 코드 조각은 css 버튼 클릭 애니메이션을 만드는 핵심 줄만 항목마다 뽑았고, 완결본은 zip 안 파일을 열어야 나옵니다.
01하트 팝 등장
무엇이 튀어 오르나
좋아요를 누르면 인라인 하트의 채움 레이어가 가운데를 기준으로 0에서 1.15배까지 튀어 올랐다가 1배로 가라앉습니다.

<main class="stage">
<label class="heart is-demo">
<input type="checkbox" class="heart__input">
<span class="heart__icon" aria-hidden="true">
<span class="heart__shape heart__shape--outline"></span>
<span class="heart__shape heart__shape--fill"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--star"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--star"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--star"></span>
</span>
<span class="heart__label">좋아요</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-quick; // 하트가 튀어 오르는 시간
$easing: $ease-pop; // 튕기는 이징
$color: $subject-cream; // 채워질 때 하트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
@mixin heart-shape {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
}
.heart {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon { position: relative; width: 34px; height: 30px; flex: none; }
&__shape {
position: absolute; inset: 0;
&::before { @include heart-shape; left: 50%; transform: rotate(-45deg); transform-origin: 0 100%; } // 규칙8: 하트 로브 회전 피벗
&::after { @include heart-shape; left: 0; transform: rotate(45deg); transform-origin: 100% 100%; } // 규칙8: 하트 로브 회전 피벗
}
&__shape--outline { color: rgba(255, 247, 230, .3); }
&__shape--fill {
color: $color;
opacity: 0;
transform: scale(.4) rotate(-8deg);
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 버스트 파티클(미니 하트·별). 좌표·페이드는 JS 가 계산해 여기 transform 을 덮어쓴다.
&__particle {
position: absolute; top: 50%; left: 50%;
width: 10px; height: 10px; margin: -5px 0 0 -5px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__particle--heart {
width: 12px; height: 12px; margin: -6px 0 0 -6px;
background: $color;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
&__particle--star {
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%);
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-cream; }
// 실제 클릭: 체크되면 채움 하트가 튀며 나타난다
&__input:checked ~ &__icon &__shape--fill { opacity: 1; transform: scale(1) rotate(0deg); }
// 데모 · 문서용: 2초마다 자동 반복
&.is-demo &__icon &__shape--fill { animation: heart-pop-loop $dur-loop $easing infinite; }
}
@keyframes heart-pop-loop {
0%, 15% { opacity: 0; transform: scale(.4) rotate(-8deg); }
35% { opacity: 1; transform: scale(1.15) rotate(6deg); }
55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(.4) rotate(-8deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 순간 전환만 ────────────
@media (prefers-reduced-motion: reduce) {
.heart__shape--fill { transition: opacity 1ms; }
.heart__particle { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.heart.is-demo .heart__icon .heart__shape--fill { animation: none !important; opacity: 1; }
}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);
}
.heart {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.heart__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.heart__icon {
position: relative;
width: 34px;
height: 30px;
flex: none;
}
.heart__shape {
position: absolute;
inset: 0;
}
.heart__shape::before {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
left: 50%;
transform: rotate(-45deg);
transform-origin: 0 100%;
}
.heart__shape::after {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
left: 0;
transform: rotate(45deg);
transform-origin: 100% 100%;
}
.heart__shape--outline {
color: rgba(255, 247, 230, 0.3);
}
.heart__shape--fill {
color: #fff7e6;
opacity: 0;
transform: scale(0.4) rotate(-8deg);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.heart__particle {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.heart__particle--heart {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
background: #fff7e6;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
.heart__particle--star {
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%);
}
.heart__label {
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.heart.is-demo .heart__icon .heart__shape--fill {
animation: heart-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes heart-pop-loop {
0%, 15% {
opacity: 0;
transform: scale(0.4) rotate(-8deg);
}
35% {
opacity: 1;
transform: scale(1.15) rotate(6deg);
}
55%, 80% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0.4) rotate(-8deg);
}
}
@media (prefers-reduced-motion: reduce) {
.heart__shape--fill {
transition: opacity 1ms;
}
.heart__particle {
display: none;
}
.heart.is-demo .heart__icon .heart__shape--fill {
animation: none !important;
opacity: 1;
}
}// 한 겹: 질량-감쇠 물리 파티클 버스트 — 하트가 채워질 때 미니 하트·별이 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속해 더 멀리 간다)와 중력(질량 무관)을 받아 흩어졌다 멈춘다.
// CSS keyframes 하드코딩이 아니라 requestAnimationFrame 안에서 좌표를 계산해 transform 에 쓴다.
(function () {
var icon = document.querySelector('.heart__icon'); if (!icon) return;
var ps = [].slice.call(icon.querySelectorAll('.heart__particle'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .6 + Math.random() * .9; // 개별 질량 0.6~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.5; // 위쪽 부채꼴 ±72°
var v0 = (110 + Math.random() * 100) * (1.15 - m * .2); // 무거울수록 살짝 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.4, g: 300, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 540,
s: .4 + m * .3, born: now + i * 22, life: 800 + Math.random() * 450 };
}
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 < ps.length; i++) {
var el = ps[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: 실제 클릭 — 체크되는 순간 발사
var w = document.querySelector('.heart'), inp = w && w.querySelector('.heart__input');
if (inp) {
inp.addEventListener('change', function () { w.classList.remove('is-demo'); if (inp.checked) fire(); });
inp.addEventListener('pointerdown', function () { w.classList.remove('is-demo'); });
}
// 트리거 2: 데모 루프 — CSS 하트 팝(2s 루프의 35% ≈ 660ms)에 맞춰 발사
function demoShot() { if (w && w.classList.contains('is-demo')) setTimeout(fire, 660); }
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./HeartPop.module.scss";
export type HeartPopProps = {
/** 하트가 튀어 오르는 시간 (기본 220ms) */
duration?: number;
/** CSS easing (기본 cubic-bezier(.2,1.4,.4,1)) */
easing?: string;
/** 채워질 때 하트 색 */
color?: string;
defaultLiked?: boolean;
onChange?: (liked: boolean) => 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;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 하트 / 별 모양 교차 배치 (레이아웃·색은 기존 그대로)
const KINDS = ["heart", "star", "heart", "star", "heart", "star", "heart"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (질량이 클수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.9; // 개별 질량 0.6~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.5; // 위쪽 부채꼴 ±72°
const v0 = (110 + Math.random() * 100) * (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: 300,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 540,
s: 0.4 + m * 0.3,
born: now + i * 22, life: 800 + Math.random() * 450, // 순차 분사 + 수명 랜덤
};
}
export default function HeartPop({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultLiked = false,
onChange,
}: HeartPopProps) {
const [liked, setLiked] = useState(defaultLiked);
const [burstKey, setBurstKey] = useState(0);
const iconRef = useRef<HTMLSpanElement>(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 icon = iconRef.current;
if (!icon) return;
const els = Array.from(icon.querySelectorAll<HTMLElement>(`.${styles.particle}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now, alive = true;
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); else alive = false; // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => { alive = false; cancelAnimationFrame(raf); };
}, [burstKey]);
return (
<label className={styles.heart} style={vars}>
<input
type="checkbox"
className={styles.input}
checked={liked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setLiked(e.target.checked);
if (e.target.checked) fire();
onChange?.(e.target.checked);
}}
/>
<span className={styles.icon} aria-hidden="true" ref={iconRef}>
<span className={`${styles.shape} ${styles.outline}`} />
<span className={`${styles.shape} ${styles.fill} ${liked ? styles.isLiked : ""}`} />
{KINDS.map((kind, i) => (
<span
key={i}
className={`${styles.particle} ${kind === "heart" ? styles.pHeart : styles.pStar}`}
/>
))}
</span>
<span className={styles.label}>좋아요</span>
</label>
);
}01 · 하트 팝 등장 — 측정 누적면적 1.06% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
체크 상태가 남는 이유
실제 상태를 갖는 체크박스라서 새로고침 없이도 눌렀는지 확인할 수 있습니다.
.heart__shape--fill {
color: $color;
opacity: 0;
transform: scale(.4) rotate(-8deg);
transition: opacity $duration $easing, transform $duration $easing;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
opacity: 1;
transform: scale(1) rotate(0deg);
}
@keyframes heart-pop-loop {
35% { opacity: 1; transform: scale(1.15) rotate(6deg); }
55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
}
02파티클 터짐
점 여덟 개가 흩어지는 각도
체크되는 순간 하트 둘레로 작은 점 여덟 개가 CSS 커스텀 속성 --i로 계산한 각도만큼 흩어지며 사라집니다.

<main class="stage">
<label class="burst is-demo">
<input type="checkbox" class="burst__input">
<span class="burst__icon" aria-hidden="true">
<span class="burst__heart"></span>
<span class="burst__dot" style="--i:0"></span>
<span class="burst__dot" style="--i:1"></span>
<span class="burst__dot" style="--i:2"></span>
<span class="burst__dot" style="--i:3"></span>
<span class="burst__dot" style="--i:4"></span>
<span class="burst__dot" style="--i:5"></span>
<span class="burst__dot" style="--i:6"></span>
<span class="burst__dot" style="--i:7"></span>
</span>
<span class="burst__label">좋아요</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 파티클이 퍼지는 시간
$easing: $ease-out; // 퍼질 때는 빠르게 시작해 느려진다
$color: $subject-ink; // 파티클·하트 색 (노랑 무대라 잉크색)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.burst {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon { position: relative; width: 40px; height: 36px; flex: none; }
&__heart {
position: absolute; inset: 8px;
background: $color;
clip-path: path("M12 22 2 13C-1 10 0 5 4 4 7 3 10 5 12 8 14 5 17 3 20 4 24 5 25 10 22 13Z");
transform: scale(.5);
opacity: .5;
transition: transform $duration $easing, opacity $duration $easing;
}
// 물리 파티클 — 좌표·스케일·페이드는 JS 적분(rAF)이 매 프레임 transform 에 쓴다 (keyframes 아님)
&__dot {
position: absolute; top: 50%; left: 50%; width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
border-radius: 50%;
background: $color;
opacity: 0;
will-change: transform, opacity;
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-ink; }
&__input:checked ~ &__icon &__heart { transform: scale(1); opacity: 1; }
// 파티클 발사는 JS 물리가 담당 — 체크 시 keyframes 는 더 이상 쓰지 않는다
&.is-demo &__icon &__heart { animation: burst-heart-loop $dur-loop $easing infinite; }
}
@keyframes burst-heart-loop {
0%, 20% { transform: scale(.5); opacity: .5; }
35%, 80% { transform: scale(1); opacity: 1; }
100% { transform: scale(.5); opacity: .5; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.burst__heart { transition: opacity 1ms; }
.burst__dot { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.burst.is-demo .burst__icon .burst__heart { animation: none !important; }
}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);
}
.burst {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.burst__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.burst__icon {
position: relative;
width: 40px;
height: 36px;
flex: none;
}
.burst__heart {
position: absolute;
inset: 8px;
background: #17141a;
clip-path: path("M12 22 2 13C-1 10 0 5 4 4 7 3 10 5 12 8 14 5 17 3 20 4 24 5 25 10 22 13Z");
transform: scale(0.5);
opacity: 0.5;
transition: transform 600ms ease-out, opacity 600ms ease-out;
}
.burst__dot {
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 50%;
background: #17141a;
opacity: 0;
will-change: transform, opacity;
}
.burst__label {
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.burst__input:checked ~ .burst__icon .burst__heart {
transform: scale(1);
opacity: 1;
}
.burst.is-demo .burst__icon .burst__heart {
animation: burst-heart-loop 2s ease-out infinite;
}
@keyframes burst-heart-loop {
0%, 20% {
transform: scale(0.5);
opacity: 0.5;
}
35%, 80% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.burst__heart {
transition: opacity 1ms;
}
.burst__dot {
display: none;
}
.burst.is-demo .burst__icon .burst__heart {
animation: none !important;
}
}// 질량-감쇠 물리 버스트 — 기존 45° 고정 링 keyframes 를 JS 적분으로 바꿔치기했다 (레이아웃·색 불변).
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량 m(0.7~1.6)을 갖고 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 무거울수록 덜 감속해 관성으로 더 멀리 간다)와 중력 g(질량 무관)을 받아 흩어졌다 멈춘다.
// 좌표는 requestAnimationFrame 안에서 계산해 transform 에 쓴다 — 타이밍·거리를 keyframes 에 박지 않는다.
(function () {
var icon = document.querySelector('.burst__icon'); if (!icon) return;
var dots = [].slice.call(icon.querySelectorAll('.burst__dot'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !dots.length) return;
var now = performance.now();
for (var i = 0; i < dots.length; i++) {
var m = .7 + Math.random() * .9; // 개별 질량 0.7~1.6
var ang = (i / dots.length) * Math.PI * 2 + (Math.random() - .5) * .55; // 45° 뼈대 + 지터
var v0 = (150 + Math.random() * 130) * m; // 무거울수록 세게 — 질량이 관성
dots[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 4.2, g: 260, s: .35 + m * .45,
born: now + i * 20, life: 620 + Math.random() * 380 }; // 순차 분사(20ms) + 수명 랜덤
}
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 < dots.length; i++) {
var el = dots[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;
var o = age / p.life; // 수명 후반 40%에서 페이드아웃
el.style.opacity = o < .6 ? '1' : (1 - (o - .6) / .4).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 체크되는 순간 발사
var w = document.querySelector('.burst'), inp = w && w.querySelector('.burst__input');
if (inp) {
inp.addEventListener('change', function () { w.classList.remove('is-demo'); if (inp.checked) fire(); });
inp.addEventListener('pointerdown', function () { w.classList.remove('is-demo'); });
}
// 트리거 2: 데모 루프 — CSS 하트 루프(2s 의 35% ≈ 660ms)에 맞춰 발사
function demoShot() { if (w && w.classList.contains('is-demo')) setTimeout(fire, 660); }
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./ParticleBurst.module.scss";
export type ParticleBurstProps = {
/** 파티클이 퍼지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
onChange?: (liked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--i"?: number };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number; s: number;
born: number; life: number;
};
const DOTS = Array.from({ length: 8 }, (_, i) => i);
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속해 관성으로 더 멀리), 중력 = g (질량 무관). */
function spawn(now: number, i: number, n: number): Particle {
const m = 0.7 + Math.random() * 0.9; // 개별 질량 0.7~1.6
const ang = (i / n) * Math.PI * 2 + (Math.random() - 0.5) * 0.55; // 45° 뼈대 + 지터
const v0 = (150 + Math.random() * 130) * m; // 무거울수록 세게 — 질량이 관성
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 4.2, g: 260, s: 0.35 + m * 0.45,
born: now + i * 20, life: 620 + Math.random() * 380, // 순차 분사 + 수명 랜덤
};
}
export default function ParticleBurst({ duration = 600, easing = "ease-out", color = "#17141a", onChange }: ParticleBurstProps) {
const [liked, setLiked] = useState(false);
const [burstKey, setBurstKey] = useState(0);
const iconRef = useRef<HTMLSpanElement>(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 icon = iconRef.current;
if (!icon) return;
const els = Array.from(icon.querySelectorAll<HTMLElement>(`.${styles.dot}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i, els.length));
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;
const o = age / p.life;
el.style.opacity = o < 0.6 ? "1" : (1 - (o - 0.6) / 0.4).toFixed(3);
el.style.transform = `translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
const handle = (checked: boolean) => {
setLiked(checked);
if (checked) fire(); // 다시 좋아요를 눌러도 파티클이 재생되게
onChange?.(checked);
};
return (
<label className={styles.burst} style={vars}>
<input type="checkbox" className={styles.input} checked={liked} onChange={(e: ChangeEvent<HTMLInputElement>) => handle(e.target.checked)} />
<span className={styles.icon} aria-hidden="true" ref={iconRef}>
<span className={`${styles.heart} ${liked ? styles.isLiked : ""}`} />
{DOTS.map((i) => (
<span key={`${burstKey}-${i}`} className={styles.dot} style={{ "--i": i } as DotVars} />
))}
</span>
<span className={styles.label}>좋아요</span>
</label>
);
}02 · 파티클 터짐 — 측정 누적면적 1.614% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이미지 없이 만드는 법
이미지 하나 없이 span 여덟 개와 원 하나로 만든 파티클입니다.
.burst__dot {
position: absolute; top: 50%; left: 50%;
width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
border-radius: 50%; background: $color;
transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1);
}
.burst__input:checked ~ .burst__icon .burst__dot {
animation: particle-fly $duration $easing forwards;
animation-delay: calc(var(--i) * 20ms);
}
@keyframes particle-fly {
100% { opacity: 0; transform: rotate(calc(var(--i) * 45deg)) translateY(-34px) scale(.2); }
}
03좋아요 카운트 동기
숫자가 따라 오르는 순간
체크 상태가 바뀔 때마다 옆 숫자가 1씩 오르내리고, 바뀐 숫자는 scale로 짧게 튑니다.

<main class="stage">
<label class="like is-demo">
<input type="checkbox" class="like__input">
<span class="like__heart" aria-hidden="true"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__spark like__spark--star"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__spark like__spark--star"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__count"><span class="like__num" aria-live="polite">12</span></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 숫자가 튀는 시간
$easing: $ease-pop; // 팝 이징
$color: $subject-cream; // 하트·숫자 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.like {
position: relative; // 스파크 파티클의 좌표 기준 (레이아웃 불변)
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 10px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__heart {
display: inline-block;
width: 18px; height: 18px;
align-self: center;
background: rgba(255, 247, 230, .4);
clip-path: path("M9 17 1.5 10C-1 7.5 0 3.5 3 3 5.5 2.5 7.5 4 9 6 10.5 4 12.5 2.5 15 3 18 3.5 19 7.5 16.5 10Z");
transition: background $duration $easing, transform $duration $easing;
}
&__input:checked ~ &__heart { background: $color; transform: scale(1.15); }
// 한 겹: 물리 스파크 파티클(미니 하트·별) — 하트 위에서 발사. 좌표·페이드는 JS 가 계산한다.
&__spark {
position: absolute; top: 50%; left: 9px; // 하트(첫 아이템, 수직 중앙) 중심 — 50% 로 모든 글자 크기에서 추적
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__spark--heart {
width: 12px; height: 12px; margin: -6px 0 0 -6px;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
&__spark--star {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
&__count { font-weight: 700; font-size: clamp(18px, 5vw, 22px); line-height: 1; font-family: inherit; color: $subject-cream; }
&__num.is-pop { animation: count-pop $duration $easing; }
&.is-demo &__heart { animation: heart-fill-loop $dur-loop $easing infinite; }
}
@keyframes count-pop {
0% { display: inline-block; transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
@keyframes heart-fill-loop {
0%, 20% { background: rgba(255, 247, 230, .4); transform: scale(1); }
35%, 60% { background: $color; transform: scale(1.15); }
80%, 100% { background: rgba(255, 247, 230, .4); transform: scale(1); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.like__heart { transition: background 1ms; }
.like__num.is-pop { animation: none !important; }
.like__spark { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.like.is-demo .like__heart { animation: none !important; }
}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);
}
.like {
position: relative;
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 10px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.like__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.like__heart {
display: inline-block;
width: 18px;
height: 18px;
align-self: center;
background: rgba(255, 247, 230, 0.4);
clip-path: path("M9 17 1.5 10C-1 7.5 0 3.5 3 3 5.5 2.5 7.5 4 9 6 10.5 4 12.5 2.5 15 3 18 3.5 19 7.5 16.5 10Z");
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like__input:checked ~ .like__heart {
background: #fff7e6;
transform: scale(1.15);
}
.like__spark {
position: absolute;
top: 50%;
left: 9px;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #fff7e6;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.like__spark--heart {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
.like__spark--star {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.like__count {
font-weight: 700;
font-size: clamp(18px, 5vw, 22px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.like__num.is-pop {
animation: count-pop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like.is-demo .like__heart {
animation: heart-fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes count-pop {
0% {
display: inline-block;
transform: scale(1);
}
50% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
@keyframes heart-fill-loop {
0%, 20% {
background: rgba(255, 247, 230, 0.4);
transform: scale(1);
}
35%, 60% {
background: #fff7e6;
transform: scale(1.15);
}
80%, 100% {
background: rgba(255, 247, 230, 0.4);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.like__heart {
transition: background 1ms;
}
.like__num.is-pop {
animation: none !important;
}
.like__spark {
display: none;
}
.like.is-demo .like__heart {
animation: none !important;
}
}// 체크 상태가 바뀔 때마다 숫자를 1 더하거나 빼고 scale 팝을 다시 튄다.
// 한 겹: 숫자가 오를 때 질량-감쇠 물리 스파크 버스트 — 미니 하트·별이 하트 위에서 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var w = document.querySelector(".like");
if (!w) return;
var inp = w.querySelector(".like__input"), num = w.querySelector(".like__num");
var sparks = [].slice.call(w.querySelectorAll(".like__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 = .6 + Math.random() * .9; // 개별 질량 0.6~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.4; // 위쪽 부채꼴 ±69°
var v0 = (100 + Math.random() * 90) * (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.6, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 480,
s: .4 + m * .3, born: now + i * 26, life: 780 + Math.random() * 420 };
}
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; // 전부 정지하면 루프 종료
}
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop"); void num.offsetWidth; num.classList.add("is-pop");
if (inp.checked) fire(); // 숫자가 오를 때만 발사
}
inp.addEventListener("change", function () { w.classList.remove("is-demo"); sync(); });
setInterval(function () { if (!w.classList.contains("is-demo")) return; inp.checked = !inp.checked; sync(); }, 1400);
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./LikeCountSync.module.scss";
export type LikeCountSyncProps = {
duration?: number;
easing?: string;
color?: string;
baseCount?: number;
};
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;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 하트 / 별 모양 교차 배치
const KINDS = ["heart", "star", "heart", "star", "heart"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (질량이 클수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.9; // 개별 질량 0.6~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.4; // 위쪽 부채꼴 ±69°
const v0 = (100 + Math.random() * 90) * (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: 320,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 480,
s: 0.4 + m * 0.3,
born: now + i * 26, life: 800 + Math.random() * 420, // 순차 분사 + 수명 랜덤
};
}
export default function LikeCountSync({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6", baseCount = 12 }: LikeCountSyncProps) {
const [liked, setLiked] = useState(false);
const [pop, setPop] = useState(0);
const wrapRef = useRef<HTMLLabelElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fire = useCallback(() => setPop((p) => p + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!pop) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const els = Array.from(wrap.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);
}, [pop]);
const toggle = (checked: boolean) => { setLiked(checked); if (checked) fire(); };
return (
<label className={styles.like} style={vars} ref={wrapRef}>
<input type="checkbox" className={styles.input} checked={liked} onChange={(e: ChangeEvent<HTMLInputElement>) => toggle(e.target.checked)} />
<span className={`${styles.heart} ${liked ? styles.isLiked : ""}`} aria-hidden="true" />
{KINDS.map((kind, i) => (
<span key={i} className={`${styles.spark} ${kind === "heart" ? styles.sHeart : styles.sStar}`} aria-hidden="true" />
))}
<span className={styles.count}>
<span key={pop} className={styles.num}>{liked ? baseCount + 1 : baseCount}</span>
</span>
</label>
);
}03 · 좋아요 카운트 동기 — 측정 누적면적 0.179% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 JS 가 필요한가
CSS만으로는 텍스트를 바꿀 수 없어서 JS 몇 줄이 change 이벤트에서 숫자만 갈아 끼웁니다.
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop");
void num.offsetWidth;
num.classList.add("is-pop");
}
inp.addEventListener("change", sync);
04별점 채움
라디오를 역순으로 두는 까닭
라디오 다섯 개를 5·4·3·2·1 역순으로 두고, 체크된 라디오 뒤 형제 선택자로 앞쪽 별까지 함께 채웁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
고른 별만 튀는 규칙
고른 별 하나만 scale로 튀어 선택을 확인해 줍니다. 그와 동시에 고른 별 글자 위에서 미니 별 네 개가 흩어지는데, 각 파티클은 저마다 질량(0.6~1.4)을 가져 requestAnimationFrame 안에서 준암시 오일러 적분으로 감쇠(-k/m·v — 무거울수록 덜 감속)와 중력을 받으며 날아가다 멈춥니다.
.rating__stars { display: flex; flex-direction: row-reverse; }
.rating__input:checked ~ .rating__star { color: $color; }
.rating__input:checked + .rating__star { animation: star-pick $duration $easing; }
@keyframes star-pick {
45% { transform: scale(1.35); }
100% { transform: scale(1); }
}
05반쪽 별 그라데이션 표시
입력이 아닌 읽기 전용 배지
이건 입력이 아니라 읽기 전용 평균 평점 배지입니다.

<main class="stage">
<div class="avg" style="--rating:3.5" role="img" aria-label="5점 만점에 3.5점">
<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 · 반쪽 별 그라데이션 표시 — 측정 누적면적 1.199% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
소수점 평점을 그리는 법
별 두 벌을 겹쳐 위쪽 벌을 overflow: hidden으로 폭만큼만 보이게 해서 소수점 평점을 표현하고, 0에서 목표치까지 차오르는 동작이 2초 간격으로 되풀이됩니다. 채움이 앞가장자리(--pct 지점)에 다 차는 순간 그 지점에서 미니 별 여섯 개가 터지는데, 이 파티클도 질량별 감쇠(-k/m·v)와 중력(질량 무관)을 requestAnimationFrame 적분으로 받아 흩어집니다.
.avg__fill {
position: absolute; inset: 0;
width: 0%; overflow: hidden; color: $color;
}
@keyframes reveal-fill {
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
06박수 카운트
좋아요 버튼 애니메이션은 몇 번까지 반복되나
누를 때마다 숫자가 오르는 박수 버튼입니다.

<main class="stage">
<button class="clap is-demo" type="button" aria-label="박수">
<span class="clap__icon" aria-hidden="true">👏</span>
<span class="clap__plus" aria-hidden="true">+1</span>
<span class="clap__count"><span aria-live="polite">128</span></span>
<span class="clap__fx" aria-hidden="true"><span class="clap__spark"></span><span class="clap__spark"></span><span class="clap__spark"></span><span class="clap__spark"></span><span class="clap__spark"></span></span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 아이콘이 튀는 시간
$easing: $ease-pop; // 튕기는 이징
$color: $subject-ink; // 숫자 강조색 (노랑 무대)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.clap {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 10px);
border: 0;
border-radius: $r-pill;
padding: clamp(10px, 3.6vw, 14px) clamp(18px, 5.4vw, 22px);
font-weight: 700; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit;
color: $color;
background: rgba(23, 20, 26, .08);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__icon { font-size: clamp(20px, 5.6vw, 24px); display: inline-block; transform: scale(1) rotate(0deg); }
&__plus {
position: absolute; top: -2px; left: 34px;
font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; color: $color;
opacity: 0; transform: translateY(0);
pointer-events: none;
}
&__count { font-variant-numeric: tabular-nums; }
// 한 겹: 물리 스파크 파티클(미니 별) — fire 직전 JS 가 아이콘 중심으로 원점을 옮긴다. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 0; 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;
}
&.is-clap &__icon { animation: clap-bounce $duration $easing; }
&.is-clap &__plus { animation: clap-plus $duration $easing; }
&.is-demo &__icon { animation: clap-bounce-loop $dur-loop $easing infinite; }
&.is-demo &__plus { animation: clap-plus-loop $dur-loop $easing infinite; }
}
@keyframes clap-bounce {
0% { transform: scale(1) rotate(0deg); }
40% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
@keyframes clap-bounce-loop {
0%, 60% { transform: scale(1) rotate(0deg); }
75% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus-loop {
0%, 60% { opacity: 0; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.clap.is-clap .clap__icon,
.clap.is-clap .clap__plus,
.clap.is-demo .clap__icon,
.clap.is-demo .clap__plus { animation: none !important; opacity: 0; transform: none; }
.clap__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.clap {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 10px);
border: 0;
border-radius: 999px;
padding: clamp(10px, 3.6vw, 14px) clamp(18px, 5.4vw, 22px);
font-weight: 700;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: rgba(23, 20, 26, 0.08);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.clap__icon {
font-size: clamp(20px, 5.6vw, 24px);
display: inline-block;
transform: scale(1) rotate(0deg);
}
.clap__plus {
position: absolute;
top: -2px;
left: 34px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0;
transform: translateY(0);
pointer-events: none;
}
.clap__count {
font-variant-numeric: tabular-nums;
}
.clap__fx {
position: absolute;
top: 50%;
left: 0;
pointer-events: none;
}
.clap__spark {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #17141a;
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;
}
.clap.is-clap .clap__icon {
animation: clap-bounce 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.clap.is-clap .clap__plus {
animation: clap-plus 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.clap.is-demo .clap__icon {
animation: clap-bounce-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.clap.is-demo .clap__plus {
animation: clap-plus-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes clap-bounce {
0% {
transform: scale(1) rotate(0deg);
}
40% {
transform: scale(1.3) rotate(-12deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes clap-plus {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-16px);
}
}
@keyframes clap-bounce-loop {
0%, 60% {
transform: scale(1) rotate(0deg);
}
75% {
transform: scale(1.3) rotate(-12deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes clap-plus-loop {
0%, 60% {
opacity: 0;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-16px);
}
}
@media (prefers-reduced-motion: reduce) {
.clap.is-clap .clap__icon,
.clap.is-clap .clap__plus,
.clap.is-demo .clap__icon,
.clap.is-demo .clap__plus {
animation: none !important;
opacity: 0;
transform: none;
}
.clap__spark {
display: none;
}
}// 누를 때마다 카운트가 1 오르고 아이콘이 튄다. .is-demo 인 동안은 타이머가 대신 누른다.
// 한 겹: 박수마다 질량-감쇠 물리 스파크 버스트 — 미니 별이 아이콘 중심에서 흩어진다 (박수 리듬에 맞춰 빠르고 짧게).
// 각 파티클은 초기 속도(위쪽 좁은 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
var b = document.querySelector(".clap"), n = b.querySelector(".clap__count span");
var fx = b.querySelector(".clap__fx"), icon = b.querySelector(".clap__icon");
var sparks = [].slice.call(fx.querySelectorAll(".clap__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
fx.style.left = (icon.offsetLeft + icon.offsetWidth / 2) + "px"; // 아이콘 중심으로 발사 원점 이동
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .6 + Math.random() * .6; // 개별 질량 0.6~1.2
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.4; // 위쪽 좁은 부채꼴 ±69°
var v0 = (100 + Math.random() * 70) * (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.8, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 840,
s: .4 + m * .3, born: now + i * 18, life: 550 + 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; // 전부 정지하면 루프 종료
}
function clap() {
n.textContent = String(Number(n.textContent) + 1);
b.classList.remove("is-clap"); void b.offsetWidth; b.classList.add("is-clap");
fire(); // 실제 클릭·데모 루프 둘 다 박수마다 발사
}
b.addEventListener("pointerdown", function () { b.classList.remove("is-demo"); clap(); });
var demoClap = function () { if (!b.classList.contains("is-demo")) return; clap(); };
setInterval(demoClap, 900);
setTimeout(demoClap, 100); // 첫 사이클부터 녹화 창 안에 잡힌다import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ClapCount.module.scss";
export type ClapCountProps = {
duration?: number;
easing?: string;
color?: string;
baseCount?: number;
};
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 SPARKS = [0, 1, 2, 3, 4];
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속), 중력 = g (질량 무관). 박수 리듬에 맞춰 빠르고 짧게. */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.6; // 개별 질량 0.6~1.2
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.4; // 위쪽 좁은 부채꼴 ±69°
const v0 = (100 + Math.random() * 70) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.8, g: 320, s: 0.4 + m * 0.3,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 840,
born: now + i * 18, life: 550 + Math.random() * 300, // 순차 분사 + 수명 랜덤
};
}
export default function ClapCount({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#17141a", baseCount = 128 }: ClapCountProps) {
const [count, setCount] = useState(baseCount);
const [claps, setClaps] = useState(0); // 카운트 증가 펄스이자 파티클 burstKey 를 겸한다
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const clap = () => { setCount((c) => c + 1); setClaps((k) => k + 1); };
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!claps) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const btn = btnRef.current;
if (!btn) return;
const icon = btn.querySelector<HTMLElement>(`.${styles.icon}`);
const fx = btn.querySelector<HTMLElement>(`.${styles.fx}`);
if (!icon || !fx) return;
fx.style.left = `${icon.offsetLeft + icon.offsetWidth / 2}px`; // 아이콘 중심으로 발사 원점 이동
const els = Array.from(fx.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);
}, [claps]);
return (
<button type="button" className={styles.clap} style={vars} onClick={clap} aria-label="박수" ref={btnRef}>
<span key={claps} className={styles.icon} aria-hidden="true">👏</span>
{claps > 0 && <span key={`plus-${claps}`} className={styles.plus} aria-hidden="true">+1</span>}
<span className={styles.count}>{count}</span>
{claps > 0 && (
<span key={`fx-${claps}`} className={styles.fx} aria-hidden="true">
{SPARKS.map((i) => (
<span key={`${claps}-${i}`} className={styles.spark} />
))}
</span>
)}
</button>
);
}06 · 박수 카운트 — 측정 누적면적 0.886% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
아이콘과 +1 이 함께 도는 법
클릭마다 아이콘이 scale과 rotate로 짧게 튀고, "+1" 글자가 위로 올라가며 opacity로 사라집니다. 박수를 칠 때마다 아이콘 중심에서 미니 별 다섯 개가 짧게 터지는 것도 같이 붙어 있는데, 파티클은 가벼운 질량(0.6~1.2)과 빠른 감쇠로 박수 리듬에 맞게 0.6초 안에 떨어져 사라집니다.
@keyframes clap-bounce {
40% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
07북마크 접힘
모서리가 접히는 기준점
체크하면 리본 모양 북마크가 위에서부터 채워지고, 오른쪽 위 모서리의 작은 삼각형이 그 모서리를 기준으로 돌며 접힌 종이처럼 펼쳐집니다. 접힘이 완성되는 순간 그 귀 꼭짓점에서 다이아몬드 종잇조각 여섯 개가 흩어지는데, 조각마다 질량(0.7~1.6)을 줘 무거울수록 관성으로 더 멀리 날아가다 감쇠(-k/m·v)와 중력에 눌려 멈춥니다.

<main class="stage">
<label class="bookmark is-demo">
<input type="checkbox" class="bookmark__input">
<span class="bookmark__icon" aria-hidden="true">
<span class="bookmark__ribbon"></span>
<span class="bookmark__fold"></span>
<span class="bookmark__fx" aria-hidden="true">
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
</span>
</span>
<span class="bookmark__label">저장</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 리본이 채워지는 시간
$easing: $ease-spring; // 스프링 이징
$color: $subject-cream; // 채워진 리본 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
$shape: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
.bookmark {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon { position: relative; width: 24px; height: 30px; flex: none; }
&__ribbon {
position: absolute; inset: 0;
clip-path: $shape;
background: rgba(255, 247, 230, .3);
&::after {
content: "";
position: absolute; inset: 0;
clip-path: $shape;
background: $color;
opacity: 0;
transform: scaleY(.35);
transform-origin: top; // 규칙8: 리본 세로 채움 위에서
transition: opacity $duration $easing, transform $duration $easing;
}
}
&__fold {
position: absolute; top: 0; right: 0; width: 8px; height: 8px;
clip-path: polygon(100% 0, 0 0, 100% 100%);
background: rgba(23, 20, 26, .35);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0; // 규칙8: 접힌 귀 꼭짓점 경첩
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 종잇조각(confetti) 파티클 — 접힌 귀(오른쪽 위 모서리)에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 0; left: 100%; pointer-events: none; } // 귀 꼭짓점이 원점
&__chip {
position: absolute; top: 0; left: 0;
width: 8px; height: 8px; margin: -4px 0 0 -4px;
background: $color;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-ink; } // AA: 주황 무대 위 글자 5.50:1
&__input:checked ~ &__icon &__ribbon::after { opacity: 1; transform: scaleY(1); }
&__input:checked ~ &__icon &__fold { opacity: 1; transform: scale(1) rotate(0deg); }
&.is-demo &__icon &__ribbon::after { animation: ribbon-fill-loop $dur-loop $easing infinite; }
&.is-demo &__icon &__fold { animation: fold-in-loop $dur-loop $easing infinite; }
}
@keyframes ribbon-fill-loop {
0%, 20% { opacity: 0; transform: scaleY(.35); }
40%, 80% { opacity: 1; transform: scaleY(1); }
100% { opacity: 0; transform: scaleY(.35); }
}
@keyframes fold-in-loop {
0%, 20% { opacity: 0; transform: scale(0) rotate(-90deg); }
40%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0) rotate(-90deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.bookmark__ribbon::after, .bookmark__fold { transition: opacity 1ms; }
.bookmark.is-demo .bookmark__icon .bookmark__ribbon::after,
.bookmark.is-demo .bookmark__icon .bookmark__fold { animation: none !important; opacity: 1; transform: none; }
.bookmark__chip { display: 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);
}
.bookmark {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bookmark__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.bookmark__icon {
position: relative;
width: 24px;
height: 30px;
flex: none;
}
.bookmark__ribbon {
position: absolute;
inset: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
background: rgba(255, 247, 230, 0.3);
}
.bookmark__ribbon::after {
content: "";
position: absolute;
inset: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
background: #fff7e6;
opacity: 0;
transform: scaleY(0.35);
transform-origin: top;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bookmark__fold {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
clip-path: polygon(100% 0, 0 0, 100% 100%);
background: rgba(23, 20, 26, 0.35);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0;
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bookmark__fx {
position: absolute;
top: 0;
left: 100%;
pointer-events: none;
}
.bookmark__chip {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
background: #fff7e6;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.bookmark__label {
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__ribbon::after {
opacity: 1;
transform: scaleY(1);
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.bookmark.is-demo .bookmark__icon .bookmark__ribbon::after {
animation: ribbon-fill-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.bookmark.is-demo .bookmark__icon .bookmark__fold {
animation: fold-in-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ribbon-fill-loop {
0%, 20% {
opacity: 0;
transform: scaleY(0.35);
}
40%, 80% {
opacity: 1;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(0.35);
}
}
@keyframes fold-in-loop {
0%, 20% {
opacity: 0;
transform: scale(0) rotate(-90deg);
}
40%, 80% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0) rotate(-90deg);
}
}
@media (prefers-reduced-motion: reduce) {
.bookmark__ribbon::after, .bookmark__fold {
transition: opacity 1ms;
}
.bookmark.is-demo .bookmark__icon .bookmark__ribbon::after,
.bookmark.is-demo .bookmark__icon .bookmark__fold {
animation: none !important;
opacity: 1;
transform: none;
}
.bookmark__chip {
display: none;
}
}// 한 겹: 종이 조각(confetti) 물리 파티클 — 저장 체크 순간 접힌 귀(오른쪽 위 모서리)에서
// 다이아몬드 종잇조각이 흩어진다. 각 조각은 질량·감쇠(-k/m·v)·중력을 매 프레임 준암시 오일러
// 적분으로 받아 날아갔다 멈춘다. transform 은 JS 가 계산해 기입한다(키이프레임 아님).
(function () {
var ps = [].slice.call(document.querySelectorAll('.bookmark__chip'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .7 + Math.random() * .9; // 개별 질량 0.7~1.6
var ang = -Math.PI / 4 + (Math.random() - .5) * 2.8; // 바깥쪽(위·오른쪽) 부채꼴 ±80°
var v0 = (110 + Math.random() * 80) * (1.15 - m * .2); // 무거울수록 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.2, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 960,
s: .4 + m * .3, born: now + i * 20, life: 700 + 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 < ps.length; i++) {
var el = ps[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: 실제 클릭 — 체크되는 순간(저장 순간) 발사
document.querySelectorAll('.bookmark').forEach(function (b) {
var inp = b.querySelector('.bookmark__input');
if (inp) inp.addEventListener('change', function () { b.classList.remove('is-demo'); if (inp.checked) fire(); });
if (inp) inp.addEventListener('pointerdown', function () { b.classList.remove('is-demo'); });
});
// 트리거 2: 데모 루프 — 귀 접힘(2s 루프의 40% ≈ 800ms)에 맞춰 발사
function demoShot() {
document.querySelectorAll('.bookmark').forEach(function (b) {
if (b.classList.contains('is-demo')) setTimeout(fire, 800);
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./BookmarkFold.module.scss";
export type BookmarkFoldProps = {
duration?: number;
easing?: string;
color?: string;
defaultSaved?: boolean;
onChange?: (saved: boolean) => 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;
rot: number; spin: number; s: number;
born: number; life: number;
};
const CHIP_COUNT = 6;
/** 질량-감쇠 물리 초기 상태. 감쇠 = -k/m·v, 중력 = g. 접힌 귀에서 위·오른쪽 부채꼴로 흩어진다. */
function spawn(now: number, i: number): Particle {
const m = 0.7 + Math.random() * 0.9; // 개별 질량 0.7~1.6
const ang = -Math.PI / 4 + (Math.random() - 0.5) * 2.8; // 바깥쪽(위·오른쪽) 부채꼴 ±80°
const v0 = (110 + 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.2, g: 320,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 960,
s: 0.4 + m * 0.3,
born: now + i * 20, life: 700 + Math.random() * 300, // 순차 분사 + 수명 랜덤
};
}
export default function BookmarkFold({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#fff7e6", defaultSaved = false, onChange }: BookmarkFoldProps) {
const [saved, setSaved] = useState(defaultSaved);
const [burstKey, setBurstKey] = useState(0);
const iconRef = useRef<HTMLSpanElement>(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 icon = iconRef.current;
if (!icon) return;
const els = Array.from(icon.querySelectorAll<HTMLElement>(`.${styles.chip}`));
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 (
<label className={styles.bookmark} style={vars}>
<input
type="checkbox"
className={styles.input}
checked={saved}
onChange={(e: ChangeEvent<HTMLInputElement>) => { setSaved(e.target.checked); if (e.target.checked) fire(); onChange?.(e.target.checked); }}
/>
<span className={styles.icon} aria-hidden="true" ref={iconRef}>
<span className={styles.ribbon}>
<span className={`${styles.ribbonFill} ${saved ? styles.isSaved : ""}`} />
</span>
<span className={`${styles.fold} ${saved ? styles.isSaved : ""}`} />
<span className={styles.fx} aria-hidden="true">
{Array.from({ length: CHIP_COUNT }, (_, i) => (
<span key={`${burstKey}-${i}`} className={styles.chip} aria-hidden="true" />
))}
</span>
</span>
<span className={styles.label}>저장</span>
</label>
);
}07 · 북마크 접힘 — 측정 누적면적 0.758% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
핵심 코드
.bookmark__fold {
clip-path: polygon(100% 0, 0 0, 100% 100%);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0;
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
opacity: 1; transform: scale(1) rotate(0deg);
}
08엄지 바운스
회색에서 색이 살아나는 순간
체크되기 전엔 filter: grayscale로 가라앉아 있다가, 체크되면 색이 살아나며 엄지가 -20도에서 5도를 지나 0도로 돌아오는 동안 scale도 함께 튑니다. 엄지가 착지하는 오버슈트 순간 아이콘에서 임팩트 스파크(미니 별 넷과 점 둘)가 튀는데, 가벼운 질량과 빠른 감쇠(k=4.0)로 짧게 튀었다 사라져 힘이 실린 느낌을 줍니다.
<main class="stage">
<label class="thumb is-demo">
<input type="checkbox" class="thumb__input">
<span class="thumb__icon" aria-hidden="true">👍</span>
<span class="thumb__label">도움됐어요</span>
<span class="thumb__fx" aria-hidden="true"><span class="thumb__spark thumb__spark--star"></span><span class="thumb__spark thumb__spark--dot"></span><span class="thumb__spark thumb__spark--star"></span><span class="thumb__spark thumb__spark--dot"></span><span class="thumb__spark thumb__spark--star"></span><span class="thumb__spark thumb__spark--star"></span></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 손짓이 도는 시간
$easing: $ease-spring; // 스프링 이징
$color: $subject-blue; // 켜졌을 때 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.thumb {
position: relative; // 한 겹: 임팩트 스파크의 좌표 기준 (레이아웃 불변)
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon {
font-size: clamp(26px, 7.2vw, 32px);
display: inline-block;
transform: scale(1) rotate(0deg);
filter: grayscale(1) opacity(.55);
transition: filter $duration $easing;
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-ink; }
// 한 겹: 물리 임팩트 스파크(미니 별+점) — 엄지 착지 순간 아이콘 중심에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 0; pointer-events: none; } // left 는 fire 직전 JS 가 아이콘 중심으로 이동
&__spark {
position: absolute; top: 0; left: 0;
width: 8px; height: 8px; margin: -4px 0 0 -4px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__spark--star {
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%);
}
&__spark--dot { background: $color; border-radius: 50%; }
&__input:checked ~ &__icon { filter: grayscale(0) opacity(1); animation: thumb-bounce $duration $easing; }
&.is-demo &__icon { animation: thumb-bounce-loop $dur-loop $easing infinite; }
}
@keyframes thumb-bounce {
0% { transform: scale(1) rotate(0deg); }
30% { transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes thumb-bounce-loop {
0%, 15% { filter: grayscale(1) opacity(.55); transform: scale(1) rotate(0deg); }
40% { filter: grayscale(0) opacity(1); transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
80%, 100% { filter: grayscale(1) opacity(.55); transform: scale(1) rotate(0deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.thumb__icon { transition: filter 1ms; }
.thumb__input:checked ~ .thumb__icon { animation: none !important; }
.thumb.is-demo .thumb__icon { animation: none !important; }
.thumb__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);
}
.thumb {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.thumb__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.thumb__icon {
font-size: clamp(26px, 7.2vw, 32px);
display: inline-block;
transform: scale(1) rotate(0deg);
filter: grayscale(1) opacity(0.55);
transition: filter 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.thumb__label {
position: relative;
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.thumb__fx {
position: absolute;
top: 50%;
left: 0;
pointer-events: none;
}
.thumb__spark {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.thumb__spark--star {
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%);
}
.thumb__spark--dot {
background: #2f6df6;
border-radius: 50%;
}
.thumb__input:checked ~ .thumb__icon {
filter: grayscale(0) opacity(1);
animation: thumb-bounce 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.thumb.is-demo .thumb__icon {
animation: thumb-bounce-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes thumb-bounce {
0% {
transform: scale(1) rotate(0deg);
}
30% {
transform: scale(1.25) rotate(-20deg);
}
60% {
transform: scale(1.1) rotate(5deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes thumb-bounce-loop {
0%, 15% {
filter: grayscale(1) opacity(0.55);
transform: scale(1) rotate(0deg);
}
40% {
filter: grayscale(0) opacity(1);
transform: scale(1.25) rotate(-20deg);
}
60% {
transform: scale(1.1) rotate(5deg);
}
80%, 100% {
filter: grayscale(1) opacity(0.55);
transform: scale(1) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.thumb__icon {
transition: filter 1ms;
}
.thumb__input:checked ~ .thumb__icon {
animation: none !important;
}
.thumb.is-demo .thumb__icon {
animation: none !important;
}
.thumb__spark {
display: none;
}
}// 한 겹: 임팩트 스파크 물리 파티클 — 엄지가 착지하는 순간(체크 오버슈트) 아이콘에서
// 미니 별+점이 튄다. 각 파티클은 질량·감쇠(-k/m·v — 임팩트라 빠른 감쇠)·중력을 매 프레임
// 준암시 오일러 적분으로 받아 퍼졌다 멈춘다. transform 은 JS 가 계산해 기입한다.
(function () {
var ps = [].slice.call(document.querySelectorAll('.thumb__spark'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !ps.length) return;
var w = document.querySelector('.thumb'), icon = w && w.querySelector('.thumb__icon'), fx = w && w.querySelector('.thumb__fx');
if (!fx || !icon) return;
fx.style.left = (icon.offsetLeft + icon.offsetWidth / 2) + 'px'; // 아이콘 중심으로 발사점 이동(라벨 .thumb 기준)
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .5 + Math.random() * .6; // 가벼운 질량 0.5~1.1 (임팩트)
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.2; // 위쪽 부채꼴 ±92°
var v0 = (120 + Math.random() * 80) * (1.1 - m * .15); // 가벼울수록 빠르게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 4.0, g: 300, rot: (Math.random() - .5) * 40, spin: ps[i].className.indexOf('star') < 0 ? 0 : (Math.random() - .5) * 720,
s: .4 + m * .3, born: now + i * 16, life: 550 + Math.random() * 350 };
}
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 < ps.length; i++) {
var el = ps[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: 실제 클릭 — 체크되는 순간(엄지 착지) 발사
document.querySelectorAll('.thumb').forEach(function (t) {
var inp = t.querySelector('.thumb__input');
if (inp) inp.addEventListener('change', function () { t.classList.remove('is-demo'); if (inp.checked) fire(); });
if (inp) inp.addEventListener('pointerdown', function () { t.classList.remove('is-demo'); });
});
// 트리거 2: 데모 루프 — 바운스 오버슈트(2s 루프의 40% ≈ 800ms)에 맞춰 발사
function demoShot() {
document.querySelectorAll('.thumb').forEach(function (t) {
if (t.classList.contains('is-demo')) setTimeout(fire, 800);
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./ThumbsUpBounce.module.scss";
export type ThumbsUpBounceProps = {
duration?: number;
easing?: string;
defaultHelpful?: boolean;
onChange?: (helpful: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/** 임팩트 스파크 1개의 물성 — 가벼운 질량·빠른 감쇠로 착지 순간 퍼졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 별 4개 + 동그라미 점 2개 교차 배치 (점은 회전 없이 translate+scale 만)
const KINDS = ["star", "dot", "star", "dot", "star", "star"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 = -k/m·v (임팩트라 빠른 감쇠 k=4), 중력 = g. */
function spawn(now: number, i: number): Particle {
const m = 0.5 + Math.random() * 0.6; // 가벼운 질량 0.5~1.1
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.2; // 위쪽 부채꼴 ±92°
const v0 = (120 + Math.random() * 80) * (1.1 - m * 0.15); // 가벼울수록 빠르게 출발
const isDot = KINDS[i] === "dot";
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 4.0, g: 300,
rot: (Math.random() - 0.5) * 40, spin: isDot ? 0 : (Math.random() - 0.5) * 720,
s: 0.4 + m * 0.3,
born: now + i * 16, life: 550 + Math.random() * 350, // 순차 분사 + 수명 랜덤
};
}
export default function ThumbsUpBounce({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", defaultHelpful = false, onChange }: ThumbsUpBounceProps) {
const [helpful, setHelpful] = useState(defaultHelpful);
const [burstKey, setBurstKey] = useState(0);
const wrapRef = useRef<HTMLLabelElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const icon = wrap.querySelector<HTMLElement>(`.${styles.icon}`);
const fx = wrap.querySelector<HTMLElement>(`.${styles.fx}`);
if (!icon || !fx) return;
fx.style.left = `${icon.offsetLeft + icon.offsetWidth / 2}px`; // 아이콘 중심으로 발사점 이동(아이콘 애니메이션과 분리)
const els = Array.from(fx.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 (
<label className={styles.thumb} style={vars} ref={wrapRef}>
<input
type="checkbox"
className={styles.input}
checked={helpful}
onChange={(e: ChangeEvent<HTMLInputElement>) => { setHelpful(e.target.checked); if (e.target.checked) fire(); onChange?.(e.target.checked); }}
/>
<span className={`${styles.icon} ${helpful ? styles.isHelpful : ""}`} aria-hidden="true">👍</span>
<span className={styles.label}>도움됐어요</span>
<span className={styles.fx} aria-hidden="true">
{KINDS.map((kind, i) => (
<span key={`${burstKey}-${i}`} className={`${styles.spark} ${kind === "star" ? styles.sStar : styles.sDot}`} aria-hidden="true" />
))}
</span>
</label>
);
}08 · 엄지 바운스 — 측정 누적면적 1.192% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.thumb__icon {
filter: grayscale(1) opacity(.55);
transition: filter $duration $easing;
}
.thumb__input:checked ~ .thumb__icon {
filter: grayscale(0) opacity(1);
animation: thumb-bounce $duration $easing;
}
@keyframes thumb-bounce {
30% { transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
}
09이모지 리액션
이모지 하나만 골라지는 구조
이모지 네 개 중 하나를 고르는 라디오 그룹입니다.

<main class="stage">
<fieldset class="react is-demo">
<legend class="react__legend">리액션 고르기</legend>
<div class="react__row">
<input type="radio" name="react" id="e1" class="react__input" style="--i:0"><label for="e1" class="react__emoji" style="--i:0" aria-label="하트">❤️<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e2" class="react__input" style="--i:1"><label for="e2" class="react__emoji" style="--i:1" aria-label="웃음">😂<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e3" class="react__input" style="--i:2"><label for="e3" class="react__emoji" style="--i:2" aria-label="좋아요">👍<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e4" class="react__input" style="--i:3"><label for="e4" class="react__emoji" style="--i:3" aria-label="놀람">😮<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
</div>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 선택 반응 시간
$easing: $ease-pop; // 팝 이징
$color: $subject-cream; // 선택 안 된 것들이 가라앉는 톤 기준
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.react {
border: 0; padding: 0; margin: 0;
&__legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
&__row { display: flex; gap: clamp(8px, 2.6vw, 14px); }
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__emoji {
position: relative; // 한 겹: 스파크 파티클의 좌표 기준 (레이아웃 불변)
font-size: clamp(24px, 6.6vw, 30px);
display: inline-block;
opacity: .7;
transform: scale(1);
cursor: pointer;
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 미니 하트 파티클 — 고른 이모지 라벨 중심에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 50%; pointer-events: none; }
&__spark {
position: absolute; top: 0; left: 0;
width: 12px; height: 12px; margin: -6px 0 0 -6px;
background: $color;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
// 하나가 선택되면 :has() 로 전체가 가라앉고, 그 하나만 다시 커진다
&:has(&__input:checked) &__emoji { opacity: .4; transform: scale(.9); }
&__input:checked + &__emoji { opacity: 1; transform: scale(1.3); }
&.is-demo &__emoji { animation: react-cycle $dur-loop $easing infinite; animation-delay: calc(var(--i) * .4s); }
}
@keyframes react-cycle {
0%, 87.5%, 100% { opacity: .55; transform: scale(1); }
12.5% { opacity: 1; transform: scale(1.3); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.react__emoji { transition: opacity 1ms; }
.react.is-demo .react__emoji { animation: none !important; }
.react__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);
}
.react {
border: 0;
padding: 0;
margin: 0;
}
.react__legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.react__row {
display: flex;
gap: clamp(8px, 2.6vw, 14px);
}
.react__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.react__emoji {
position: relative;
font-size: clamp(24px, 6.6vw, 30px);
display: inline-block;
opacity: 0.7;
transform: scale(1);
cursor: pointer;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.react__fx {
position: absolute;
top: 50%;
left: 50%;
pointer-events: none;
}
.react__spark {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
background: #fff7e6;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.react:has(.react__input:checked) .react__emoji {
opacity: 0.4;
transform: scale(0.9);
}
.react__input:checked + .react__emoji {
opacity: 1;
transform: scale(1.3);
}
.react.is-demo .react__emoji {
animation: react-cycle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 0.4s);
}
@keyframes react-cycle {
0%, 87.5%, 100% {
opacity: 0.55;
transform: scale(1);
}
12.5% {
opacity: 1;
transform: scale(1.3);
}
}
@media (prefers-reduced-motion: reduce) {
.react__emoji {
transition: opacity 1ms;
}
.react.is-demo .react__emoji {
animation: none !important;
}
.react__spark {
display: none;
}
}// 한 겹: 미니 하트 물리 파티클 — 이모지를 고르는 순간 그 이모지 라벨에서 하트가 흩어진다.
// 각 하트는 질량·감쇠(-k/m·v)·중력을 매 프레임 준암시 오일러 적분으로 받아 퍼졌다 멈춘다.
// transform 은 JS 가 계산해 기입한다(키이프레임 아님).
(function () {
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire(fx) {
if (reduce || !fx) return;
var ps = [].slice.call(fx.querySelectorAll('.react__spark'));
if (!ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .6 + Math.random() * .8; // 개별 질량 0.6~1.4
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.2; // 위쪽 부채꼴 ±92°
var v0 = (110 + Math.random() * 70) * (1.15 - m * .2); // 무거울수록 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.5, g: 300, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 800,
s: .4 + m * .3, born: now + i * 22, life: 650 + Math.random() * 350 };
}
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;
var all = document.querySelectorAll('.react__spark');
for (var i = 0; i < all.length; i++) {
var el = all[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: 실제 클릭 — 고른 라벨의 그룹만 발사
document.querySelectorAll('.react').forEach(function (r) {
r.querySelectorAll('.react__input').forEach(function (inp) {
inp.addEventListener('change', function () {
r.classList.remove('is-demo');
var lb = document.querySelector('label[for="' + inp.id + '"]');
if (lb && inp.checked) fire(lb.querySelector('.react__fx'));
});
inp.addEventListener('pointerdown', function () { r.classList.remove('is-demo'); });
});
});
// 트리거 2: 데모 루프 — 이모지 i 가 자기 사이클(2s, delay i*.4s)의 12.5%(≈250ms)에 튄다
function demoShot() {
document.querySelectorAll('.react').forEach(function (r) {
if (!r.classList.contains('is-demo')) return;
var lbs = r.querySelectorAll('.react__emoji');
for (var i = 0; i < lbs.length; i++) {
(function (lb) { setTimeout(function () { fire(lb.querySelector('.react__fx')); }, 250 + 400 * i); })(lbs[i]);
}
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmojiReact.module.scss";
export type EmojiReactProps = {
duration?: number;
easing?: string;
defaultValue?: string | null;
onChange?: (value: string) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/** 미니 하트 1개의 물성 — 질량·감쇠·중력으로 퍼졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
const SPARK_COUNT = 5;
const OPTIONS = [
{ id: "heart", emoji: "❤️", label: "하트" },
{ id: "laugh", emoji: "😂", label: "웃음" },
{ id: "like", emoji: "👍", label: "좋아요" },
{ id: "wow", emoji: "😮", label: "놀람" },
];
/** 질량-감쇠 물리 초기 상태. 감쇠 = -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.2; // 위쪽 부채꼴 ±92°
const v0 = (110 + Math.random() * 70) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.5, g: 300,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 800,
s: 0.4 + m * 0.3,
born: now + i * 22, life: 650 + Math.random() * 350, // 순차 분사 + 수명 랜덤
};
}
export default function EmojiReact({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", defaultValue = null, onChange }: EmojiReactProps) {
const [picked, setPicked] = useState(defaultValue);
const [burstKey, setBurstKey] = useState(0);
const wrapRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const els = Array.from(wrap.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.react} style={vars}>
<legend className={styles.legend}>리액션 고르기</legend>
<div className={styles.row} data-active={picked ? "" : undefined} ref={wrapRef}>
{OPTIONS.map((o) => (
<label key={o.id} className={styles.emoji} data-picked={picked === o.id || undefined}>
<input
type="radio"
name="react"
className={styles.input}
checked={picked === o.id}
onChange={() => { setPicked(o.id); fire(); onChange?.(o.id); }}
aria-label={o.label}
/>
<span aria-hidden="true">{o.emoji}</span>
{picked === o.id && (
<span className={styles.fx} aria-hidden="true">
{Array.from({ length: SPARK_COUNT }, (_, i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} aria-hidden="true" />
))}
</span>
)}
</label>
))}
</div>
</fieldset>
);
}09 · 이모지 리액션 — 측정 누적면적 4.024% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
전체를 가라앉히는 규칙
:has()로 그룹 안에 고른 게 있으면 전체를 살짝 가라앉히고, 고른 이모지 하나만 다시 키웁니다. 고르는 순간 그 이모지에서 미니 하트 다섯 개가 위로 흩어지는데, 하트마다 질량(0.6~1.4)이 달라 가벼운 것은 일찍 떨어지고 무거운 것은 관성으로 더 오래 떠 있습니다(requestAnimationFrame 적분).
.react__emoji {
transition: opacity $duration $easing, transform $duration $easing;
}
.react:has(.react__input:checked) .react__emoji {
opacity: .4; transform: scale(.9);
}
.react__input:checked + .react__emoji {
opacity: 1; transform: scale(1.3);
}
어디서 깨지는가 — 함정 하나
커스텀 속성은 보간되지 않는다
CSS 커스텀 속성(--rating처럼 --로 시작하는 값)은 그 자체를 키프레임으로 애니메이션하면 기본적으로 보간되지 않습니다. 05 반쪽 별 그라데이션 표시를 처음 만들 때 --fill: 0%에서 --fill: var(--pct)로 넘어가도록 키프레임을 짰더니, 중간 단계 없이 시작 값에서 끝 값으로 한 프레임 만에 뚝 켜졌습니다. 별이 부드럽게 차오르는 그림이 아니었습니다. MDN의 @property 문서에 나오듯 커스텀 속성은 타입을 등록해야 보간이 됩니다. 등록 없이 고치는 방법은 width처럼 원래부터 보간되는 표준 속성에 calc(var(--rating) / 5 * 100%) 값을 넘기는 것이고, 05는 이 방식으로 다시 짜서 렌더 측정에서 스물네 프레임 중 열세 프레임이 움직이는 애니메이션이 됐습니다.
:has() 는 보강으로만 쓴다
여기서 하나 더 짚을 게 있습니다. 라디오 버튼 구조를 그대로 살린 04 별점 채움과 09 이모지 리액션은 :has() 선택자로 "그룹 안에 체크된 게 있는가"를 판단합니다. :has()는 Chrome·Edge 105+, Safari 15.4+, Firefox 121+에서 동작하고(caniuse.com/css-has), 그보다 오래된 브라우저에서는 04의 형제 선택자 채움만 남고 09의 가라앉는 효과는 빠집니다. 기능 전체가 죽지 않도록 :has()는 항상 보강(progressive enhancement)으로만 쓰고, 여기 적어 둔 비밀번호 8ayu345c를 그대로 옮기면 아홉 개 전부의 vanilla·React 파일이 담긴 zip이 열립니다.
접근성
줄인 움직임에서 남는 것
아홉 개 모두 prefers-reduced-motion: reduce에서 움직임은 끄고 상태 색은 남깁니다. 01·02·07·08은 튀거나 터지는 애니메이션을 없애고 체크된 최종 모양만 즉시 보여주며, 05는 리빌 애니메이션 대신 목표 폭을 바로 고정합니다. 04는 fieldset과 legend로 별 다섯 개를 하나의 평점 그룹으로 묶고 각 라디오에 aria-label="N점"을 달아 스크린리더가 숫자를 읽게 했고, 09도 이모지마다 aria-label로 감정 이름을 읽게 했습니다. 06 박수 버튼은 누를 때마다 오르는 숫자를 aria-live="polite"로 알립니다.
@media (prefers-reduced-motion: reduce) {
.heart__shape--fill { transition: opacity 1ms; }
.avg__fill { animation: none !important; width: var(--pct); }
.thumb__icon { transition: filter 1ms; }
}
이어서 볼 글
좋아요·별점 말고 다른 CSS 애니메이션은 CSS 카테고리에 더 있고, 이 블로그가 어떤 곳인지는 소개 페이지에 적어뒀습니다.
FAQ
좋아요 버튼 css 아홉 개를 한 화면에 다 써도 되나요?
좋아요 하트나 별점처럼 카드마다 반복되는 것은 목록에 여러 개 있어도 괜찮습니다. 다만 06 박수 카운트와 09 이모지 리액션처럼 클릭할 때마다 크게 반응하는 것은 한 화면에 하나만 두는 편이 낫습니다. 여러 개가 동시에 튀면 어디를 봐야 할지 헷갈립니다.
React 버전은 무엇이 다른가요?
zip의 react/ 폴더는 체크·선택 상태를 useState로 관리합니다. 04 별점과 09 리액션은 고른 값을 onChange 콜백으로 부모에 올려주고, 05는 입력이 아니라 rating prop 하나로 값을 받는 읽기 전용 컴포넌트입니다.
체크박스 대신 button을 써도 되나요?
네, 상태가 켜짐·꺼짐 둘뿐이면 <button aria-pressed="true">로 바꿔도 동일하게 동작합니다. 다만 라벨과 자동으로 연결되는 체크박스와 달리, 버튼은 상태가 바뀔 때마다 aria-pressed 값을 직접 갱신해야 스크린리더가 변화를 압니다.









