9 CSS Heart Like Animations — dán 1 dòng là chạy
Chín css heart like animation cho nút thích, đánh giá sao và các nút phản hồi kiểu vỗ tay, bookmark, emoji được gom vào một bài — chỉ đổi transform và opacity,
01 heart-like-rating-heart-pop
02 heart-like-rating-particle-burst
03 heart-like-rating-like-count-sync
04 heart-like-rating-star-fill
05 heart-like-rating-star-half
06 heart-like-rating-clap-count
07 heart-like-rating-bookmark-fold
09 heart-like-rating-emoji-react
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Trái tim bung ra
- 02 Pháo hoa hạt nhỏ
- 03 Số lượt thích đổi theo
- 04 Sao đánh giá tô đầy
- 05 Huy hiệu sao lẻ
- 06 Đếm vỗ tay
- 07 Góc bookmark gập mở
- 08 Ngón cái lắc lư
- 09 Chọn emoji cảm xúc
Thứ tự chín mục không theo độ nổi tiếng mà theo loại input nằm phía sau mỗi hiệu ứng. 01 đến 03 và 06 đến 08 đều dùng một checkbox hoặc button tự bật tắt một mình; 04 và 09 dùng một nhóm radio buộc chỉ giữ lại đúng một lựa chọn; riêng 05 không phải input nào cả mà là badge chỉ để xem. Mỗi ô trong lưới phía trên tự phát lại bằng class is-demo cứ hai giây một lần, vì khung iframe không nhận được cú chạm thật của bạn; script trong từng file gỡ đúng class đó ngay khi bạn thật sự bấm, để phần còn lại chạy bằng :checked chứ không phải vòng lặp giả.
01Trái tim bung ra
Bạn thấy gì
Bấm vào nhãn thì checkbox ẩn chuyển trạng thái, kéo theo lớp tô màu của trái tim phóng từ tỉ lệ .4 lên 1.15 lần quanh chính tâm rồi lắng lại đúng 1 lần.

<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">Thích</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;
}
}// 한 겹: 질량-감쇠 물리 파티클 버스트 — 하트가 채워질 때 미니 Trái tim·별이 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-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 Trái tim 팝(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 · Trái tim bung ra — Diện tích đo được 1.06% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Trái tim dựng từ hai pseudo-element bo tròn xoay ±45 độ, không dùng ảnh hay thẻ svg nào cả.
.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); }
}
02Pháo hoa hạt nhỏ
Chuyện gì diễn ra trên màn hình
Ngay khi checkbox được chọn, tám chấm quanh trái tim toả ra theo góc riêng của từng chấm — góc đó tính từ custom property --i nhân với 45deg — rồi mờ dần và thu nhỏ khi bay ra xa.

<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">Thích</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 Trái tim 루프(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 · Pháo hoa hạt nhỏ — Diện tích đo được 1.614% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Không có ảnh nào ở đây cả, chỉ tám span tròn dùng chung một keyframes.
.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); }
}
03Số lượt thích đổi theo
Chuyển động thế nào
CSS không tự đổi được chữ số, nên một đoạn script ngắn nghe sự kiện change của checkbox, ghi lại "13" hoặc "12" vào span rồi bật class is-pop để con số vừa đổi nảy lên bằng scale trong chớp mắt.

<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 팝을 다시 튄다.
// 한 겹: 숫자가 오를 때 질량-감쇠 물리 스파크 버스트 — 미니 Trái tim·별이 Trái tim 위에서 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-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 · Số lượt thích đổi theo — Diện tích đo được 0.179% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop");
void num.offsetWidth;
num.classList.add("is-pop");
}
inp.addEventListener("change", 갱신);
04Sao đánh giá tô đầy
Bạn thấy gì
Năm radio xếp ngược 5·4·3·2·1 rồi lật bằng row-reverse để thứ tự hiển thị trên màn hình đúng từ 1 đến 5, nên sibling selector ~ tô màu mọi sao đứng sau radio vừa chọn trong DOM — tức mọi sao thấp hơn hoặc bằng điểm đó. Ngay lúc chọn, bốn sao mini bung ra từ chính ngôi sao được chọn, mỗi hạt mang khối lượng riêng (0.6–1.4) nên tích phân Euler bán ẩn bên trong requestAnimationFrame kéo nó xuống bằng lực cản (-k/m·v — hạt nặng giảm tốc chậm hơn) và trọng lực rồi dừng hẳn.

<main class="stage">
<fieldset class="rating is-demo">
<legend class="rating__legend">Đánh giá</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 sao">★<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 sao">★<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 sao">★<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 sao">★<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 sao">★<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 · Sao đánh giá tô đầy — Diện tích đo được 2.926% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Riêng sao vừa chọn còn nảy thêm một nhịp scale để xác nhận cú bấm.
.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); }
}
05Huy hiệu sao lẻ
Chuyện gì diễn ra trên màn hình
Đây không phải input mà là badge điểm trung bình chỉ để xem, dựng từ hai bộ sao chồng nhau: bộ nền mờ ở dưới và bộ tô màu đặt chồng lên trên, dùng overflow: hidden để chỉ lộ ra đúng phần trăm điểm. Khi vạch tô chạm tới mép trước của nó (điểm --pct), sáu sao mini bung đúng từ mép đó, cùng chịu lực cản theo khối lượng (-k/m·v) và trọng lượng không phụ thuộc khối lượng, được tích phân từng khung hình trong requestAnimationFrame.

<main class="stage">
<div class="avg" style="--rating:3.5" role="img" aria-label="3.5 trên 5 sao">
<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 · Huy hiệu sao lẻ — Diện tích đo được 1.199% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Cứ mỗi hai giây, phần trăm đó lại chạy từ 0 lên giá trị thật, giữ nguyên một lúc rồi lặp lại từ đầu — badge này không có cú chạm nào để dừng lại, nên vòng lặp không tắt hẳn.
.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Đếm vỗ tay
Chuyển động thế nào
Mỗi lần bấm, icon 👏 nảy nhẹ bằng scale trong lúc chữ "+1" trôi lên bằng translateY rồi mờ dần qua opacity, còn con số bên dưới tăng thêm 1 qua vài dòng JS. Mỗi cái vỗ tay cũng ném ra một loạt ngắn năm sao mini từ tâm icon — khối lượng nhẹ (0.6–1.2) kèm lực cản nhanh nên tia lửa tắt trong khoảng 0.6 giây, khớp nhịp vỗ tay thay vì tồn dai dẳng.

<main class="stage">
<button class="clap is-demo" type="button" aria-label="Vỗ tay">
<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 인 동안은 타이머가 대신 누른다.
// 한 겹: 박수마다 질량-감쇠 물리 스파크 버스트 — 미니 별이 아이콘 중심에서 흩어진다 (Vỗ tay 리듬에 맞춰 빠르고 짧게).
// 각 파티클은 초기 속도(위쪽 좁은 부채꼴·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-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 · Đếm vỗ tay — Diện tích đo được 0.886% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Bấm liên tục thì animation chỉ chạy lại chứ không cộng dồn chồng lên nhau.
@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); }
}
07Góc bookmark gập mở
Bạn thấy gì
Check vào thì ruy băng bookmark tô đầy dần từ trên xuống bằng scaleY, đồng thời một tam giác nhỏ ở góc trên phải xoay từ -90 độ về 0 độ quanh chính góc đó — nhờ transform-origin đặt đúng vào góc chứ không phải tâm — nên trông như một mẩu giấy vừa được gập mở ra. Ngay lúc nếp gập khép lại, sáu mảnh giấy hình thoi bay ra từ đúng góc ấy, mỗi mảnh mang khối lượng riêng (0.7–1.6) nên mảnh nặng bay xa hơn nhờ quán tính trước khi lực cản (-k/m·v) và trọng lực kéo nó dừng lại.

<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">Lưu</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) 물리 파티클 — Lưu 체크 순간 접힌 귀(오른쪽 위 모서리)에서
// 다이아몬드 종잇조각이 흩어진다. 각 조각은 질량·감쇠(-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: 실제 클릭 — 체크되는 순간(Lưu 순간) 발사
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 · Góc bookmark gập mở — Diện tích đo được 0.758% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
.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, .35);
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);
}
08Ngón cái lắc lư
Chuyện gì diễn ra trên màn hình
Trước khi check, icon ngón cái nằm im dưới filter grayscale. Ngay khoảnh khắc icon đáp xuống, một cụm tia lửa gồm bốn sao mini và hai chấm nảy ra từ icon — khối lượng nhẹ cùng lực cản nhanh (k=4.0) làm nó lóe lên rồi tắt ngay, tạo cảm giác cú chạm thật có lực.
<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">Hữu ích</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 · Ngón cái lắc lư — Diện tích đo được 1.192% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Check vào thì màu sống lại đồng thời icon xoay từ -20 độ qua 5 độ rồi mới về hẳn 0 độ, còn scale phồng lên rồi xẹp theo đúng ba mốc đó để giống một cái vẫy tay thật hơn là một vòng xoay đều.
.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); }
}
09Chọn emoji cảm xúc
Chuyển động thế nào
Bốn emoji nằm trong một nhóm radio. Chọn xong thì năm trái tim mini bung lên từ chính emoji đó, và vì mỗi tim có khối lượng khác nhau (0.6–1.4) nên con nhẹ rơi xuống sớm còn con nặng lướt tiếp nhờ quán tính — tất cả được cân bằng bằng cùng một vòng tích phân requestAnimationFrame.

<main class="stage">
<fieldset class="react is-demo">
<legend class="react__legend">Chọn cảm xúc</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="Trái tim">❤️<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="Cười">😂<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="Thích">👍<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="Ngạc nhiên">😮<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;
}
}// 한 겹: 미니 Trái tim 물리 파티클 — 이모지를 고르는 순간 그 이모지 라벨에서 하트가 흩어진다.
// 각 하트는 질량·감쇠(-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 · Chọn emoji cảm xúc — Diện tích đo được 4.024% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Selector :has() kiểm tra cả nhóm có radio nào được chọn chưa — nếu có, mọi emoji đồng loạt mờ và nhỏ lại — rồi riêng emoji vừa chọn được tô đậm bằng rule sibling + ngay sau input đó, phóng to hẳn lên so với phần còn lại.
.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);
}
Ở đâu dễ vỡ — cái bẫy custom property không chịu chuyển động mượt
Custom property cần khai báo kiểu
Custom property CSS (giá trị bắt đầu bằng --, như --rating ở mục 05) không tự được trình duyệt nội suy khi đặt thẳng nó vào keyframes — thiếu khai báo kiểu, trình duyệt chỉ biết nhảy thẳng từ giá trị đầu sang giá trị cuối chứ không tính các bước ở giữa. Bản dựng đầu tiên của mục 05 từng animate trực tiếp --fill từ 0% lên var(--pct), nên chuyển động nhảy thẳng từ đầu sang cuối chỉ trong đúng một khung hình duy nhất — sao bật sáng cái rụp thay vì chảy dần lên. Trang @property của MDN giải thích custom property cần đăng ký kiểu (<percentage> hoặc <length> chẳng hạn) thì trình duyệt mới nội suy được nó. Không muốn đăng ký kiểu thì đường vòng là animate một thuộc tính chuẩn vốn đã nội suy sẵn — ở đây là width — và gán cho nó calc(var(--rating) / 5 * 100%); mục 05 hiện dùng đúng cách này nên phần chảy lên chia đều ra nhiều khung hình chứ không dừng ở một cú nhảy.
Vùng hỗ trợ selector has
Bẫy thứ hai nằm ở mục 09: nó dựa vào :has() để biết cả nhóm radio có lựa chọn nào chưa. Theo bảng hỗ trợ trên caniuse, :has() mới chạy từ Chrome/Edge 105+, Safari 15.4+ và Firefox 121+ — dưới các mốc đó, cú mờ đi đồng loạt của mục 09 sẽ không xảy ra, chỉ còn emoji được chọn tự phóng to mà thôi. Mục 04 không đụng đến :has() — nó chỉ dùng sibling selector ~, vốn chạy được trên trình duyệt từ rất lâu trước đó, nên phần tô sao của mục này không bị ảnh hưởng. Coi :has() như một lớp tô điểm thêm chứ đừng để cả tính năng phụ thuộc vào nó. Chín file vanilla trong bản nén không có đoạn script tự đảo trạng thái hai giây một lần như lưới demo phía trên, mật khẩu để mở là 8ayu345c, gõ đúng y như nó xuất hiện ở đây.
Khả năng tiếp cận
Luồng focus của input ẩn
Bảy mục dùng checkbox hoặc radio ẩn (01, 02, 03, 04, 07, 08, 09) vẫn nằm trong luồng focus bình thường vì chỉ bị ẩn bằng kỹ thuật thu nhỏ còn 1px, nên phím Tab và phím cách vẫn đổi được trạng thái mà không cần chuột. Mục 04 bọc năm radio trong fieldset cùng legend ẩn kiểu sr-only để trình đọc màn hình biết đây là một nhóm đánh giá, và mỗi label sao còn có aria-label riêng đọc thành "N điểm". Mục 06 dùng aria-live="polite" để số vỗ tay mới được đọc ngay khi tăng, còn mục 09 gắn aria-label cho từng emoji để tên cảm xúc được đọc thay vì chỉ đọc ký tự emoji thô. Dưới prefers-reduced-motion: reduce, tất cả animation và transition rút còn gần như tức thời — trạng thái cuối hiện ra ngay, không phần nào bị mất hẳn khả năng đọc hay bấm. Trang prefers-reduced-motion của MDN ghi rõ mức hỗ trợ trình duyệt cho media query này.
Thay bằng nút aria-pressed
Muốn đổi checkbox thành <button aria-pressed="true"> cũng được, miễn script tự cập nhật aria-pressed mỗi lần trạng thái đổi:
<fieldset>
<legend>Đánh giá</legend>
<button type="button" class="rating__star" aria-pressed="false">★</button>
</fieldset>
Xem thêm các bài CSS khác trong chuyên mục CSS, còn trang giới thiệu nói rõ vì sao mỗi bài ở đây đi kèm một file zip có mật khẩu thay vì dán thẳng code.
FAQ
Checkbox ẩn để đổi giao diện có phải mẹo hack không?
Không hẳn là hack — đây là cách làm được W3C khuyến nghị khi cần vẽ lại control gốc: input thật vẫn ở đó và vẫn nhận focus, chỉ có style bên ngoài thay hẳn diện mạo mặc định của trình duyệt. Cách này khác hẳn display: none, vốn gỡ input ra khỏi cả luồng focus lẫn cây accessibility.
Chín hiệu ứng này có animate trực tiếp màu nền hay không?
Có một ngoại lệ: mục 03 animate thẳng background để đổi màu trái tim khi checkbox bật. Mục 08 né hẳn việc đổi màu nền, chuyển màu qua filter thay vì background. Sáu mục còn lại chỉ dùng opacity, transform hoặc width — đúng nhóm thuộc tính trình duyệt chỉ cần vẽ lại lớp compositor mà không phải tính lại layout, nên khung hình mượt hơn animate màu trực tiếp; mục 03 là chỗ đánh đổi hiệu năng nhỏ để đổi màu tức thì.
Bản react/ trong zip quản lý trạng thái thế nào?
Mỗi component giữ trạng thái checked hoặc rating bằng useState và đẩy giá trị lên cha qua onChange. Riêng mục 05 không nhận input nào cả, nó là component chỉ đọc, nhận đúng một prop rating rồi tự tính phần trăm tô như bản vanilla.









