デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
いいねを押すと、ハート全体がわずかに縮んでから弾み、そのあいだに中の塗りが膨らんで満ちます。線もいいねの色に変わり、同じ色の輪が広がって消え、縁から12個の粒が飛び散ります。もう一度押すと、塗りが縮んで線画だけに戻ります。
塗りは0.35秒の back.out(2.5) で膨らみ、一度行き過ぎてから収まります。この行き過ぎがあるので、中身が詰まるような手応えが出ます。全体の弾みは0.6秒の elastic.out。粒は等間隔の向きから少しずつずらし、飛ぶ距離にもばらつきを持たせました。
塗りは SVG の中の図形なので、何も指定せずに拡大すると、中心ではなく左上を起点に膨らみ、ハートが斜めから現れます。transformOrigin を「50% 50%」にして、塗り自身の中心から膨らませています。
いいねの動きはタイムラインにまとめ、押すたびに restart で頭から再生します。取り消しのほうは、塗りを縮めて線の色を戻す別の短い動きです。厄介なのは、弾けている途中で取り消されたとき。両方の動きが塗りの大きさを取り合い、取り消したはずの塗りがあとからまた膨らんでしまいます。取り消す前に、いいねの動きを最後まで進めて止めておくことにしました。
押されているかどうかは aria-pressed で伝えます。いいねの色はローズカラーを暗くした濃い色で、粒はそこからローズカラー、グレージュ、ラベンダー、セージへと順に色を回しています。
コード
<!-- その場で状態が変わるので button。状態は aria-pressed、名前は aria-label が持つ -->
<button class="like" type="button" aria-pressed="false" aria-label="いいね">
<!-- 輪と粒は飾りなので読み上げから外す。粒は script.js が入れる -->
<span class="like__ring" aria-hidden="true"></span>
<span class="like__burst" aria-hidden="true"></span>
<svg class="like__heart" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<!-- 同じ形の塗りと線画を重ねる。塗りを中心から膨らませて満たす -->
<path class="like__fill" d="M12 20.6C12 20.6 3 15.2 3 8.9C3 6.1 5.1 4 7.7 4C9.5 4 11.1 5.1 12 6.6C12.9 5.1 14.5 4 16.3 4C18.9 4 21 6.1 21 8.9C21 15.2 12 20.6 12 20.6Z" />
<path class="like__outline" d="M12 20.6C12 20.6 3 15.2 3 8.9C3 6.1 5.1 4 7.7 4C9.5 4 11.1 5.1 12 6.6C12.9 5.1 14.5 4 16.3 4C18.9 4 21 6.1 21 8.9C21 15.2 12 20.6 12 20.6Z" />
</svg>
</button>
.like {
--size: 24px; /* ハートの大きさ */
--ink: #262626; /* 線画の色 */
--liked: #a05a54; /* いいねの色(濃いローズカラー) */
--dot: 3px; /* 飛び散る粒の大きさ */
position: relative;
display: inline-grid;
place-items: center;
width: 48px; /* 押せる範囲。アイコンより広くとる */
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
/* 輪・粒・ハートをグリッドの同じセルに重ね、どれも中央に置く */
.like > * {
grid-area: 1 / 1;
}
.like__heart {
width: var(--size);
height: var(--size);
overflow: visible;
}
.like__fill {
fill: var(--liked);
}
/* 24 グリッドで線幅 2(24px で描けば 2px)。線端と角は丸 */
.like__outline {
fill: none;
stroke: var(--ink);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* 押した瞬間に広がって消える輪。ふだんは見せない */
.like__ring {
width: calc(var(--size) * 0.92);
height: calc(var(--size) * 0.92);
box-sizing: border-box;
border: 2px solid var(--liked);
border-radius: 50%;
opacity: 0;
}
/* 粒が飛び出す起点(中央の点) */
.like__burst {
position: relative;
width: 0;
height: 0;
}
.like__dot {
position: absolute;
left: calc(var(--dot) / -2);
top: calc(var(--dot) / -2);
width: var(--dot);
height: var(--dot);
border-radius: 50%;
}
/* 粒の色は 5 色を順に回す(いいねの色・ローズカラー・グレージュ・ラベンダー・セージ) */
.like__dot:nth-child(5n + 1) { background: var(--liked); }
.like__dot:nth-child(5n + 2) { background: #b27771; }
.like__dot:nth-child(5n + 3) { background: #a28767; }
.like__dot:nth-child(5n + 4) { background: #877fa1; }
.like__dot:nth-child(5n) { background: #739374; }
.like:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const COUNT = 12; // 飛び散る粒の数
document.querySelectorAll(".like").forEach((button) => {
const heart = button.querySelector(".like__heart");
const fill = button.querySelector(".like__fill");
const outline = button.querySelector(".like__outline");
const ring = button.querySelector(".like__ring");
const burst = button.querySelector(".like__burst");
const styles = getComputedStyle(button);
// 色と大きさは CSS で決める。GSAP は var(--name) のままでは補間できないので、値にして渡す
const ink = styles.getPropertyValue("--ink").trim();
const likedColor = styles.getPropertyValue("--liked").trim();
const size = parseFloat(styles.getPropertyValue("--size"));
const like = gsap
.timeline({ paused: true })
// 押した瞬間に縮み、いいねの色に満ちながら弾んで戻る
.fromTo(heart, { scale: 1 }, { scale: 0.8, duration: 0.1, ease: "power2.in" }, 0)
.fromTo(heart, { scale: 0.8 }, { scale: 1, duration: 0.6, ease: "elastic.out(1.2, 0.4)", immediateRender: false }, 0.1)
// SVG の中の図形は、原点を中央にしないと左上から膨らむ
.fromTo(fill, { scale: 0, transformOrigin: "50% 50%" }, { scale: 1, duration: 0.35, ease: "back.out(2.5)" }, 0.1)
.fromTo(outline, { stroke: ink }, { stroke: likedColor, duration: 0.12, ease: "none" }, 0.1)
// 輪が広がりながら消える
.fromTo(ring, { scale: 0.3, opacity: 1 }, { scale: 1.15, duration: 0.5, ease: "power2.out", immediateRender: false }, 0.1)
.to(ring, { opacity: 0, duration: 0.35, ease: "power1.in" }, 0.25);
// 粒は向きを等間隔から少しずらし、飛ぶ距離と大きさを散らす
for (let i = 0; i < COUNT; i++) {
const dot = document.createElement("span");
dot.className = "like__dot";
burst.append(dot);
const angle = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.35;
const [x, y] = [Math.cos(angle), Math.sin(angle)];
const far = size * (0.62 + Math.random() * 0.2);
like
.fromTo(dot, { scale: 0 }, { scale: 0.7 + Math.random() * 0.6, duration: 0.1, ease: "power1.out" }, 0.12)
.fromTo(dot, { x: x * size * 0.3, y: y * size * 0.3 }, { x: x * far, y: y * far, duration: 0.6, ease: "power3.out" }, 0.12)
.to(dot, { scale: 0, duration: 0.4, ease: "power2.in" }, 0.32);
}
// 動きを減らす設定では、ほぼ一瞬で切り替える
if (reduceMotion) like.timeScale(20);
// 最初からいいね済み(保存した状態を読み込んだときなど)は、動かさずに終わりの状態にする
if (button.getAttribute("aria-pressed") === "true") like.progress(1);
let undo = null; // 取り消しの動き
button.addEventListener("click", () => {
const liked = button.getAttribute("aria-pressed") !== "true";
button.setAttribute("aria-pressed", String(liked));
undo?.kill();
if (liked) {
like.restart(); // 押すたびに頭から
return;
}
// 弾けている途中なら終わらせてから戻す(塗りの大きさを 2 本の動きで取り合わない)
like.progress(1).pause();
undo = gsap
.timeline()
.to(heart, { scale: 0.85, duration: 0.1, ease: "power2.in" })
.to(heart, { scale: 1, duration: 0.3, ease: "power2.out" })
.to(fill, { scale: 0, duration: 0.25, ease: "power2.in" }, 0)
.to(outline, { stroke: ink, duration: 0.2, ease: "none" }, 0)
.timeScale(reduceMotion ? 20 : 1);
});
});