CSS ハート アニメーション 9選 — 一行でコピペOK
css ハート アニメーションを9種類まとめました。いいねボタンのハートから星評価、拍手カウントや絵文字リアクションまで、押した瞬間に画面がはっきり反応するものだけを選び、ほとんどはtransformとopacityだけで動き、05だけwidthで割合を示します。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ハートがポンと膨らむ
- 02 パーティクルが弾ける
- 03 いいねカウント連動
- 04 星の塗りつぶし
- 05 半星グラデーション表示
- 06 拍手カウント
- 07 ブックマークの折り込み
- 08 親指アイコンのバウンス
- 09 絵文字リアクション
並べた順は人気ではなく、押した直後に何が起きるかです。まずハート自体が跳ねる2つ(01・02)、隣の数字が連動する1つ(03)、値を選ぶ星評価2つ(04・05)、押すたびに増える1つ(06)、あとで見返すための保存1つ(07)、確認の合図1つ(08)、最後は複数から1つを選ぶリアクション(09)という並びです。コードは動きを作る核だけを抜き出していて、完成した9ファイルはzipを開かないと出てきません。
01ハートがポンと膨らむ
「いいね アニメーション css」の基本形
いいねのラベルを押すと、内側に重ねた塗りつぶしハートがopacity 0・scale .4から1倍まで一気に伸び、回転も-8度から0度へ戻ります。裏にあるのは本物のチェックボックスなので、ページを読み込み直しても押した状態が消えません。投稿のいいねボタンや商品のお気に入りアイコンに向いています。

<main class="stage">
<label class="heart is-demo">
<input type="checkbox" class="heart__input">
<span class="heart__icon" aria-hidden="true">
<span class="heart__shape heart__shape--outline"></span>
<span class="heart__shape heart__shape--fill"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--star"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--star"></span>
<span class="heart__particle heart__particle--heart"></span>
<span class="heart__particle heart__particle--star"></span>
</span>
<span class="heart__label">いいね</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-quick; // 하트가 튀어 오르는 시간
$easing: $ease-pop; // 튕기는 이징
$color: $subject-cream; // 채워질 때 하트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
@mixin heart-shape {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
}
.heart {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon { position: relative; width: 34px; height: 30px; flex: none; }
&__shape {
position: absolute; inset: 0;
&::before { @include heart-shape; left: 50%; transform: rotate(-45deg); transform-origin: 0 100%; } // 규칙8: 하트 로브 회전 피벗
&::after { @include heart-shape; left: 0; transform: rotate(45deg); transform-origin: 100% 100%; } // 규칙8: 하트 로브 회전 피벗
}
&__shape--outline { color: rgba(255, 247, 230, .3); }
&__shape--fill {
color: $color;
opacity: 0;
transform: scale(.4) rotate(-8deg);
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 버스트 파티클(미니 하트·별). 좌표·페이드는 JS 가 계산해 여기 transform 을 덮어쓴다.
&__particle {
position: absolute; top: 50%; left: 50%;
width: 10px; height: 10px; margin: -5px 0 0 -5px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__particle--heart {
width: 12px; height: 12px; margin: -6px 0 0 -6px;
background: $color;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
&__particle--star {
background: $color;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-cream; }
// 실제 클릭: 체크되면 채움 하트가 튀며 나타난다
&__input:checked ~ &__icon &__shape--fill { opacity: 1; transform: scale(1) rotate(0deg); }
// 데모 · 문서용: 2초마다 자동 반복
&.is-demo &__icon &__shape--fill { animation: heart-pop-loop $dur-loop $easing infinite; }
}
@keyframes heart-pop-loop {
0%, 15% { opacity: 0; transform: scale(.4) rotate(-8deg); }
35% { opacity: 1; transform: scale(1.15) rotate(6deg); }
55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(.4) rotate(-8deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 순간 전환만 ────────────
@media (prefers-reduced-motion: reduce) {
.heart__shape--fill { transition: opacity 1ms; }
.heart__particle { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.heart.is-demo .heart__icon .heart__shape--fill { animation: none !important; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.heart {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.heart__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.heart__icon {
position: relative;
width: 34px;
height: 30px;
flex: none;
}
.heart__shape {
position: absolute;
inset: 0;
}
.heart__shape::before {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
left: 50%;
transform: rotate(-45deg);
transform-origin: 0 100%;
}
.heart__shape::after {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
left: 0;
transform: rotate(45deg);
transform-origin: 100% 100%;
}
.heart__shape--outline {
color: rgba(255, 247, 230, 0.3);
}
.heart__shape--fill {
color: #fff7e6;
opacity: 0;
transform: scale(0.4) rotate(-8deg);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.heart__particle {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.heart__particle--heart {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
background: #fff7e6;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
.heart__particle--star {
background: #fff7e6;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.heart__label {
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.heart.is-demo .heart__icon .heart__shape--fill {
animation: heart-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes heart-pop-loop {
0%, 15% {
opacity: 0;
transform: scale(0.4) rotate(-8deg);
}
35% {
opacity: 1;
transform: scale(1.15) rotate(6deg);
}
55%, 80% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0.4) rotate(-8deg);
}
}
@media (prefers-reduced-motion: reduce) {
.heart__shape--fill {
transition: opacity 1ms;
}
.heart__particle {
display: none;
}
.heart.is-demo .heart__icon .heart__shape--fill {
animation: none !important;
opacity: 1;
}
}// 한 겹: 질량-감쇠 물리 파티클 버스트 — 하트가 채워질 때 미니 ハート·별이 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속해 더 멀리 간다)와 중력(질량 무관)을 받아 흩어졌다 멈춘다.
// CSS keyframes 하드코딩이 아니라 requestAnimationFrame 안에서 좌표를 계산해 transform 에 쓴다.
(function () {
var icon = document.querySelector('.heart__icon'); if (!icon) return;
var ps = [].slice.call(icon.querySelectorAll('.heart__particle'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .6 + Math.random() * .9; // 개별 질량 0.6~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.5; // 위쪽 부채꼴 ±72°
var v0 = (110 + Math.random() * 100) * (1.15 - m * .2); // 무거울수록 살짝 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.4, g: 300, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 540,
s: .4 + m * .3, born: now + i * 22, life: 800 + Math.random() * 450 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < ps.length; i++) {
var el = ps[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 그 자리에 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 체크되는 순간 발사
var w = document.querySelector('.heart'), inp = w && w.querySelector('.heart__input');
if (inp) {
inp.addEventListener('change', function () { w.classList.remove('is-demo'); if (inp.checked) fire(); });
inp.addEventListener('pointerdown', function () { w.classList.remove('is-demo'); });
}
// 트리거 2: 데모 루프 — CSS ハート 팝(2s 루프의 35% ≈ 660ms)에 맞춰 발사
function demoShot() { if (w && w.classList.contains('is-demo')) setTimeout(fire, 660); }
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./HeartPop.module.scss";
export type HeartPopProps = {
/** 하트가 튀어 오르는 시간 (기본 220ms) */
duration?: number;
/** CSS easing (기본 cubic-bezier(.2,1.4,.4,1)) */
easing?: string;
/** 채워질 때 하트 색 */
color?: string;
defaultLiked?: boolean;
onChange?: (liked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 하트 / 별 모양 교차 배치 (레이아웃·색은 기존 그대로)
const KINDS = ["heart", "star", "heart", "star", "heart", "star", "heart"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (질량이 클수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.9; // 개별 질량 0.6~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.5; // 위쪽 부채꼴 ±72°
const v0 = (110 + Math.random() * 100) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.4, g: 300,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 540,
s: 0.4 + m * 0.3,
born: now + i * 22, life: 800 + Math.random() * 450, // 순차 분사 + 수명 랜덤
};
}
export default function HeartPop({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultLiked = false,
onChange,
}: HeartPopProps) {
const [liked, setLiked] = useState(defaultLiked);
const [burstKey, setBurstKey] = useState(0);
const iconRef = useRef<HTMLSpanElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 체크되는 순간 파티클을 (재)발사 — 다시 좋아요를 눌러도 재생
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const icon = iconRef.current;
if (!icon) return;
const els = Array.from(icon.querySelectorAll<HTMLElement>(`.${styles.particle}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now, alive = true;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => { alive = false; cancelAnimationFrame(raf); };
}, [burstKey]);
return (
<label className={styles.heart} style={vars}>
<input
type="checkbox"
className={styles.input}
checked={liked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setLiked(e.target.checked);
if (e.target.checked) fire();
onChange?.(e.target.checked);
}}
/>
<span className={styles.icon} aria-hidden="true" ref={iconRef}>
<span className={`${styles.shape} ${styles.outline}`} />
<span className={`${styles.shape} ${styles.fill} ${liked ? styles.isLiked : ""}`} />
{KINDS.map((kind, i) => (
<span
key={i}
className={`${styles.particle} ${kind === "heart" ? styles.pHeart : styles.pStar}`}
/>
))}
</span>
<span className={styles.label}>좋아요</span>
</label>
);
}01 · ハートがポンと膨らむ — 計測 累積面積 1.06% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこに置くか
.heart__shape--fill {
color: $color;
opacity: 0;
transform: scale(.4) rotate(-8deg);
transition: opacity $duration $easing, transform $duration $easing;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
opacity: 1;
transform: scale(1) rotate(0deg);
}
@keyframes heart-pop-loop {
35% { opacity: 1; transform: scale(1.15) rotate(6deg); }
55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
}
02パーティクルが弾ける
8つの点が散る角度
チェックが入った瞬間、ハートの周りに置いた8つの点がCSSカスタムプロパティ--iで角度を割り振られ、それぞれ違う向きへtranslateYとopacityで飛び散って消えます。画像は一枚も使わず、span8個とclip-pathで描いた小さなハートだけで組みました。SNSのリアクションボタンや、アプリで最初のいいねを促す場面に向いています。

<main class="stage">
<label class="burst is-demo">
<input type="checkbox" class="burst__input">
<span class="burst__icon" aria-hidden="true">
<span class="burst__heart"></span>
<span class="burst__dot" style="--i:0"></span>
<span class="burst__dot" style="--i:1"></span>
<span class="burst__dot" style="--i:2"></span>
<span class="burst__dot" style="--i:3"></span>
<span class="burst__dot" style="--i:4"></span>
<span class="burst__dot" style="--i:5"></span>
<span class="burst__dot" style="--i:6"></span>
<span class="burst__dot" style="--i:7"></span>
</span>
<span class="burst__label">いいね</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 파티클이 퍼지는 시간
$easing: $ease-out; // 퍼질 때는 빠르게 시작해 느려진다
$color: $subject-ink; // 파티클·하트 색 (노랑 무대라 잉크색)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.burst {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon { position: relative; width: 40px; height: 36px; flex: none; }
&__heart {
position: absolute; inset: 8px;
background: $color;
clip-path: path("M12 22 2 13C-1 10 0 5 4 4 7 3 10 5 12 8 14 5 17 3 20 4 24 5 25 10 22 13Z");
transform: scale(.5);
opacity: .5;
transition: transform $duration $easing, opacity $duration $easing;
}
// 물리 파티클 — 좌표·스케일·페이드는 JS 적분(rAF)이 매 프레임 transform 에 쓴다 (keyframes 아님)
&__dot {
position: absolute; top: 50%; left: 50%; width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
border-radius: 50%;
background: $color;
opacity: 0;
will-change: transform, opacity;
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-ink; }
&__input:checked ~ &__icon &__heart { transform: scale(1); opacity: 1; }
// 파티클 발사는 JS 물리가 담당 — 체크 시 keyframes 는 더 이상 쓰지 않는다
&.is-demo &__icon &__heart { animation: burst-heart-loop $dur-loop $easing infinite; }
}
@keyframes burst-heart-loop {
0%, 20% { transform: scale(.5); opacity: .5; }
35%, 80% { transform: scale(1); opacity: 1; }
100% { transform: scale(.5); opacity: .5; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.burst__heart { transition: opacity 1ms; }
.burst__dot { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.burst.is-demo .burst__icon .burst__heart { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.burst {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.burst__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.burst__icon {
position: relative;
width: 40px;
height: 36px;
flex: none;
}
.burst__heart {
position: absolute;
inset: 8px;
background: #17141a;
clip-path: path("M12 22 2 13C-1 10 0 5 4 4 7 3 10 5 12 8 14 5 17 3 20 4 24 5 25 10 22 13Z");
transform: scale(0.5);
opacity: 0.5;
transition: transform 600ms ease-out, opacity 600ms ease-out;
}
.burst__dot {
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 50%;
background: #17141a;
opacity: 0;
will-change: transform, opacity;
}
.burst__label {
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.burst__input:checked ~ .burst__icon .burst__heart {
transform: scale(1);
opacity: 1;
}
.burst.is-demo .burst__icon .burst__heart {
animation: burst-heart-loop 2s ease-out infinite;
}
@keyframes burst-heart-loop {
0%, 20% {
transform: scale(0.5);
opacity: 0.5;
}
35%, 80% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.burst__heart {
transition: opacity 1ms;
}
.burst__dot {
display: none;
}
.burst.is-demo .burst__icon .burst__heart {
animation: none !important;
}
}// 질량-감쇠 물리 버스트 — 기존 45° 고정 링 keyframes 를 JS 적분으로 바꿔치기했다 (레이아웃·색 불변).
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량 m(0.7~1.6)을 갖고 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 무거울수록 덜 감속해 관성으로 더 멀리 간다)와 중력 g(질량 무관)을 받아 흩어졌다 멈춘다.
// 좌표는 requestAnimationFrame 안에서 계산해 transform 에 쓴다 — 타이밍·거리를 keyframes 에 박지 않는다.
(function () {
var icon = document.querySelector('.burst__icon'); if (!icon) return;
var dots = [].slice.call(icon.querySelectorAll('.burst__dot'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !dots.length) return;
var now = performance.now();
for (var i = 0; i < dots.length; i++) {
var m = .7 + Math.random() * .9; // 개별 질량 0.7~1.6
var ang = (i / dots.length) * Math.PI * 2 + (Math.random() - .5) * .55; // 45° 뼈대 + 지터
var v0 = (150 + Math.random() * 130) * m; // 무거울수록 세게 — 질량이 관성
dots[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 4.2, g: 260, s: .35 + m * .45,
born: now + i * 20, life: 620 + Math.random() * 380 }; // 순차 분사(20ms) + 수명 랜덤
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < dots.length; i++) {
var el = dots[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 그 자리에 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g
p.x += p.vx * dt; p.y += p.vy * dt;
var o = age / p.life; // 수명 후반 40%에서 페이드아웃
el.style.opacity = o < .6 ? '1' : (1 - (o - .6) / .4).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 체크되는 순간 발사
var w = document.querySelector('.burst'), inp = w && w.querySelector('.burst__input');
if (inp) {
inp.addEventListener('change', function () { w.classList.remove('is-demo'); if (inp.checked) fire(); });
inp.addEventListener('pointerdown', function () { w.classList.remove('is-demo'); });
}
// 트리거 2: 데모 루프 — CSS ハート 루프(2s 의 35% ≈ 660ms)에 맞춰 발사
function demoShot() { if (w && w.classList.contains('is-demo')) setTimeout(fire, 660); }
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./ParticleBurst.module.scss";
export type ParticleBurstProps = {
/** 파티클이 퍼지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
onChange?: (liked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--i"?: number };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number; s: number;
born: number; life: number;
};
const DOTS = Array.from({ length: 8 }, (_, i) => i);
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속해 관성으로 더 멀리), 중력 = g (질량 무관). */
function spawn(now: number, i: number, n: number): Particle {
const m = 0.7 + Math.random() * 0.9; // 개별 질량 0.7~1.6
const ang = (i / n) * Math.PI * 2 + (Math.random() - 0.5) * 0.55; // 45° 뼈대 + 지터
const v0 = (150 + Math.random() * 130) * m; // 무거울수록 세게 — 질량이 관성
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 4.2, g: 260, s: 0.35 + m * 0.45,
born: now + i * 20, life: 620 + Math.random() * 380, // 순차 분사 + 수명 랜덤
};
}
export default function ParticleBurst({ duration = 600, easing = "ease-out", color = "#17141a", onChange }: ParticleBurstProps) {
const [liked, setLiked] = useState(false);
const [burstKey, setBurstKey] = useState(0);
const iconRef = useRef<HTMLSpanElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const icon = iconRef.current;
if (!icon) return;
const els = Array.from(icon.querySelectorAll<HTMLElement>(`.${styles.dot}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i, els.length));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt;
const o = age / p.life;
el.style.opacity = o < 0.6 ? "1" : (1 - (o - 0.6) / 0.4).toFixed(3);
el.style.transform = `translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
const handle = (checked: boolean) => {
setLiked(checked);
if (checked) fire(); // 다시 좋아요를 눌러도 파티클이 재생되게
onChange?.(checked);
};
return (
<label className={styles.burst} style={vars}>
<input type="checkbox" className={styles.input} checked={liked} onChange={(e: ChangeEvent<HTMLInputElement>) => handle(e.target.checked)} />
<span className={styles.icon} aria-hidden="true" ref={iconRef}>
<span className={`${styles.heart} ${liked ? styles.isLiked : ""}`} />
{DOTS.map((i) => (
<span key={`${burstKey}-${i}`} className={styles.dot} style={{ "--i": i } as DotVars} />
))}
</span>
<span className={styles.label}>좋아요</span>
</label>
);
}02 · パーティクルが弾ける — 計測 累積面積 1.614% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
.burst__dot {
position: absolute; top: 50%; left: 50%;
width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
border-radius: 50%; background: $color;
transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1);
}
.burst__input:checked ~ .burst__icon .burst__dot {
animation: particle-fly $duration $easing forwards;
animation-delay: calc(var(--i) * 20ms);
}
@keyframes particle-fly {
100% { opacity: 0; transform: rotate(calc(var(--i) * 45deg)) translateY(-34px) scale(.2); }
}
03いいねカウント連動
数字が連動する仕組み
チェックのオン・オフが切り替わるたび、change イベントを拾ったJS数行が隣の数字を1増減させ、変わった直後の数字だけscaleでポンと弾みます。CSSだけでは文字そのものを書き換えられないため、ここだけJSに任せています。

<main class="stage">
<label class="like is-demo">
<input type="checkbox" class="like__input">
<span class="like__heart" aria-hidden="true"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__spark like__spark--star"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__spark like__spark--star"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__count"><span class="like__num" aria-live="polite">12</span></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 숫자가 튀는 시간
$easing: $ease-pop; // 팝 이징
$color: $subject-cream; // 하트·숫자 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.like {
position: relative; // 스파크 파티클의 좌표 기준 (레이아웃 불변)
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 10px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__heart {
display: inline-block;
width: 18px; height: 18px;
align-self: center;
background: rgba(255, 247, 230, .4);
clip-path: path("M9 17 1.5 10C-1 7.5 0 3.5 3 3 5.5 2.5 7.5 4 9 6 10.5 4 12.5 2.5 15 3 18 3.5 19 7.5 16.5 10Z");
transition: background $duration $easing, transform $duration $easing;
}
&__input:checked ~ &__heart { background: $color; transform: scale(1.15); }
// 한 겹: 물리 스파크 파티클(미니 하트·별) — 하트 위에서 발사. 좌표·페이드는 JS 가 계산한다.
&__spark {
position: absolute; top: 50%; left: 9px; // 하트(첫 아이템, 수직 중앙) 중심 — 50% 로 모든 글자 크기에서 추적
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__spark--heart {
width: 12px; height: 12px; margin: -6px 0 0 -6px;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
&__spark--star {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
&__count { font-weight: 700; font-size: clamp(18px, 5vw, 22px); line-height: 1; font-family: inherit; color: $subject-cream; }
&__num.is-pop { animation: count-pop $duration $easing; }
&.is-demo &__heart { animation: heart-fill-loop $dur-loop $easing infinite; }
}
@keyframes count-pop {
0% { display: inline-block; transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
@keyframes heart-fill-loop {
0%, 20% { background: rgba(255, 247, 230, .4); transform: scale(1); }
35%, 60% { background: $color; transform: scale(1.15); }
80%, 100% { background: rgba(255, 247, 230, .4); transform: scale(1); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.like__heart { transition: background 1ms; }
.like__num.is-pop { animation: none !important; }
.like__spark { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.like.is-demo .like__heart { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.like {
position: relative;
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 10px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.like__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.like__heart {
display: inline-block;
width: 18px;
height: 18px;
align-self: center;
background: rgba(255, 247, 230, 0.4);
clip-path: path("M9 17 1.5 10C-1 7.5 0 3.5 3 3 5.5 2.5 7.5 4 9 6 10.5 4 12.5 2.5 15 3 18 3.5 19 7.5 16.5 10Z");
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like__input:checked ~ .like__heart {
background: #fff7e6;
transform: scale(1.15);
}
.like__spark {
position: absolute;
top: 50%;
left: 9px;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #fff7e6;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.like__spark--heart {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
.like__spark--star {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.like__count {
font-weight: 700;
font-size: clamp(18px, 5vw, 22px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.like__num.is-pop {
animation: count-pop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like.is-demo .like__heart {
animation: heart-fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes count-pop {
0% {
display: inline-block;
transform: scale(1);
}
50% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
@keyframes heart-fill-loop {
0%, 20% {
background: rgba(255, 247, 230, 0.4);
transform: scale(1);
}
35%, 60% {
background: #fff7e6;
transform: scale(1.15);
}
80%, 100% {
background: rgba(255, 247, 230, 0.4);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.like__heart {
transition: background 1ms;
}
.like__num.is-pop {
animation: none !important;
}
.like__spark {
display: none;
}
.like.is-demo .like__heart {
animation: none !important;
}
}// 체크 상태가 바뀔 때마다 숫자를 1 더하거나 빼고 scale 팝을 다시 튄다.
// 한 겹: 숫자가 오를 때 질량-감쇠 물리 스파크 버스트 — 미니 ハート·별이 ハート 위에서 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var w = document.querySelector(".like");
if (!w) return;
var inp = w.querySelector(".like__input"), num = w.querySelector(".like__num");
var sparks = [].slice.call(w.querySelectorAll(".like__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .6 + Math.random() * .9; // 개별 질량 0.6~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.4; // 위쪽 부채꼴 ±69°
var v0 = (100 + Math.random() * 90) * (1.15 - m * .2); // 무거울수록 느게 출발
sparks[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.6, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 480,
s: .4 + m * .3, born: now + i * 26, life: 780 + Math.random() * 420 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < sparks.length; i++) {
var el = sparks[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop"); void num.offsetWidth; num.classList.add("is-pop");
if (inp.checked) fire(); // 숫자가 오를 때만 발사
}
inp.addEventListener("change", function () { w.classList.remove("is-demo"); sync(); });
setInterval(function () { if (!w.classList.contains("is-demo")) return; inp.checked = !inp.checked; sync(); }, 1400);
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./LikeCountSync.module.scss";
export type LikeCountSyncProps = {
duration?: number;
easing?: string;
color?: string;
baseCount?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 하트 / 별 모양 교차 배치
const KINDS = ["heart", "star", "heart", "star", "heart"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (질량이 클수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.9; // 개별 질량 0.6~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.4; // 위쪽 부채꼴 ±69°
const v0 = (100 + Math.random() * 90) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.6, g: 320,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 480,
s: 0.4 + m * 0.3,
born: now + i * 26, life: 800 + Math.random() * 420, // 순차 분사 + 수명 랜덤
};
}
export default function LikeCountSync({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6", baseCount = 12 }: LikeCountSyncProps) {
const [liked, setLiked] = useState(false);
const [pop, setPop] = useState(0);
const wrapRef = useRef<HTMLLabelElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fire = useCallback(() => setPop((p) => p + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!pop) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const els = Array.from(wrap.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [pop]);
const toggle = (checked: boolean) => { setLiked(checked); if (checked) fire(); };
return (
<label className={styles.like} style={vars} ref={wrapRef}>
<input type="checkbox" className={styles.input} checked={liked} onChange={(e: ChangeEvent<HTMLInputElement>) => toggle(e.target.checked)} />
<span className={`${styles.heart} ${liked ? styles.isLiked : ""}`} aria-hidden="true" />
{KINDS.map((kind, i) => (
<span key={i} className={`${styles.spark} ${kind === "heart" ? styles.sHeart : styles.sStar}`} aria-hidden="true" />
))}
<span className={styles.count}>
<span key={pop} className={styles.num}>{liked ? baseCount + 1 : baseCount}</span>
</span>
</label>
);
}03 · いいねカウント連動 — 計測 累積面積 0.179% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
CSSだけでは足りない理由
var w = document.querySelector(".like"), inp = w.querySelector(".like__input"), num = w.querySelector(".like__num");
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop"); void num.offsetWidth; num.classList.add("is-pop");
}
inp.addEventListener("change", function () { w.classList.remove("is-demo"); sync(); });
04星の塗りつぶし
ラジオを逆順に並べる理由
5個のラジオを5・4・3・2・1の逆順に並べ、チェックされたラジオより後ろの星をすべて~セレクタで塗りつぶし、選んだ星だけさらにscaleで弾ませて選択を伝えます。5つの星はfieldsetとlegendでひとつの評価グループにまとめてあります。レビューの星評価入力や商品の満足度評価に向いています。選んだ瞬間その星の上からミニ星4個が弾け、それぞれ質量(0.6〜1.4)を持つためrequestAnimationFrame内の準陰的オイラー積分で減衰(-k/m·v — 重いほど減速しにくい)と重力を受けながら飛んで止まります。

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

<main class="stage">
<div class="avg" style="--rating:3.5" role="img" aria-label="5点満点中3.5点">
<span class="avg__stars" aria-hidden="true">
<span class="avg__base">★★★★★</span>
<span class="avg__fill"><span class="avg__fill-text">★★★★★</span></span>
<span class="avg__fx" aria-hidden="true"><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span><span class="avg__spark"></span></span>
</span>
<span class="avg__num">3.5</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 차오르는 리빌 시간
$easing: $ease-out; // 채워질 때는 감속
$color: $subject-cream; // 채워진 별 색 (어두운 무대라 크림)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
// 입력이 아니라 읽기전용 평균 평점 배지. --rating(0~5, 소수 가능) 이 채움 폭을 정한다.
// 별 두 벌을 겹친다: 흐린 바탕 별 다섯 개 + 그 위에 overflow:hidden 로 폭만큼만 보이는 채움 별 다섯 개.
// custom property(--*) 자체를 애니메이션하면 보간이 안 돼 한 프레임 만에 뚝 끊긴다(실측) → width 는 표준 속성이라 부드럽게 보간된다.
.avg {
--pct: calc(var(--rating) / 5 * 100%);
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 12px);
font-family: inherit;
&__stars {
position: relative;
display: inline-block;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1;
letter-spacing: .06em;
white-space: nowrap;
}
&__base { display: block; color: rgba(255, 247, 230, .72); } // AA: 바탕 별도 큰 글자 3:1 — 잉크 위 9.21:1
&__fill {
position: absolute; inset: 0;
display: block;
width: 0%;
overflow: hidden;
color: $color;
white-space: nowrap;
}
&__fill-text { display: block; }
&__num { font-weight: 700; font-size: clamp(16px, 4.6vw, 20px); line-height: 1; font-family: inherit; color: $subject-cream; }
// 한 겹: 물리 스파크 파티클(미니 별) — 채움 앞가장자리(--pct)에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: var(--pct); pointer-events: none; }
&__spark {
position: absolute; top: 50%; left: 50%;
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
}
// 처음 보일 때 0 → 목표치까지 한 번 차오르고, 그 뒤로는 숨쉬듯 살짝 반복한다 (실제 사용자 클릭이 없는 표시용이라 항상 데모 상태)
@keyframes reveal-fill {
0% { width: 0%; }
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.avg__fill { animation: none !important; width: var(--pct); }
.avg__spark { display: none; } // 물리 파티클도 함께 숨긴다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.avg {
--pct: calc(var(--rating) / 5 * 100%);
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 12px);
font-family: inherit;
}
.avg__stars {
position: relative;
display: inline-block;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1;
letter-spacing: 0.06em;
white-space: nowrap;
}
.avg__base {
display: block;
color: rgba(255, 247, 230, 0.72);
}
.avg__fill {
position: absolute;
inset: 0;
display: block;
width: 0%;
overflow: hidden;
color: #fff7e6;
white-space: nowrap;
}
.avg__fill-text {
display: block;
}
.avg__num {
font-weight: 700;
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.avg__fx {
position: absolute;
top: 50%;
left: var(--pct);
pointer-events: none;
}
.avg__spark {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #fff7e6;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
@keyframes reveal-fill {
0% {
width: 0%;
}
55%, 80% {
width: var(--pct);
}
100% {
width: var(--pct);
}
}
.avg__fill {
animation: reveal-fill 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.avg__fill {
animation: none !important;
width: var(--pct);
}
.avg__spark {
display: none;
}
}// 한 겹: 채움 앞가장자리(--pct)에서 질량-감쇠 물리 스파크 버스트 — 미니 별이 차오르는 지점에서 흩어진다.
// 각 파티클은 초기 속도(넓은 위쪽 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var stars = document.querySelector(".avg__stars");
if (!stars) return;
var sparks = [].slice.call(stars.querySelectorAll(".avg__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .7 + Math.random() * .8; // 개별 질량 0.7~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.4; // 넓은 위쪽 부채꼴 ±97°
var v0 = (130 + Math.random() * 80) * (1.15 - m * .2); // 무거울수록 느게 출발
sparks[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.4, g: 280, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 920,
s: .4 + m * .3, born: now + i * 26, life: 700 + Math.random() * 400 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < sparks.length; i++) {
var el = sparks[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 데모 루프: reveal-fill 이 2s 루프의 55%(=1100ms)에 --pct 에 도달 — 채움 앞가장자리가 다 차는 순간 발사
function cycle() { setTimeout(fire, 1100); }
setInterval(cycle, 2000);
cycle(); // 첫 사이클(t=0)부터 녹화 창 안에 잡힌다
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HalfStarGradient.module.scss";
export type HalfStarGradientProps = {
/** 5점 만점 평균 평점, 소수 가능 */
rating: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pct"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number; s: number;
rot: number; spin: number;
born: number; life: number;
};
// 읽는 순서가 언어마다 달라 라벨 전체를 한 문구로 둔다 (en 은 "4.3 out of 5")
const RATING_LABEL = "5점 만점에 {r}점";
const SPARKS = [0, 1, 2, 3, 4, 5];
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.7 + Math.random() * 0.8; // 개별 질량 0.7~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.4; // 넓은 위쪽 부채꼴 ±97°
const v0 = (130 + Math.random() * 80) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.4, g: 280, s: 0.4 + m * 0.3,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 920,
born: now + i * 26, life: 700 + Math.random() * 400, // 순차 분사 + 수명 랜덤
};
}
// 입력이 아니라 읽기전용 평균 평점 배지. 클릭으로 값을 바꾸는 별점은 04 StarFill 을 쓴다.
export default function HalfStarGradient({ rating, duration = 600, easing = "ease-out", color = "#fff7e6" }: HalfStarGradientProps) {
const pct = `${Math.max(0, Math.min(5, rating)) / 5 * 100}%`;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--pct": pct };
const [burstKey, setBurstKey] = useState(0);
const starsRef = useRef<HTMLSpanElement>(null);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const stars = starsRef.current;
if (!stars) return;
const els = Array.from(stars.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform = `translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
// 값이 바뀌면(및 최초 마운트) 채움이 다 차는 duration 뒤에 앞가장자리에서 발사
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = setTimeout(() => setBurstKey((k) => k + 1), duration);
return () => clearTimeout(timer);
}, [rating, duration]);
return (
<div className={styles.avg} style={vars} role="img" aria-label={RATING_LABEL.replace("{r}", String(rating))}>
<span className={styles.stars} aria-hidden="true" ref={starsRef}>
<span className={styles.base}>★★★★★</span>
<span className={styles.fill}><span className={styles.fillText}>★★★★★</span></span>
<span className={styles.fx}>
{SPARKS.map((i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} />
))}
</span>
</span>
<span className={styles.num}>{rating}</span>
</div>
);
}05 · 半星グラデーション表示 — 計測 累積面積 1.199% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
商品一覧での使いどころ
.avg__fill {
position: absolute; inset: 0;
width: 0%; overflow: hidden; color: $color;
}
@keyframes reveal-fill {
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
06拍手カウント
押すたびに増える数字
押すたびに数字が増えるボタンで、クリックごとにアイコンがscaleとrotateで軽く弾み、「+1」の文字がtranslateYで上へ流れながらopacityで消えます。Medium風の拍手ボタンやフィードバックウィジェットに向いています。拍手のたびアイコンの中心からミニ星5個も短く弾け、軽い質量(0.6〜1.2)と速い減衰により0.6秒ほどで落ちて消えるので拍手のリズムに合わせた動きになります。

<main class="stage">
<button class="clap is-demo" type="button" aria-label="拍手">
<span class="clap__icon" aria-hidden="true">👏</span>
<span class="clap__plus" aria-hidden="true">+1</span>
<span class="clap__count"><span aria-live="polite">128</span></span>
<span class="clap__fx" aria-hidden="true"><span class="clap__spark"></span><span class="clap__spark"></span><span class="clap__spark"></span><span class="clap__spark"></span><span class="clap__spark"></span></span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 아이콘이 튀는 시간
$easing: $ease-pop; // 튕기는 이징
$color: $subject-ink; // 숫자 강조색 (노랑 무대)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.clap {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 10px);
border: 0;
border-radius: $r-pill;
padding: clamp(10px, 3.6vw, 14px) clamp(18px, 5.4vw, 22px);
font-weight: 700; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit;
color: $color;
background: rgba(23, 20, 26, .08);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__icon { font-size: clamp(20px, 5.6vw, 24px); display: inline-block; transform: scale(1) rotate(0deg); }
&__plus {
position: absolute; top: -2px; left: 34px;
font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; color: $color;
opacity: 0; transform: translateY(0);
pointer-events: none;
}
&__count { font-variant-numeric: tabular-nums; }
// 한 겹: 물리 스파크 파티클(미니 별) — fire 직전 JS 가 아이콘 중심으로 원점을 옮긴다. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 0; pointer-events: none; }
&__spark {
position: absolute; top: 50%; left: 50%;
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&.is-clap &__icon { animation: clap-bounce $duration $easing; }
&.is-clap &__plus { animation: clap-plus $duration $easing; }
&.is-demo &__icon { animation: clap-bounce-loop $dur-loop $easing infinite; }
&.is-demo &__plus { animation: clap-plus-loop $dur-loop $easing infinite; }
}
@keyframes clap-bounce {
0% { transform: scale(1) rotate(0deg); }
40% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
@keyframes clap-bounce-loop {
0%, 60% { transform: scale(1) rotate(0deg); }
75% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus-loop {
0%, 60% { opacity: 0; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.clap.is-clap .clap__icon,
.clap.is-clap .clap__plus,
.clap.is-demo .clap__icon,
.clap.is-demo .clap__plus { animation: none !important; opacity: 0; transform: none; }
.clap__spark { display: none; } // 물리 파티클도 함께 숨긴다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.clap {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 10px);
border: 0;
border-radius: 999px;
padding: clamp(10px, 3.6vw, 14px) clamp(18px, 5.4vw, 22px);
font-weight: 700;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: rgba(23, 20, 26, 0.08);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.clap__icon {
font-size: clamp(20px, 5.6vw, 24px);
display: inline-block;
transform: scale(1) rotate(0deg);
}
.clap__plus {
position: absolute;
top: -2px;
left: 34px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0;
transform: translateY(0);
pointer-events: none;
}
.clap__count {
font-variant-numeric: tabular-nums;
}
.clap__fx {
position: absolute;
top: 50%;
left: 0;
pointer-events: none;
}
.clap__spark {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #17141a;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.clap.is-clap .clap__icon {
animation: clap-bounce 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.clap.is-clap .clap__plus {
animation: clap-plus 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.clap.is-demo .clap__icon {
animation: clap-bounce-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.clap.is-demo .clap__plus {
animation: clap-plus-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes clap-bounce {
0% {
transform: scale(1) rotate(0deg);
}
40% {
transform: scale(1.3) rotate(-12deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes clap-plus {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-16px);
}
}
@keyframes clap-bounce-loop {
0%, 60% {
transform: scale(1) rotate(0deg);
}
75% {
transform: scale(1.3) rotate(-12deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes clap-plus-loop {
0%, 60% {
opacity: 0;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-16px);
}
}
@media (prefers-reduced-motion: reduce) {
.clap.is-clap .clap__icon,
.clap.is-clap .clap__plus,
.clap.is-demo .clap__icon,
.clap.is-demo .clap__plus {
animation: none !important;
opacity: 0;
transform: none;
}
.clap__spark {
display: none;
}
}// 누를 때마다 카운트가 1 오르고 아이콘이 튄다. .is-demo 인 동안은 타이머가 대신 누른다.
// 한 겹: 박수마다 질량-감쇠 물리 스파크 버스트 — 미니 별이 아이콘 중심에서 흩어진다 (拍手 리듬에 맞춰 빠르고 짧게).
// 각 파티클은 초기 속도(위쪽 좁은 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
var b = document.querySelector(".clap"), n = b.querySelector(".clap__count span");
var fx = b.querySelector(".clap__fx"), icon = b.querySelector(".clap__icon");
var sparks = [].slice.call(fx.querySelectorAll(".clap__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
fx.style.left = (icon.offsetLeft + icon.offsetWidth / 2) + "px"; // 아이콘 중심으로 발사 원점 이동
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .6 + Math.random() * .6; // 개별 질량 0.6~1.2
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.4; // 위쪽 좁은 부채꼴 ±69°
var v0 = (100 + Math.random() * 70) * (1.15 - m * .2); // 무거울수록 느게 출발
sparks[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.8, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 840,
s: .4 + m * .3, born: now + i * 18, life: 550 + Math.random() * 300 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < sparks.length; i++) {
var el = sparks[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
function clap() {
n.textContent = String(Number(n.textContent) + 1);
b.classList.remove("is-clap"); void b.offsetWidth; b.classList.add("is-clap");
fire(); // 실제 클릭·데모 루프 둘 다 박수마다 발사
}
b.addEventListener("pointerdown", function () { b.classList.remove("is-demo"); clap(); });
var demoClap = function () { if (!b.classList.contains("is-demo")) return; clap(); };
setInterval(demoClap, 900);
setTimeout(demoClap, 100); // 첫 사이클부터 녹화 창 안에 잡힌다import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ClapCount.module.scss";
export type ClapCountProps = {
duration?: number;
easing?: string;
color?: string;
baseCount?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number; s: number;
rot: number; spin: number;
born: number; life: number;
};
const SPARKS = [0, 1, 2, 3, 4];
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (무거울수록 덜 감속), 중력 = g (질량 무관). 박수 리듬에 맞춰 빠르고 짧게. */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.6; // 개별 질량 0.6~1.2
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.4; // 위쪽 좁은 부채꼴 ±69°
const v0 = (100 + Math.random() * 70) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.8, g: 320, s: 0.4 + m * 0.3,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 840,
born: now + i * 18, life: 550 + Math.random() * 300, // 순차 분사 + 수명 랜덤
};
}
export default function ClapCount({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#17141a", baseCount = 128 }: ClapCountProps) {
const [count, setCount] = useState(baseCount);
const [claps, setClaps] = useState(0); // 카운트 증가 펄스이자 파티클 burstKey 를 겸한다
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const clap = () => { setCount((c) => c + 1); setClaps((k) => k + 1); };
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!claps) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const btn = btnRef.current;
if (!btn) return;
const icon = btn.querySelector<HTMLElement>(`.${styles.icon}`);
const fx = btn.querySelector<HTMLElement>(`.${styles.fx}`);
if (!icon || !fx) return;
fx.style.left = `${icon.offsetLeft + icon.offsetWidth / 2}px`; // 아이콘 중심으로 발사 원점 이동
const els = Array.from(fx.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform = `translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [claps]);
return (
<button type="button" className={styles.clap} style={vars} onClick={clap} aria-label="박수" ref={btnRef}>
<span key={claps} className={styles.icon} aria-hidden="true">👏</span>
{claps > 0 && <span key={`plus-${claps}`} className={styles.plus} aria-hidden="true">+1</span>}
<span className={styles.count}>{count}</span>
{claps > 0 && (
<span key={`fx-${claps}`} className={styles.fx} aria-hidden="true">
{SPARKS.map((i) => (
<span key={`${claps}-${i}`} className={styles.spark} />
))}
</span>
)}
</button>
);
}06 · 拍手カウント — 計測 累積面積 0.886% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
Medium風の拍手ボタンに
@keyframes clap-bounce {
40% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
07ブックマークの折り込み
折り込みの基準点
チェックするとリボン型のブックマークが上からscaleYで塗りつぶされ、右上角の小さな三角形がその角を基準にrotateとscaleで開き、紙が折れて戻ったように見えます。記事の保存ボタンやあとで読むリストへの追加に向いています。折り込みが完成した瞬間、その角からダイヤモンド型の紙片6個が散り、それぞれ質量(0.7〜1.6)を持つため重いものは慣性でより遠くへ飛んでから減衰(-k/m·v)と重力に抑えられて止まります。

<main class="stage">
<label class="bookmark is-demo">
<input type="checkbox" class="bookmark__input">
<span class="bookmark__icon" aria-hidden="true">
<span class="bookmark__ribbon"></span>
<span class="bookmark__fold"></span>
<span class="bookmark__fx" aria-hidden="true">
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
<span class="bookmark__chip"></span>
</span>
</span>
<span class="bookmark__label">保存</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 리본이 채워지는 시간
$easing: $ease-spring; // 스프링 이징
$color: $subject-cream; // 채워진 리본 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
$shape: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
.bookmark {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon { position: relative; width: 24px; height: 30px; flex: none; }
&__ribbon {
position: absolute; inset: 0;
clip-path: $shape;
background: rgba(255, 247, 230, .3);
&::after {
content: "";
position: absolute; inset: 0;
clip-path: $shape;
background: $color;
opacity: 0;
transform: scaleY(.35);
transform-origin: top; // 규칙8: 리본 세로 채움 위에서
transition: opacity $duration $easing, transform $duration $easing;
}
}
&__fold {
position: absolute; top: 0; right: 0; width: 8px; height: 8px;
clip-path: polygon(100% 0, 0 0, 100% 100%);
background: rgba(23, 20, 26, .35);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0; // 규칙8: 접힌 귀 꼭짓점 경첩
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 종잇조각(confetti) 파티클 — 접힌 귀(오른쪽 위 모서리)에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 0; left: 100%; pointer-events: none; } // 귀 꼭짓점이 원점
&__chip {
position: absolute; top: 0; left: 0;
width: 8px; height: 8px; margin: -4px 0 0 -4px;
background: $color;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-ink; } // AA: 주황 무대 위 글자 5.50:1
&__input:checked ~ &__icon &__ribbon::after { opacity: 1; transform: scaleY(1); }
&__input:checked ~ &__icon &__fold { opacity: 1; transform: scale(1) rotate(0deg); }
&.is-demo &__icon &__ribbon::after { animation: ribbon-fill-loop $dur-loop $easing infinite; }
&.is-demo &__icon &__fold { animation: fold-in-loop $dur-loop $easing infinite; }
}
@keyframes ribbon-fill-loop {
0%, 20% { opacity: 0; transform: scaleY(.35); }
40%, 80% { opacity: 1; transform: scaleY(1); }
100% { opacity: 0; transform: scaleY(.35); }
}
@keyframes fold-in-loop {
0%, 20% { opacity: 0; transform: scale(0) rotate(-90deg); }
40%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0) rotate(-90deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.bookmark__ribbon::after, .bookmark__fold { transition: opacity 1ms; }
.bookmark.is-demo .bookmark__icon .bookmark__ribbon::after,
.bookmark.is-demo .bookmark__icon .bookmark__fold { animation: none !important; opacity: 1; transform: none; }
.bookmark__chip { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bookmark {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bookmark__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.bookmark__icon {
position: relative;
width: 24px;
height: 30px;
flex: none;
}
.bookmark__ribbon {
position: absolute;
inset: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
background: rgba(255, 247, 230, 0.3);
}
.bookmark__ribbon::after {
content: "";
position: absolute;
inset: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
background: #fff7e6;
opacity: 0;
transform: scaleY(0.35);
transform-origin: top;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bookmark__fold {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
clip-path: polygon(100% 0, 0 0, 100% 100%);
background: rgba(23, 20, 26, 0.35);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0;
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bookmark__fx {
position: absolute;
top: 0;
left: 100%;
pointer-events: none;
}
.bookmark__chip {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
background: #fff7e6;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.bookmark__label {
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__ribbon::after {
opacity: 1;
transform: scaleY(1);
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.bookmark.is-demo .bookmark__icon .bookmark__ribbon::after {
animation: ribbon-fill-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.bookmark.is-demo .bookmark__icon .bookmark__fold {
animation: fold-in-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ribbon-fill-loop {
0%, 20% {
opacity: 0;
transform: scaleY(0.35);
}
40%, 80% {
opacity: 1;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(0.35);
}
}
@keyframes fold-in-loop {
0%, 20% {
opacity: 0;
transform: scale(0) rotate(-90deg);
}
40%, 80% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0) rotate(-90deg);
}
}
@media (prefers-reduced-motion: reduce) {
.bookmark__ribbon::after, .bookmark__fold {
transition: opacity 1ms;
}
.bookmark.is-demo .bookmark__icon .bookmark__ribbon::after,
.bookmark.is-demo .bookmark__icon .bookmark__fold {
animation: none !important;
opacity: 1;
transform: none;
}
.bookmark__chip {
display: none;
}
}// 한 겹: 종이 조각(confetti) 물리 파티클 — 保存 체크 순간 접힌 귀(오른쪽 위 모서리)에서
// 다이아몬드 종잇조각이 흩어진다. 각 조각은 질량·감쇠(-k/m·v)·중력을 매 프레임 준암시 오일러
// 적분으로 받아 날아갔다 멈춘다. transform 은 JS 가 계산해 기입한다(키이프레임 아님).
(function () {
var ps = [].slice.call(document.querySelectorAll('.bookmark__chip'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .7 + Math.random() * .9; // 개별 질량 0.7~1.6
var ang = -Math.PI / 4 + (Math.random() - .5) * 2.8; // 바깥쪽(위·오른쪽) 부채꼴 ±80°
var v0 = (110 + Math.random() * 80) * (1.15 - m * .2); // 무거울수록 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.2, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 960,
s: .4 + m * .3, born: now + i * 20, life: 700 + Math.random() * 300 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < ps.length; i++) {
var el = ps[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 그 자리에 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 체크되는 순간(保存 순간) 발사
document.querySelectorAll('.bookmark').forEach(function (b) {
var inp = b.querySelector('.bookmark__input');
if (inp) inp.addEventListener('change', function () { b.classList.remove('is-demo'); if (inp.checked) fire(); });
if (inp) inp.addEventListener('pointerdown', function () { b.classList.remove('is-demo'); });
});
// 트리거 2: 데모 루프 — 귀 접힘(2s 루프의 40% ≈ 800ms)에 맞춰 발사
function demoShot() {
document.querySelectorAll('.bookmark').forEach(function (b) {
if (b.classList.contains('is-demo')) setTimeout(fire, 800);
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./BookmarkFold.module.scss";
export type BookmarkFoldProps = {
duration?: number;
easing?: string;
color?: string;
defaultSaved?: boolean;
onChange?: (saved: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 종잇조각 1개의 물성 — 질량·감쇠·중력으로 날아갔다 멈춘다 (귀 꼭짓점 기준 바깥 부채꼴) */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
const CHIP_COUNT = 6;
/** 질량-감쇠 물리 초기 상태. 감쇠 = -k/m·v, 중력 = g. 접힌 귀에서 위·오른쪽 부채꼴로 흩어진다. */
function spawn(now: number, i: number): Particle {
const m = 0.7 + Math.random() * 0.9; // 개별 질량 0.7~1.6
const ang = -Math.PI / 4 + (Math.random() - 0.5) * 2.8; // 바깥쪽(위·오른쪽) 부채꼴 ±80°
const v0 = (110 + Math.random() * 80) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.2, g: 320,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 960,
s: 0.4 + m * 0.3,
born: now + i * 20, life: 700 + Math.random() * 300, // 순차 분사 + 수명 랜덤
};
}
export default function BookmarkFold({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#fff7e6", defaultSaved = false, onChange }: BookmarkFoldProps) {
const [saved, setSaved] = useState(defaultSaved);
const [burstKey, setBurstKey] = useState(0);
const iconRef = useRef<HTMLSpanElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const icon = iconRef.current;
if (!icon) return;
const els = Array.from(icon.querySelectorAll<HTMLElement>(`.${styles.chip}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
return (
<label className={styles.bookmark} style={vars}>
<input
type="checkbox"
className={styles.input}
checked={saved}
onChange={(e: ChangeEvent<HTMLInputElement>) => { setSaved(e.target.checked); if (e.target.checked) fire(); onChange?.(e.target.checked); }}
/>
<span className={styles.icon} aria-hidden="true" ref={iconRef}>
<span className={styles.ribbon}>
<span className={`${styles.ribbonFill} ${saved ? styles.isSaved : ""}`} />
</span>
<span className={`${styles.fold} ${saved ? styles.isSaved : ""}`} />
<span className={styles.fx} aria-hidden="true">
{Array.from({ length: CHIP_COUNT }, (_, i) => (
<span key={`${burstKey}-${i}`} className={styles.chip} aria-hidden="true" />
))}
</span>
</span>
<span className={styles.label}>저장</span>
</label>
);
}07 · ブックマークの折り込み — 計測 累積面積 0.758% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
記事の保存ボタンに
.bookmark__fold {
clip-path: polygon(100% 0, 0 0, 100% 100%);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0;
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
opacity: 1; transform: scale(1) rotate(0deg);
}
08親指アイコンのバウンス
グレースケールから色が戻るまで
チェックされる前はfilter: grayscaleで色を落としてあり、チェックされると色が戻ると同時に、親指のアイコンが-20度から5度を経て0度に戻るまでscaleも一緒に伸び縮みします。回答の「役に立った」ボタンやレビューの「参考になった」表示に向いています。着地のオーバーシュートの瞬間にはアイコンからミニ星4個と点2個の衝撃スパークが跳ね、軽い質量と速い減衰(k=4.0)で短く閃いて消えるため力が込もった感触になります。
<main class="stage">
<label class="thumb is-demo">
<input type="checkbox" class="thumb__input">
<span class="thumb__icon" aria-hidden="true">👍</span>
<span class="thumb__label">参考になった</span>
<span class="thumb__fx" aria-hidden="true"><span class="thumb__spark thumb__spark--star"></span><span class="thumb__spark thumb__spark--dot"></span><span class="thumb__spark thumb__spark--star"></span><span class="thumb__spark thumb__spark--dot"></span><span class="thumb__spark thumb__spark--star"></span><span class="thumb__spark thumb__spark--star"></span></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 손짓이 도는 시간
$easing: $ease-spring; // 스프링 이징
$color: $subject-blue; // 켜졌을 때 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.thumb {
position: relative; // 한 겹: 임팩트 스파크의 좌표 기준 (레이아웃 불변)
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__icon {
font-size: clamp(26px, 7.2vw, 32px);
display: inline-block;
transform: scale(1) rotate(0deg);
filter: grayscale(1) opacity(.55);
transition: filter $duration $easing;
}
&__label { font-weight: 600; font-size: clamp(15px, 4.2vw, 18px); line-height: 1; font-family: inherit; color: $subject-ink; }
// 한 겹: 물리 임팩트 스파크(미니 별+점) — 엄지 착지 순간 아이콘 중심에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 0; pointer-events: none; } // left 는 fire 직전 JS 가 아이콘 중심으로 이동
&__spark {
position: absolute; top: 0; left: 0;
width: 8px; height: 8px; margin: -4px 0 0 -4px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__spark--star {
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
&__spark--dot { background: $color; border-radius: 50%; }
&__input:checked ~ &__icon { filter: grayscale(0) opacity(1); animation: thumb-bounce $duration $easing; }
&.is-demo &__icon { animation: thumb-bounce-loop $dur-loop $easing infinite; }
}
@keyframes thumb-bounce {
0% { transform: scale(1) rotate(0deg); }
30% { transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes thumb-bounce-loop {
0%, 15% { filter: grayscale(1) opacity(.55); transform: scale(1) rotate(0deg); }
40% { filter: grayscale(0) opacity(1); transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
80%, 100% { filter: grayscale(1) opacity(.55); transform: scale(1) rotate(0deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.thumb__icon { transition: filter 1ms; }
.thumb__input:checked ~ .thumb__icon { animation: none !important; }
.thumb.is-demo .thumb__icon { animation: none !important; }
.thumb__spark { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.thumb {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.thumb__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.thumb__icon {
font-size: clamp(26px, 7.2vw, 32px);
display: inline-block;
transform: scale(1) rotate(0deg);
filter: grayscale(1) opacity(0.55);
transition: filter 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.thumb__label {
position: relative;
font-weight: 600;
font-size: clamp(15px, 4.2vw, 18px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.thumb__fx {
position: absolute;
top: 50%;
left: 0;
pointer-events: none;
}
.thumb__spark {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.thumb__spark--star {
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #2f6df6;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.thumb__spark--dot {
background: #2f6df6;
border-radius: 50%;
}
.thumb__input:checked ~ .thumb__icon {
filter: grayscale(0) opacity(1);
animation: thumb-bounce 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.thumb.is-demo .thumb__icon {
animation: thumb-bounce-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes thumb-bounce {
0% {
transform: scale(1) rotate(0deg);
}
30% {
transform: scale(1.25) rotate(-20deg);
}
60% {
transform: scale(1.1) rotate(5deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes thumb-bounce-loop {
0%, 15% {
filter: grayscale(1) opacity(0.55);
transform: scale(1) rotate(0deg);
}
40% {
filter: grayscale(0) opacity(1);
transform: scale(1.25) rotate(-20deg);
}
60% {
transform: scale(1.1) rotate(5deg);
}
80%, 100% {
filter: grayscale(1) opacity(0.55);
transform: scale(1) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.thumb__icon {
transition: filter 1ms;
}
.thumb__input:checked ~ .thumb__icon {
animation: none !important;
}
.thumb.is-demo .thumb__icon {
animation: none !important;
}
.thumb__spark {
display: none;
}
}// 한 겹: 임팩트 스파크 물리 파티클 — 엄지가 착지하는 순간(체크 오버슈트) 아이콘에서
// 미니 별+점이 튄다. 각 파티클은 질량·감쇠(-k/m·v — 임팩트라 빠른 감쇠)·중력을 매 프레임
// 준암시 오일러 적분으로 받아 퍼졌다 멈춘다. transform 은 JS 가 계산해 기입한다.
(function () {
var ps = [].slice.call(document.querySelectorAll('.thumb__spark'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !ps.length) return;
var w = document.querySelector('.thumb'), icon = w && w.querySelector('.thumb__icon'), fx = w && w.querySelector('.thumb__fx');
if (!fx || !icon) return;
fx.style.left = (icon.offsetLeft + icon.offsetWidth / 2) + 'px'; // 아이콘 중심으로 발사점 이동(라벨 .thumb 기준)
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .5 + Math.random() * .6; // 가벼운 질량 0.5~1.1 (임팩트)
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.2; // 위쪽 부채꼴 ±92°
var v0 = (120 + Math.random() * 80) * (1.1 - m * .15); // 가벼울수록 빠르게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 4.0, g: 300, rot: (Math.random() - .5) * 40, spin: ps[i].className.indexOf('star') < 0 ? 0 : (Math.random() - .5) * 720,
s: .4 + m * .3, born: now + i * 16, life: 550 + Math.random() * 350 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < ps.length; i++) {
var el = ps[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 그 자리에 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 체크되는 순간(엄지 착지) 발사
document.querySelectorAll('.thumb').forEach(function (t) {
var inp = t.querySelector('.thumb__input');
if (inp) inp.addEventListener('change', function () { t.classList.remove('is-demo'); if (inp.checked) fire(); });
if (inp) inp.addEventListener('pointerdown', function () { t.classList.remove('is-demo'); });
});
// 트리거 2: 데모 루프 — 바운스 오버슈트(2s 루프의 40% ≈ 800ms)에 맞춰 발사
function demoShot() {
document.querySelectorAll('.thumb').forEach(function (t) {
if (t.classList.contains('is-demo')) setTimeout(fire, 800);
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./ThumbsUpBounce.module.scss";
export type ThumbsUpBounceProps = {
duration?: number;
easing?: string;
defaultHelpful?: boolean;
onChange?: (helpful: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/** 임팩트 스파크 1개의 물성 — 가벼운 질량·빠른 감쇠로 착지 순간 퍼졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 별 4개 + 동그라미 점 2개 교차 배치 (점은 회전 없이 translate+scale 만)
const KINDS = ["star", "dot", "star", "dot", "star", "star"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 = -k/m·v (임팩트라 빠른 감쇠 k=4), 중력 = g. */
function spawn(now: number, i: number): Particle {
const m = 0.5 + Math.random() * 0.6; // 가벼운 질량 0.5~1.1
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.2; // 위쪽 부채꼴 ±92°
const v0 = (120 + Math.random() * 80) * (1.1 - m * 0.15); // 가벼울수록 빠르게 출발
const isDot = KINDS[i] === "dot";
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 4.0, g: 300,
rot: (Math.random() - 0.5) * 40, spin: isDot ? 0 : (Math.random() - 0.5) * 720,
s: 0.4 + m * 0.3,
born: now + i * 16, life: 550 + Math.random() * 350, // 순차 분사 + 수명 랜덤
};
}
export default function ThumbsUpBounce({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", defaultHelpful = false, onChange }: ThumbsUpBounceProps) {
const [helpful, setHelpful] = useState(defaultHelpful);
const [burstKey, setBurstKey] = useState(0);
const wrapRef = useRef<HTMLLabelElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const icon = wrap.querySelector<HTMLElement>(`.${styles.icon}`);
const fx = wrap.querySelector<HTMLElement>(`.${styles.fx}`);
if (!icon || !fx) return;
fx.style.left = `${icon.offsetLeft + icon.offsetWidth / 2}px`; // 아이콘 중심으로 발사점 이동(아이콘 애니메이션과 분리)
const els = Array.from(fx.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
return (
<label className={styles.thumb} style={vars} ref={wrapRef}>
<input
type="checkbox"
className={styles.input}
checked={helpful}
onChange={(e: ChangeEvent<HTMLInputElement>) => { setHelpful(e.target.checked); if (e.target.checked) fire(); onChange?.(e.target.checked); }}
/>
<span className={`${styles.icon} ${helpful ? styles.isHelpful : ""}`} aria-hidden="true">👍</span>
<span className={styles.label}>도움됐어요</span>
<span className={styles.fx} aria-hidden="true">
{KINDS.map((kind, i) => (
<span key={`${burstKey}-${i}`} className={`${styles.spark} ${kind === "star" ? styles.sStar : styles.sDot}`} aria-hidden="true" />
))}
</span>
</label>
);
}08 · 親指アイコンのバウンス — 計測 累積面積 1.192% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
「役に立った」ボタンに
.thumb__icon {
filter: grayscale(1) opacity(.55);
transition: filter $duration $easing;
}
.thumb__input:checked ~ .thumb__icon {
filter: grayscale(0) opacity(1);
animation: thumb-bounce $duration $easing;
}
@keyframes thumb-bounce {
30% { transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
}
09絵文字リアクション
絵文字を1つだけ選ぶ仕組み
4つの絵文字はラジオで1つだけ選べるグループにまとめ、:has()でグループ内に選んだものがあるかを判定して全体をわずかに沈め、選んだ絵文字だけopacityとscaleで再び持ち上げます。チャットメッセージのリアクションや投稿への感情リアクションに向いています。選んだ瞬間その絵文字からミニハート5個が上へ散り、ハートごとに質量(0.6〜1.4)が違うため軽いものは早く落ち、重いものは慣性で長く浮かびます(requestAnimationFrame積分)。

<main class="stage">
<fieldset class="react is-demo">
<legend class="react__legend">リアクションを選ぶ</legend>
<div class="react__row">
<input type="radio" name="react" id="e1" class="react__input" style="--i:0"><label for="e1" class="react__emoji" style="--i:0" aria-label="ハート">❤️<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e2" class="react__input" style="--i:1"><label for="e2" class="react__emoji" style="--i:1" aria-label="笑い">😂<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e3" class="react__input" style="--i:2"><label for="e3" class="react__emoji" style="--i:2" aria-label="いいね">👍<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e4" class="react__input" style="--i:3"><label for="e4" class="react__emoji" style="--i:3" aria-label="驚き">😮<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
</div>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 선택 반응 시간
$easing: $ease-pop; // 팝 이징
$color: $subject-cream; // 선택 안 된 것들이 가라앉는 톤 기준
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.react {
border: 0; padding: 0; margin: 0;
&__legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
&__row { display: flex; gap: clamp(8px, 2.6vw, 14px); }
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__emoji {
position: relative; // 한 겹: 스파크 파티클의 좌표 기준 (레이아웃 불변)
font-size: clamp(24px, 6.6vw, 30px);
display: inline-block;
opacity: .7;
transform: scale(1);
cursor: pointer;
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 미니 하트 파티클 — 고른 이모지 라벨 중심에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 50%; pointer-events: none; }
&__spark {
position: absolute; top: 0; left: 0;
width: 12px; height: 12px; margin: -6px 0 0 -6px;
background: $color;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
// 하나가 선택되면 :has() 로 전체가 가라앉고, 그 하나만 다시 커진다
&:has(&__input:checked) &__emoji { opacity: .4; transform: scale(.9); }
&__input:checked + &__emoji { opacity: 1; transform: scale(1.3); }
&.is-demo &__emoji { animation: react-cycle $dur-loop $easing infinite; animation-delay: calc(var(--i) * .4s); }
}
@keyframes react-cycle {
0%, 87.5%, 100% { opacity: .55; transform: scale(1); }
12.5% { opacity: 1; transform: scale(1.3); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.react__emoji { transition: opacity 1ms; }
.react.is-demo .react__emoji { animation: none !important; }
.react__spark { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.react {
border: 0;
padding: 0;
margin: 0;
}
.react__legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.react__row {
display: flex;
gap: clamp(8px, 2.6vw, 14px);
}
.react__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.react__emoji {
position: relative;
font-size: clamp(24px, 6.6vw, 30px);
display: inline-block;
opacity: 0.7;
transform: scale(1);
cursor: pointer;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.react__fx {
position: absolute;
top: 50%;
left: 50%;
pointer-events: none;
}
.react__spark {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
background: #fff7e6;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.react:has(.react__input:checked) .react__emoji {
opacity: 0.4;
transform: scale(0.9);
}
.react__input:checked + .react__emoji {
opacity: 1;
transform: scale(1.3);
}
.react.is-demo .react__emoji {
animation: react-cycle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 0.4s);
}
@keyframes react-cycle {
0%, 87.5%, 100% {
opacity: 0.55;
transform: scale(1);
}
12.5% {
opacity: 1;
transform: scale(1.3);
}
}
@media (prefers-reduced-motion: reduce) {
.react__emoji {
transition: opacity 1ms;
}
.react.is-demo .react__emoji {
animation: none !important;
}
.react__spark {
display: none;
}
}// 한 겹: 미니 ハート 물리 파티클 — 이모지를 고르는 순간 그 이모지 라벨에서 하트가 흩어진다.
// 각 하트는 질량·감쇠(-k/m·v)·중력을 매 프레임 준암시 오일러 적분으로 받아 퍼졌다 멈춘다.
// transform 은 JS 가 계산해 기입한다(키이프레임 아님).
(function () {
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire(fx) {
if (reduce || !fx) return;
var ps = [].slice.call(fx.querySelectorAll('.react__spark'));
if (!ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .6 + Math.random() * .8; // 개별 질량 0.6~1.4
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.2; // 위쪽 부채꼴 ±92°
var v0 = (110 + Math.random() * 70) * (1.15 - m * .2); // 무거울수록 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.5, g: 300, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 800,
s: .4 + m * .3, born: now + i * 22, life: 650 + Math.random() * 350 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
var all = document.querySelectorAll('.react__spark');
for (var i = 0; i < all.length; i++) {
var el = all[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 그 자리에 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 고른 라벨의 그룹만 발사
document.querySelectorAll('.react').forEach(function (r) {
r.querySelectorAll('.react__input').forEach(function (inp) {
inp.addEventListener('change', function () {
r.classList.remove('is-demo');
var lb = document.querySelector('label[for="' + inp.id + '"]');
if (lb && inp.checked) fire(lb.querySelector('.react__fx'));
});
inp.addEventListener('pointerdown', function () { r.classList.remove('is-demo'); });
});
});
// 트리거 2: 데모 루프 — 이모지 i 가 자기 사이클(2s, delay i*.4s)의 12.5%(≈250ms)에 튄다
function demoShot() {
document.querySelectorAll('.react').forEach(function (r) {
if (!r.classList.contains('is-demo')) return;
var lbs = r.querySelectorAll('.react__emoji');
for (var i = 0; i < lbs.length; i++) {
(function (lb) { setTimeout(function () { fire(lb.querySelector('.react__fx')); }, 250 + 400 * i); })(lbs[i]);
}
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmojiReact.module.scss";
export type EmojiReactProps = {
duration?: number;
easing?: string;
defaultValue?: string | null;
onChange?: (value: string) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/** 미니 하트 1개의 물성 — 질량·감쇠·중력으로 퍼졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
const SPARK_COUNT = 5;
const OPTIONS = [
{ id: "heart", emoji: "❤️", label: "하트" },
{ id: "laugh", emoji: "😂", label: "웃음" },
{ id: "like", emoji: "👍", label: "좋아요" },
{ id: "wow", emoji: "😮", label: "놀람" },
];
/** 질량-감쇠 물리 초기 상태. 감쇠 = -k/m·v, 중력 = g. 고른 이모지 라벨 중심에서 위쪽 부채꼴로 흩어진다. */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.8; // 개별 질량 0.6~1.4
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.2; // 위쪽 부채꼴 ±92°
const v0 = (110 + Math.random() * 70) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.5, g: 300,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 800,
s: 0.4 + m * 0.3,
born: now + i * 22, life: 650 + Math.random() * 350, // 순차 분사 + 수명 랜덤
};
}
export default function EmojiReact({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", defaultValue = null, onChange }: EmojiReactProps) {
const [picked, setPicked] = useState(defaultValue);
const [burstKey, setBurstKey] = useState(0);
const wrapRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const els = Array.from(wrap.querySelectorAll<HTMLElement>(`.${styles.spark}`)); // 렌더된 그룹(고른 라벨 하나)만
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
return (
<fieldset className={styles.react} style={vars}>
<legend className={styles.legend}>리액션 고르기</legend>
<div className={styles.row} data-active={picked ? "" : undefined} ref={wrapRef}>
{OPTIONS.map((o) => (
<label key={o.id} className={styles.emoji} data-picked={picked === o.id || undefined}>
<input
type="radio"
name="react"
className={styles.input}
checked={picked === o.id}
onChange={() => { setPicked(o.id); fire(); onChange?.(o.id); }}
aria-label={o.label}
/>
<span aria-hidden="true">{o.emoji}</span>
{picked === o.id && (
<span className={styles.fx} aria-hidden="true">
{Array.from({ length: SPARK_COUNT }, (_, i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} aria-hidden="true" />
))}
</span>
)}
</label>
))}
</div>
</fieldset>
);
}09 · 絵文字リアクション — 計測 累積面積 4.024% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
チャットのリアクションに
.react__emoji {
transition: opacity $duration $easing, transform $duration $easing;
}
.react:has(.react__input:checked) .react__emoji {
opacity: .4; transform: scale(.9);
}
.react__input:checked + .react__emoji {
opacity: 1; transform: scale(1.3);
}
どこで壊れるか — カスタムプロパティの罠
カスタムプロパティは補間されない
05の平均評価バッジを最初に組んだときは、--fill: 0%から--fill: var(--pct)へ動かすキーフレームを、カスタムプロパティそのものに対して書いていました。ところが実際に計測すると24フレーム中たった1フレームしか値が変わっておらず、星は滑らかに満ちるどころか一瞬で切り替わっているだけでした。MDNの@propertyのページにあるとおり、カスタムプロパティは型を登録しない限り補間の対象になりません。型を登録せずに直す方法は、widthのようにもとから補間される標準プロパティへcalc(var(--rating) / 5 * 100%)という計算値を渡すことで、05はこの形に書き直してから24フレーム中13フレームが動く滑らかなアニメーションになりました。
:has() は上乗せとして使う
もう一つ、04の星の塗りつぶしと09の絵文字リアクションは:has()という比較的新しいセレクタに頼っています。対応状況はcaniuse.comの:has()のページにまとまっていて、ChromeとEdgeは105以降、Safariは15.4以降、Firefoxは121以降で動き、それより古いブラウザでは09の沈み込みだけが起きず、絵文字を選ぶ操作自体は変わらず機能します。ここまで直した9つのファイルを開くには8ayu345cという文字列をそのまま入力する必要があり、下のカードからzipを受け取ってください。
アクセシビリティ
動きを止めても残るもの
9つともprefers-reduced-motion: reduceでは跳ねたり弾けたりする動きを止め、チェックや選択という最終的な状態の色だけを残します。01・02・07・08は登場のアニメーションをやめてチェック済みの最終形をそのまま出し、05はリビール用のanimationを切って目標のwidthを最初から固定します。
@media (prefers-reduced-motion: reduce) {
.heart__shape--fill { transition: opacity 1ms; }
.avg__fill { animation: none !important; width: var(--pct); }
.thumb__icon { transition: filter 1ms; }
}
星グループのラベル付け
04と09は星や絵文字をそれぞれfieldsetとlegendでひとつのグループとしてまとめ、各ラジオに読み上げ用のaria-labelを添えています。
<fieldset class="rating">
<legend class="rating__legend">評価</legend>
<input type="radio" name="rating" id="r5" class="rating__input" aria-label="5点">
<label for="r5" class="rating__star" aria-label="5点">★</label>
</fieldset>
ボタンに置き換えるとき
「css ボタン 押したら変わる」形をチェックボックスではなくボタンで作るなら、押すたびに値をJSで書き換えるaria-pressed属性が必要です。
<button type="button" class="thumb" aria-pressed="false">
<span class="thumb__icon" aria-hidden="true">👍</span>
</button>
06の拍手ボタンは押すたびに増える数字をaria-live="polite"で知らせ、画面を見ていない人にも変化が届くようにしています。
次に読む
ハートや星以外のCSSアニメーションを探すならCSS記事の一覧に、このブログが何を配っているサイトかはこのサイトについてにまとめてあります。
FAQ
「css ボタン アニメーション」を9つとも同じ画面に置いても大丈夫ですか?
カードごとに繰り返すいいねハートや星評価は、一覧の中に何個並んでいても問題ありません。ただし06の拍手カウントや09の絵文字リアクションのように押すたびに画面が大きく反応するものは、同じ画面に1つだけ置くほうが視線が散りません。複数が同時に弾むと、どこを見ればいいか分からなくなります。
Reactのコンポーネントでも同じ動きになりますか?
zip内のreact/以下にある各コンポーネントは、チェックや選択の状態をuseStateで持ち、色や速さを決める値だけをCSS側の変数へ渡しています。04の星評価と09のリアクションは選んだ値をonChangeで親コンポーネントへ返し、05は入力ではなくratingというprop一つで表示だけを受け取る読み取り専用の作りです。
チェックボックスをbuttonに置き換えても動きますか?
はい、オン・オフの2状態だけならaria-pressedを持つbuttonに置き換えても同じ見た目を作れます。ただしラベルと自動でつながるチェックボックスと違い、buttonは状態が変わるたびにaria-pressedの値を自分でJSから更新しないと、スクリーンリーダーには変化が伝わりません。









