03Like count sync
A few lines of JS add or subtract 1 from the neighboring number on every toggle, and the changed digit pops with a brief scale.
Best forCards that show a like count, comment upvote buttons
JS 카운트scale pop:checked

<main class="stage">
<label class="like is-demo">
<input type="checkbox" class="like__input">
<span class="like__heart" aria-hidden="true"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__spark like__spark--star"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__spark like__spark--star"></span>
<span class="like__spark like__spark--heart"></span>
<span class="like__count"><span class="like__num" aria-live="polite">12</span></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 숫자가 튀는 시간
$easing: $ease-pop; // 팝 이징
$color: $subject-cream; // 하트·숫자 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.like {
position: relative; // 스파크 파티클의 좌표 기준 (레이아웃 불변)
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 10px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__heart {
display: inline-block;
width: 18px; height: 18px;
align-self: center;
background: rgba(255, 247, 230, .4);
clip-path: path("M9 17 1.5 10C-1 7.5 0 3.5 3 3 5.5 2.5 7.5 4 9 6 10.5 4 12.5 2.5 15 3 18 3.5 19 7.5 16.5 10Z");
transition: background $duration $easing, transform $duration $easing;
}
&__input:checked ~ &__heart { background: $color; transform: scale(1.15); }
// 한 겹: 물리 스파크 파티클(미니 하트·별) — 하트 위에서 발사. 좌표·페이드는 JS 가 계산한다.
&__spark {
position: absolute; top: 50%; left: 9px; // 하트(첫 아이템, 수직 중앙) 중심 — 50% 로 모든 글자 크기에서 추적
width: 10px; height: 10px; margin: -5px 0 0 -5px;
background: $color;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
&__spark--heart {
width: 12px; height: 12px; margin: -6px 0 0 -6px;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
&__spark--star {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
&__count { font-weight: 700; font-size: clamp(18px, 5vw, 22px); line-height: 1; font-family: inherit; color: $subject-cream; }
&__num.is-pop { animation: count-pop $duration $easing; }
&.is-demo &__heart { animation: heart-fill-loop $dur-loop $easing infinite; }
}
@keyframes count-pop {
0% { display: inline-block; transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
@keyframes heart-fill-loop {
0%, 20% { background: rgba(255, 247, 230, .4); transform: scale(1); }
35%, 60% { background: $color; transform: scale(1.15); }
80%, 100% { background: rgba(255, 247, 230, .4); transform: scale(1); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.like__heart { transition: background 1ms; }
.like__num.is-pop { animation: none !important; }
.like__spark { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
.like.is-demo .like__heart { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.like {
position: relative;
display: inline-flex;
align-items: baseline;
gap: clamp(8px, 2.6vw, 10px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.like__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.like__heart {
display: inline-block;
width: 18px;
height: 18px;
align-self: center;
background: rgba(255, 247, 230, 0.4);
clip-path: path("M9 17 1.5 10C-1 7.5 0 3.5 3 3 5.5 2.5 7.5 4 9 6 10.5 4 12.5 2.5 15 3 18 3.5 19 7.5 16.5 10Z");
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like__input:checked ~ .like__heart {
background: #fff7e6;
transform: scale(1.15);
}
.like__spark {
position: absolute;
top: 50%;
left: 9px;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
background: #fff7e6;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.like__spark--heart {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
}
.like__spark--star {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.like__count {
font-weight: 700;
font-size: clamp(18px, 5vw, 22px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.like__num.is-pop {
animation: count-pop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like.is-demo .like__heart {
animation: heart-fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes count-pop {
0% {
display: inline-block;
transform: scale(1);
}
50% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
@keyframes heart-fill-loop {
0%, 20% {
background: rgba(255, 247, 230, 0.4);
transform: scale(1);
}
35%, 60% {
background: #fff7e6;
transform: scale(1.15);
}
80%, 100% {
background: rgba(255, 247, 230, 0.4);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.like__heart {
transition: background 1ms;
}
.like__num.is-pop {
animation: none !important;
}
.like__spark {
display: none;
}
.like.is-demo .like__heart {
animation: none !important;
}
}// 체크 상태가 바뀔 때마다 숫자를 1 더하거나 빼고 scale 팝을 다시 튄다.
// 한 겹: 숫자가 오를 때 질량-감쇠 물리 스파크 버스트 — 미니 Heart·별이 Heart 위에서 흩어진다.
// 각 파티클은 초기 속도(방향·크기 랜덤)와 개별 질량을 갖고, 매 프레임 준암시 오일러 적분으로
// 감쇠(-k/m·v — 질량이 클수록 덜 감속)와 중력(질량 무관)을 받아 흩어졌다 멈춘다 (keyframes 아님).
(function () {
var w = document.querySelector(".like");
if (!w) return;
var inp = w.querySelector(".like__input"), num = w.querySelector(".like__num");
var sparks = [].slice.call(w.querySelectorAll(".like__spark"));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire() {
if (reduce || !sparks.length) return;
var now = performance.now();
for (var i = 0; i < sparks.length; i++) {
var m = .6 + Math.random() * .9; // 개별 질량 0.6~1.5
var ang = -Math.PI / 2 + (Math.random() - .5) * 2.4; // 위쪽 부채꼴 ±69°
var v0 = (100 + Math.random() * 90) * (1.15 - m * .2); // 무거울수록 느게 출발
sparks[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.6, g: 320, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 480,
s: .4 + m * .3, born: now + i * 26, life: 780 + Math.random() * 420 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
for (var i = 0; i < sparks.length; i++) {
var el = sparks[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop"); void num.offsetWidth; num.classList.add("is-pop");
if (inp.checked) fire(); // 숫자가 오를 때만 발사
}
inp.addEventListener("change", function () { w.classList.remove("is-demo"); sync(); });
setInterval(function () { if (!w.classList.contains("is-demo")) return; inp.checked = !inp.checked; sync(); }, 1400);
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./LikeCountSync.module.scss";
export type LikeCountSyncProps = {
duration?: number;
easing?: string;
color?: string;
baseCount?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 파티클 1개의 물성 — 질량·감쇠·중력으로 흩어졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
// 미니 하트 / 별 모양 교차 배치
const KINDS = ["heart", "star", "heart", "star", "heart"] as const;
/** 질량-감쇠 물리 초기 상태. 감쇠 가속도 = -k/m·v (질량이 클수록 덜 감속), 중력 = g (질량 무관). */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.9; // 개별 질량 0.6~1.5
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.4; // 위쪽 부채꼴 ±69°
const v0 = (100 + Math.random() * 90) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.6, g: 320,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 480,
s: 0.4 + m * 0.3,
born: now + i * 26, life: 800 + Math.random() * 420, // 순차 분사 + 수명 랜덤
};
}
export default function LikeCountSync({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6", baseCount = 12 }: LikeCountSyncProps) {
const [liked, setLiked] = useState(false);
const [pop, setPop] = useState(0);
const wrapRef = useRef<HTMLLabelElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fire = useCallback(() => setPop((p) => p + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!pop) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const els = Array.from(wrap.querySelectorAll<HTMLElement>(`.${styles.spark}`));
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [pop]);
const toggle = (checked: boolean) => { setLiked(checked); if (checked) fire(); };
return (
<label className={styles.like} style={vars} ref={wrapRef}>
<input type="checkbox" className={styles.input} checked={liked} onChange={(e: ChangeEvent<HTMLInputElement>) => toggle(e.target.checked)} />
<span className={`${styles.heart} ${liked ? styles.isLiked : ""}`} aria-hidden="true" />
{KINDS.map((kind, i) => (
<span key={i} className={`${styles.spark} ${kind === "heart" ? styles.sHeart : styles.sStar}`} aria-hidden="true" />
))}
<span className={styles.count}>
<span key={pop} className={styles.num}>{liked ? baseCount + 1 : baseCount}</span>
</span>
</label>
);
}03 Like count sync — Measured area 0.179% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Heart Animations — One Line, Copy-Paste
The post this demo lives in
9 CSS Heart Animations — One Line, Copy-PasteThese nine css heart animations turn a checkbox or a radio input into a like button, a star rating, or a reaction picker See all demos in this post


