9 CSS Heart Animations — One Line, Copy-Paste
These nine css heart animations turn a checkbox or a radio input into a like button, a star rating, or a reaction picker, moving only a transform or an 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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Heart pop
- 02 Particle burst
- 03 Like count sync
- 04 Star fill
- 05 Half-star gradient display
- 06 Clap count
- 07 Bookmark fold
- 08 Thumbs-up bounce
- 09 Emoji reaction
The order of these css button animation examples follows how directly each control answers a click, not popularity. A heart filling in comes first (01–02), then a number that has to actually change (03), then rating widgets that either record a pick or just display a value (04–05). After that comes a counter built to be pressed over and over (06), a bookmark meant to be checked once and found later (07), and a confirmation gesture (08). Last comes a group where picking one option affects every sibling around it (09). Watching the grid above needs no clicking at all: every tile there loops on its own timer, since it sits inside a still iframe that can never receive a real pointer. Scroll past it once before copying anything out of the sections below.
01Heart pop
What scales up
Checking the heart's hidden checkbox scales its filled layer from 0.4 up past 1.15 and settles at a full 1, anchored on the shape's own center rather than the wrapper around it.

<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">Like</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;
}
}// 한 겹: 질량-감쇠 물리 파티클 버스트 — 하트가 채워질 때 미니 Heart·별이 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-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 Heart 팝(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 · Heart pop — Measured area 1.06% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It suits a post's like button or a product's wishlist icon, anywhere the confirmation needs to live inside the icon itself.
.heart__shape--fill {
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 {
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); }
}
02Particle burst
Eight dots, eight angles
Eight small dots sit stacked at the heart's own center, each holding a different index in the custom property --i that a calc() turns into its own angle. Checking the box sends every dot flying outward along that angle while it fades through opacity.

<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">Like</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 Heart 루프(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 · Particle burst — Measured area 1.614% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Staggering the burst
Multiplying --i by a fixed delay staggers the eight so no two leave at exactly the same millisecond, a heavier flourish than item 01's plain pop for a first-like nudge in onboarding.
.burst__dot {
border-radius: 50%;
opacity: 0;
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 {
0% { opacity: 1; transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1); }
70% { opacity: .7; }
100% { opacity: 0; transform: rotate(calc(var(--i) * 45deg)) translateY(-34px) scale(.2); }
}
03Like count sync
Why a css like button animation still needs JavaScript
CSS alone cannot rewrite a text node, so a handful of JavaScript lines swap the neighboring number by one on every change event while a scale keyframe on that same digit gives the update a brief pop.

<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 팝을 다시 튄다.
// 한 겹: 숫자가 오를 때 질량-감쇠 물리 스파크 버스트 — 미니 Heart·별이 Heart 위에서 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-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 · Like count sync — Measured area 0.179% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to drop it in
A card that already prints a like count or a comment's upvote total can drop the same few lines in without touching its checkbox markup.
function updateCount() {
count.textContent = input.checked ? "13" : "12";
count.classList.remove("is-pop");
void count.offsetWidth;
count.classList.add("is-pop");
}
input.addEventListener("change", updateCount);
04Star fill
Five radios in reverse
Five radios sit in the markup in reverse, five down to one, so a single sibling selector after the checked input colors every star that follows it in source order — which on screen reads as every star up to the pick.

<main class="stage">
<fieldset class="rating is-demo">
<legend class="rating__legend">Rating</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 stars">★<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 stars">★<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 stars">★<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 stars">★<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 star">★<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 · Star fill — Measured area 2.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Confirming the pick
The one radio actually checked gets an extra scale bounce through the adjacent-sibling selector, confirming exactly which star the tap landed on. At the same instant four mini stars burst off the picked star's glyph, each carrying its own mass (0.6–1.4) so that a semi-implicit Euler integration inside requestAnimationFrame drags it down with damping (-k/m·v — heavier decays slower) and gravity until it stops.
.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 {
0% { transform: scale(1); }
45% { transform: scale(1.35); }
100% { transform: scale(1); }
}
05Half-star width fill
A badge, not an input
This one is not an input at all but a read-only average-rating badge, where a single custom property --rating feeds a calc() that becomes a plain percentage.

<main class="stage">
<div class="avg" style="--rating:3.5" role="img" aria-label="3.5 out of 5 stars">
<span class="avg__stars" aria-hidden="true">
<span class="avg__base">★★★★★</span>
<span class="avg__fill"><span class="avg__fill-text">★★★★★</span></span>
<span class="avg__fx" aria-hidden="true"><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span></span>
</span>
<span class="avg__num">3.5</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 차오르는 리빌 시간
$easing: $ease-out; // 채워질 때는 감속
$color: $subject-cream; // 채워진 별 색 (어두운 무대라 크림)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
// 입력이 아니라 읽기전용 평균 평점 배지. --rating(0~5, 소수 가능) 이 채움 폭을 정한다.
// 별 두 벌을 겹친다: 흐린 바탕 별 다섯 개 + 그 위에 overflow:hidden 로 폭만큼만 보이는 채움 별 다섯 개.
// custom property(--*) 자체를 애니메이션하면 보간이 안 돼 한 프레임 만에 뚝 끊긴다(실측) → width 는 표준 속성이라 부드럽게 보간된다.
.avg {
--pct: calc(var(--rating) / 5 * 100%);
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 12px);
font-family: inherit;
&__stars {
position: relative;
display: inline-block;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1;
letter-spacing: .06em;
white-space: nowrap;
}
&__base { display: block; color: rgba(255, 247, 230, .72); } // AA: 바탕 별도 큰 글자 3:1 — 잉크 위 9.21:1
&__fill {
position: absolute; inset: 0;
display: block;
width: 0%;
overflow: hidden;
color: $color;
white-space: nowrap;
}
&__fill-text { display: block; }
&__num { font-weight: 700; font-size: clamp(16px, 4.6vw, 20px); line-height: 1; font-family: inherit; color: $subject-cream; }
// 한 겹: 물리 스파크 파티클(미니 별) — 채움 앞가장자리(--pct)에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: var(--pct); pointer-events: none; }
&__spark {
position: absolute; top: 50%; left: 50%;
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
}
// 처음 보일 때 0 → 목표치까지 한 번 차오르고, 그 뒤로는 숨쉬듯 살짝 반복한다 (실제 사용자 클릭이 없는 표시용이라 항상 데모 상태)
@keyframes reveal-fill {
0% { width: 0%; }
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.avg__fill { animation: none !important; width: var(--pct); }
.avg__spark { display: none; } // 물리 파티클도 함께 숨긴다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.avg {
--pct: calc(var(--rating) / 5 * 100%);
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 12px);
font-family: inherit;
}
.avg__stars {
position: relative;
display: inline-block;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1;
letter-spacing: 0.06em;
white-space: nowrap;
}
.avg__base {
display: block;
color: rgba(255, 247, 230, 0.72);
}
.avg__fill {
position: absolute;
inset: 0;
display: block;
width: 0%;
overflow: hidden;
color: #fff7e6;
white-space: nowrap;
}
.avg__fill-text {
display: block;
}
.avg__num {
font-weight: 700;
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.avg__fx {
position: absolute;
top: 50%;
left: var(--pct);
pointer-events: none;
}
.avg__spark {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #fff7e6;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
@keyframes reveal-fill {
0% {
width: 0%;
}
55%, 80% {
width: var(--pct);
}
100% {
width: var(--pct);
}
}
.avg__fill {
animation: reveal-fill 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.avg__fill {
animation: none !important;
width: var(--pct);
}
.avg__spark {
display: none;
}
}// 한 겹: 채움 앞가장자리(--pct)에서 질량-감쇠 물리 스파크 버스트 — 미니 별이 차오르는 지점에서 흩어진다.
// 각 파티클은 초기 속도(넓은 위쪽 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var stars = document.querySelector(".avg__stars");
if (!stars) return;
var sparks = [].slice.call(stars.querySelectorAll(".avg__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .7 + Math.random() * .8; // 개별 질량 0.7~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.4; // 넓은 위쪽 부채꼴 ±97°
var v0 = (130 + Math.random() * 80) * (1.15 - m * .2); // 무거울수록 느게 출발
sparks[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.4, g: 280, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 920,
s: .4 + m * .3, born: now + i * 26, life: 700 + Math.random() * 400 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < sparks.length; i++) {
var el = sparks[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 데모 루프: reveal-fill 이 2s 루프의 55%(=1100ms)에 --pct 에 도달 — 채움 앞가장자리가 다 차는 순간 발사
function cycle() { setTimeout(fire, 1100); }
setInterval(cycle, 2000);
cycle(); // 첫 사이클(t=0)부터 녹화 창 안에 잡힌다
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HalfStarGradient.module.scss";
export type HalfStarGradientProps = {
/** 5점 만점 평균 평점, 소수 가능 */
rating: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pct"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number; s: number;
rot: number; spin: number;
born: number; life: number;
};
// 읽는 순서가 언어마다 달라 라벨 전체를 한 문구로 둔다 (en 은 "4.3 out of 5")
const RATING_LABEL = "5점 만점에 {r}점";
const SPARKS = [0, 1, 2, 3, 4, 5];
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.7 + Math.random() * 0.8; // 개별 질량 0.7~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.4; // 넓은 위쪽 부채꼴 ±97°
const v0 = (130 + Math.random() * 80) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.4, g: 280, s: 0.4 + m * 0.3,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 920,
born: now + i * 26, life: 700 + Math.random() * 400, // 순차 분사 + 수명 랜덤
};
}
// 입력이 아니라 읽기전용 평균 평점 배지. 클릭으로 값을 바꾸는 별점은 04 StarFill 을 쓴다.
export default function HalfStarGradient({ rating, duration = 600, easing = "ease-out", color = "#fff7e6" }: HalfStarGradientProps) {
const pct = `${Math.max(0, Math.min(5, rating)) / 5 * 100}%`;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--pct": pct };
const [burstKey, setBurstKey] = useState(0);
const starsRef = useRef<HTMLSpanElement>(null);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const stars = starsRef.current;
if (!stars) return;
const els = Array.from(stars.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform = `translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
// 값이 바뀌면(및 최초 마운트) 채움이 다 차는 duration 뒤에 앞가장자리에서 발사
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = setTimeout(() => setBurstKey((k) => k + 1), duration);
return () => clearTimeout(timer);
}, [rating, duration]);
return (
<div className={styles.avg} style={vars} role="img" aria-label={RATING_LABEL.replace("{r}", String(rating))}>
<span className={styles.stars} aria-hidden="true" ref={starsRef}>
<span className={styles.base}>★★★★★</span>
<span className={styles.fill}><span className={styles.fillText}>★★★★★</span></span>
<span className={styles.fx}>
{SPARKS.map((i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} />
))}
</span>
</span>
<span className={styles.num}>{rating}</span>
</div>
);
}05 · Half-star gradient display — Measured area 1.199% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why width tweens
Because that percentage lands on width, a standard property, the browser already knows how to tween it, so the fill animates smoothly from zero up to the decimal rating on a repeating loop, not just once when the badge first appears. The moment the fill reaches its leading edge (the --pct point), six mini stars burst right from that edge, driven by the same mass-based damping (-k/m·v) and mass-independent gravity integrated frame by frame in requestAnimationFrame.
.avg__fill {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
}
@keyframes reveal-fill {
0% { width: 0%; }
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
06Clap count
Two animations from one class
Every press adds one to the count while a single class swap fires two animations together: the icon rotates through a quick bounce and a small "+1" label drifts upward before fading out above it.

<main class="stage">
<button class="clap is-demo" type="button" aria-label="Clap">
<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 인 동안은 타이머가 대신 누른다.
// 한 겹: 박수마다 질량-감쇠 물리 스파크 버스트 — 미니 별이 아이콘 중심에서 흩어진다 (Clap 리듬에 맞춰 빠르고 짧게).
// 각 파티클은 초기 속도(위쪽 좁은 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-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 · Clap count — Measured area 0.886% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Keeping the plus-one in step
Sharing one class between both keeps the plus-one from hanging around after the icon's bounce has already finished. Each clap also throws a short burst of five mini stars from the icon's center — light masses (0.6–1.2) with quick damping so the sparks die within about 0.6s, matching the clap rhythm instead of lingering.
.clap__icon { transform: scale(1) rotate(0deg); }
.clap__plus {
position: absolute;
opacity: 0;
transform: translateY(0);
}
.clap.is-clap .clap__icon { animation: clap-bounce $duration $easing; }
.clap.is-clap .clap__plus { animation: clap-plus $duration $easing; }
@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); }
}
07Bookmark fold
Anchored at the corner
A small triangle clipped into the ribbon's top-right corner starts scaled to nothing and rotated -90 degrees, anchored at that exact corner through transform-origin rather than the ribbon's center.

<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">Save</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) 물리 파티클 — Save 체크 순간 접힌 귀(오른쪽 위 모서리)에서
// 다이아몬드 종잇조각이 흩어진다. 각 조각은 질량·감쇠(-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: 실제 클릭 — 체크되는 순간(Save 순간) 발사
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 · Bookmark fold — Measured area 0.758% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How the fold reads
Checking the box unfolds it back to its resting angle, reading like a corner of paper being pressed down instead of a shape that simply fades in. The instant the fold completes, six diamond paper scraps scatter from that corner — each with its own mass (0.7–1.6), so heavier ones coast farther on inertia before damping (-k/m·v) and gravity pull them to a stop.
.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);
}
08Thumbs-up bounce
Color returning on check
A grayscale filter keeps the thumb dim until the checkbox behind it is checked, at which point the icon regains full color while rotating from -20 degrees through a 5-degree overshoot back to level.
<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">This helped</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 · Thumbs-up bounce — Measured area 1.192% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the overshoot matters
That overshoot before it settles is what reads as an actual hand gesture rather than a flat icon spinning into place. Right at that landing moment an impact spark of four mini stars and two dots pops off the icon, with light masses and fast damping (k=4.0) that flash and vanish quickly so the hit feels forceful.
.thumb__icon {
transform: scale(1) rotate(0deg);
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 {
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); }
}
09Emoji reaction
One radio dims the whole row
Four emoji sit in one radio group behind a fieldset, and :has() reaches up from whichever input is checked to dim the whole row before an adjacent-sibling rule grows just that one emoji back up to 1.3 times its size.

<main class="stage">
<fieldset class="react is-demo">
<legend class="react__legend">Pick a reaction</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="Heart">❤️<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="Laugh">😂<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="Like">👍<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="Wow">😮<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;
}
}// 한 겹: 미니 Heart 물리 파티클 — 이모지를 고르는 순간 그 이모지 라벨에서 하트가 흩어진다.
// 각 하트는 질량·감쇠(-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 · Emoji reaction — Measured area 4.024% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Swapping the pick
Picking a different reaction later just swaps which sibling gets the larger scale, since only one radio in the group can stay checked at a time. The pick also fires five mini hearts upward from that emoji, and because each heart gets a different mass (0.6–1.4), the light ones fall away early while the heavy ones keep coasting — all settled by the same requestAnimationFrame integration.
.react__emoji {
opacity: .7;
transform: scale(1);
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);
}
Where it breaks — the trap
Custom properties do not interpolate
CSS custom properties do not interpolate unless their type is registered. Animating --rating directly between two percentages would jump from one value straight to the next in a single frame rather than tweening between them — exactly the discrete behavior MDN's @property reference documents for unregistered custom properties. Item 05 sidesteps that by keeping --rating only as a calc() input and animating width instead. The rendered measurement backs that choice up: the fill moves across 13 of its 24 captured frames instead of resolving in one jump.
:has() as an enhancement, not a trigger
A second trap sits in item 09 alone, the one piece of this css heart like animation set that leans on a newer selector. Its dimming trick depends on :has() to ask whether any radio in its group is checked — a selector Chrome and Edge 105, Safari 15.4, and Firefox 121 support but nothing older does. On an older engine, every emoji just stays at resting opacity while the adjacent-sibling rule still grows the picked one correctly on its own. Building the picked-emoji scale so it works without :has() first, then layering the group-wide dim on top as a bonus rather than the trigger, keeps the whole picker usable even where that selector never loads. The nine files with that layering already sorted out sit behind 8ayu345c, typed exactly as it sits in this sentence, nothing added and nothing left out.
Accessibility
Reduced motion, item by item
All nine drop straight to their checked end state under prefers-reduced-motion: reduce, though which piece of motion survives depends on how each is built. Items 01, 02, 07, and 08 lose their bounce or unfold transition and just show the finished color and shape. Item 05 swaps its reveal keyframe for a fixed width already set to var(--pct), the same standard-property fallback the trap above leans on. Item 03's scale pop and item 06's plus-one float both switch off, though the number itself still changes, since neither depends on the transition that reduced motion turns off.
Labelling the star group
Item 04's five stars sit inside a fieldset with a visually hidden legend, so a screen reader announces the group's purpose once instead of five unlabeled radios in a row:
<fieldset class="rating">
<legend class="rating__legend">Rating</legend>
<input type="radio" id="r5" class="rating__input">
<label for="r5" class="rating__star" aria-label="5 stars">★</label>
<input type="radio" id="r4" class="rating__input">
<label for="r4" class="rating__star" aria-label="4 stars">★</label>
</fieldset>
Swapping in a real button
Any of the checkbox-driven items above reads the same way through a real <button> instead, as long as aria-pressed gets updated by hand on every click since a button carries no built-in checked state of its own:
<button class="heart" type="button" aria-pressed="false" aria-label="Like">
btn.addEventListener("click", function () {
var on = btn.getAttribute("aria-pressed") === "true";
btn.setAttribute("aria-pressed", String(!on));
});
More along these lines
More CSS along these same lines lives on the CSS hub, and what this shop actually hands over versus keeps folded inside the zip is spelled out on the about page.
FAQ
Can I copy out just one css heart animation code block, not the whole set?
Yes — each item's markup and SCSS stand on their own, with no shared parent state pulling in the other eight. Item 01's pop and item 04's star fill are the two simplest starting points if a page only needs one reaction, not a full picker.
Do the React versions in the zip track state differently?
The react/ folder swaps every hidden checkbox or radio for a useState boolean or index, read back into the same class names the compiled CSS already expects. Item 05 is the odd one out there too, since it takes a rating prop instead of tracking any state at all, matching its read-only role in the vanilla version.
Does aria-pressed work on all nine, or only the checkbox-based ones?
It fits any of the checkbox items — 01, 02, 03, 07, and 08 — once the checkbox is swapped for a button, since each of those already toggles a single on-or-off state. Item 06 is already a plain <button>, but its repeat-and-count tap doesn't toggle on or off, so aria-pressed doesn't describe it either way. Items 04 and 09 pick one option out of several, so a radiogroup role or the fieldset shown above fits their behavior better than a row of separately pressed buttons.









