07ハートパルス
ハートを押すと塗りが変わると同時に scale が一瞬大きくなり弾んで戻る。
使いどころ投稿・商品のいいね・お気に入りボタン
scale 1→1.3→1220msfill 크로스페이드
<main class="stage">
<button class="iconbtn is-demo" type="button" aria-pressed="false" aria-label="いいね">
<span class="iconbtn__stack">
<svg class="ic ic--outline" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z" fill="none" stroke="currentColor" stroke-width="1.7"/>
</svg>
<svg class="ic ic--fill" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z"/>
</svg>
<svg class="fx" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle class="fx__spark" r=".9" cx="12" cy="12"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:0px;--dy:-2.6px"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:2.4px;--dy:-1.2px"/>
<circle class="fx__bit" r=".5" cx="12" cy="6" style="--dx:-2.4px;--dy:-1.2px"/>
<circle class="fx__bit" r=".45" cx="4" cy="8" style="--dx:-2.6px;--dy:-1px"/>
<circle class="fx__bit" r=".45" cx="20" cy="8" style="--dx:2.6px;--dy:-1px"/>
<circle class="fx__bit" r=".45" cx="12" cy="20" style="--dx:0px;--dy:2.6px"/>
</svg>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 튕기는 시간
$easing: $ease-pop;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.iconbtn {
border: 0; background: none; cursor: pointer; color: $color;
border-radius: $r-sm;
width: clamp(52px, 14vw, 64px); height: clamp(52px, 14vw, 64px);
display: grid; place-items: center;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.iconbtn__stack { display: grid; place-items: center; width: 100%; height: 100%; }
.ic {
grid-area: 1 / 1;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--outline { opacity: 1; }
.ic--fill { fill: $color; opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--outline { opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill { opacity: 1; transform: scale(1); }
.iconbtn.is-demo .ic--outline { animation: outline-loop $dur-loop $easing infinite; }
.iconbtn.is-demo .ic--fill { animation: fill-loop $dur-loop $easing infinite; }
@keyframes outline-loop { 0%, 15% { opacity: 1; transform: scale(1); } 30% { transform: scale(1.3); } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; transform: scale(1); } }
@keyframes fill-loop { 0%, 15% { opacity: 0; transform: scale(1); } 30% { transform: scale(1.3); } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; transform: scale(1); } }
// ── 한 겹: 채워지는 순간 가장자리 입자 + 중앙 스파크 ─────────
// 아이콘과 같은 칸에 겹쳐 놓은 효과 레이어 — 레이아웃에는 영향이 없다
.fx {
grid-area: 1 / 1;
fill: currentColor;
pointer-events: none;
}
// 중앙 스파크 — 하트가 차오르는 순간 밝게 점멸
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px $color); // 미광
}
@keyframes spark-pop {
0% { opacity: .95; transform: scale(1.8); }
60% { opacity: .4; transform: scale(.8); }
100% { opacity: 0; transform: scale(.3); }
}
// 하트 가장자리에서 바깥으로 흩어지는 입자 — JS 가 좌표·방향을 계산해 넣는다
.fx__bit {
opacity: 0;
transform: translate(0, 0);
}
@keyframes bit-fly {
0% { opacity: .9; transform: translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.3); }
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--outline, .iconbtn.is-demo .ic--fill { animation: none; }
.ic { transition: none; }
.fx { 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);
}
.iconbtn {
border: 0;
background: none;
cursor: pointer;
color: #fff7e6;
border-radius: 8px;
width: clamp(52px, 14vw, 64px);
height: clamp(52px, 14vw, 64px);
display: grid;
place-items: center;
-webkit-tap-highlight-color: transparent;
}
.iconbtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.iconbtn__stack {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.ic {
grid-area: 1/1;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
transform-box: fill-box;
transform-origin: 50% 50%;
}
.ic--outline {
opacity: 1;
}
.ic--fill {
fill: #fff7e6;
opacity: 0;
}
.iconbtn[aria-pressed=true] .ic--outline {
opacity: 0;
}
.iconbtn[aria-pressed=true] .ic--fill {
opacity: 1;
transform: scale(1);
}
.iconbtn.is-demo .ic--outline {
animation: outline-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.iconbtn.is-demo .ic--fill {
animation: fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes outline-loop {
0%, 15% {
opacity: 1;
transform: scale(1);
}
30% {
transform: scale(1.3);
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fill-loop {
0%, 15% {
opacity: 0;
transform: scale(1);
}
30% {
transform: scale(1.3);
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
transform: scale(1);
}
}
.fx {
grid-area: 1/1;
fill: currentColor;
pointer-events: none;
}
.fx__spark {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 50%;
filter: drop-shadow(0 0 1px #fff7e6);
}
@keyframes spark-pop {
0% {
opacity: 0.95;
transform: scale(1.8);
}
60% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.fx__bit {
opacity: 0;
transform: translate(0, 0);
}
@keyframes bit-fly {
0% {
opacity: 0.9;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx), var(--dy)) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.iconbtn.is-demo .ic--outline, .iconbtn.is-demo .ic--fill {
animation: none;
}
.ic {
transition: none;
}
.fx {
display: none;
}
}document.querySelectorAll(".iconbtn").forEach(function (b) {
b.addEventListener("click", function () {
b.classList.remove("is-demo");
var on = b.getAttribute("aria-pressed") === "true";
b.setAttribute("aria-pressed", String(!on));
});
});
// ── 한 겹: 하트 채워지는 순간 가장자리 입자 폭발 + 중앙 스파크 ──────
// 펄스는 짧은 사건이다 — 01 처럼 펜 끝을 쫓는 대신, '채움 하트가 나타나는 프레임'을
// getComputedStyle opacity 실측으로 감지해 그 순간 하트 가장자리에서 입자를 터뜨린다.
// 입자 좌표는 fill 하트 path 를 getTotalLength/getPointAtLength 로 6 점 샘플링해 얻고,
// 방향은 도심(12,12)에서 바깥으로 — 하트가 '탁' 차오르는 물성을 흉내 낸다.
(function () {
var btn = document.querySelector('.iconbtn');
if (!btn) return;
var fx = btn.querySelector('.fx');
var fill = btn.querySelector('.ic--fill path');
if (!fx || !fill || !fill.getTotalLength) return;
var spark = fx.querySelector('.fx__spark');
var bits = [].slice.call(fx.querySelectorAll('.fx__bit'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !spark) { fx.style.display = 'none'; return; }
var L = fill.getTotalLength();
var CX = 12, CY = 12;
// 입자 하나를 하트 가장자리 임의 점에 재배치하고 바깥 방향으로 흩어지게 한다
function seed(b) {
var p = fill.getPointAtLength(Math.random() * L);
b.setAttribute('cx', p.x);
b.setAttribute('cy', p.y);
var dx = p.x - CX, dy = p.y - CY;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var sp = .45 + Math.random() * .55;
b.style.setProperty('--dx', (dx / len * sp * 6.5).toFixed(2) + 'px');
b.style.setProperty('--dy', (dy / len * sp * 6.5).toFixed(2) + 'px');
b.style.animation = 'none';
void b.getBoundingClientRect();
b.style.animation = '';
b.style.animationName = 'bit-fly';
}
function burst() {
spark.style.animation = 'none';
void spark.getBoundingClientRect();
spark.style.animation = '';
spark.style.animationName = 'spark-pop';
bits.forEach(function (b) { seed(b); });
}
var prevO = null, lastBurst = 0;
function frame(t) {
// 채움 하트가 나타나는 프레임 = opacity 가 0→1 로 오르는 중.
// 데모 루프의 상승 구간은 500ms — 문턱을 그보다 길게 잡아 한 번만 터진다
var o = parseFloat(getComputedStyle(fill.closest('.ic--fill')).opacity);
if (prevO !== null && o - prevO > .02 && t - lastBurst > 600) {
lastBurst = t;
burst();
}
prevO = o;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeartPulse.module.scss";
export type HeartPulseProps = {
/** 튕기는 시간, ms */
duration?: number;
easing?: string;
color?: string;
defaultLiked?: boolean;
onChange?: (liked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HEART = "M12 20.2s-7.3-4.5-9.5-8.9C.9 7.9 2.6 4.6 5.9 4.1c2-.3 3.8.6 4.9 2.2 1-1.6 2.9-2.5 4.9-2.2 3.3.5 5 3.8 3.4 7.2-2.2 4.4-9.1 8.9-9.1 8.9z";
const BIT_COUNT = 6;
const CX = 12, CY = 12;
export default function HeartPulse({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
defaultLiked = false,
onChange,
}: HeartPulseProps) {
const [liked, setLiked] = useState(defaultLiked);
const pathRef = useRef<SVGPathElement>(null);
const sparkRef = useRef<SVGCircleElement>(null);
const bitRefs = useRef<Array<SVGCircleElement | null>>([]) as unknown as { current: Array<SVGCircleElement | null> };
const burstRef = useRef<(() => void) | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 한 겹: 좋아요 순간 하트 가장자리 입자 폭발 + 중앙 스파크 (vanilla 데모와 동일 계산).
// 하트 path 를 getTotalLength/getPointAtLength 로 샘플링해 입자를 가장자리에 뿌리고
// 도심에서 바깥 방향으로 흩어지게 한다.
useEffect(() => {
const heart = pathRef.current, spark = sparkRef.current;
if (!heart || !spark || !heart.getTotalLength) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bits = bitRefs.current.filter(Boolean) as SVGCircleElement[];
const L = heart.getTotalLength();
const restart = (el: SVGElement) => {
el.style.animation = "none";
void el.getBoundingClientRect();
el.style.animation = "";
};
burstRef.current = () => {
restart(spark);
bits.forEach((b) => {
const p = heart.getPointAtLength(Math.random() * L);
b.setAttribute("cx", p.x.toFixed(2));
b.setAttribute("cy", p.y.toFixed(2));
const dx = p.x - CX, dy = p.y - CY;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const sp = 0.45 + Math.random() * 0.55;
b.style.setProperty("--dx", ((dx / len) * sp * 6.5).toFixed(2) + "px");
b.style.setProperty("--dy", ((dy / len) * sp * 6.5).toFixed(2) + "px");
restart(b);
});
};
return () => { burstRef.current = null; };
}, []);
return (
<button
type="button"
className={styles.iconbtn}
style={vars}
aria-pressed={liked}
aria-label="좋아요"
onClick={() => {
setLiked((v) => !v);
burstRef.current?.();
onChange?.(!liked);
}}
>
<span className={styles.iconbtn__stack}>
<svg className={`${styles.ic} ${liked ? styles["ic--hidden"] : ""}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d={HEART} fill="none" stroke="currentColor" strokeWidth={1.7} />
</svg>
<svg className={`${styles.ic} ${styles["ic--fill"]} ${liked ? "" : styles["ic--hidden"]}`} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
{/* pathRef — 입자 좌표 샘플링용 원본 경로. getTotalLength 는 경로 기하학만 보므로
스케일 애니메이션·투명도와 무관하게 항상 같은 하트 좌표를 돌려준다 */}
<path ref={pathRef} d={HEART} />
</svg>
<svg className={styles.fx} viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle ref={sparkRef} className={styles["fx__spark"]} r={0.9} cx={12} cy={12} />
{Array.from({ length: BIT_COUNT }, (_, i) => (
<circle
key={i}
ref={(el: SVGCircleElement | null) => { bitRefs.current[i] = el; }}
className={styles["fx__bit"]}
r={i < 3 ? 0.5 : 0.45}
cx={12}
cy={6}
style={{ "--dx": "0px", "--dy": "-2.6px" } as CSSProperties}
/>
))}
</svg>
</span>
</button>
);
}07 ハートパルス — 計測 累積面積 0.338% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS アイコン アニメーション 9選 — 一行でコピペOK


