デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ハートは、同じ形の塗りと線画を重ねて描きます。押すと、全体が一瞬縮んでから弾み、そのあいだに塗りが中心から膨らんで満ち、線もいいねの色に変わります。同時に同じ色の輪が広がって消え、12 個の粒が縁から外へ飛び散ります。もう一度押すと、塗りが縮んで線画に戻ります。
縮むのは 0.1 秒で 0.8 倍までです。戻りは 0.6 秒の elastic.out で弾ませ(振れ幅 1.2・周期 0.4)、塗りは 0.35 秒の back.out(2.5) で満たします。塗りが一度行き過ぎてから収まるので、中身が詰まるような手応えが出ます。輪は 0.3 倍から 1.15 倍まで広げながら薄くし、粒は等間隔の向きから ±10° ほどずらして、ハートの幅の 0.6〜0.8 倍の距離まで飛ばしながら縮めます。
塗りは SVG の中の図形なので、何も指定せずに拡大すると、中心ではなく左上を起点に膨らみ、ハートが斜めから現れます。transformOrigin を「50% 50%」にして、塗り自身の中心から膨らませます。
いいねは 1 本のタイムラインにまとめ、押すたびに頭から再生します(restart)。取り消しは別の短い動きで、塗りを縮めて線を文字の色に戻します。弾けている途中に取り消すと、2 つの動きが塗りの大きさを取り合い、取り消したあとで塗りがまた膨らみます。取り消す前に、いいねの動きを終わりまで進めて止めておきます。
押した状態は aria-pressed で伝えます。いいねの色は、くすみローズを暗くした濃い段にしています。粒は、その色からローズ・グレージュ・ラベンダーグレー・セージまでを順に回します。粒の数と色、飛ぶ距離、弾みの強さで印象が変わります。動きを減らす設定では、ほぼ一瞬で切り替えます。このデモは 24px のアイコンを 9 倍に拡大して見せています。SNS 風のいいね、お気に入り、ブックマークに向いています。
コード
<!-- その場で状態が変わるので 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);
});
});