デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
リボンの形の塗りと線画を重ねて描き、塗りだけを clipPath で切ります。切り抜きの中の「液面」の形を下から持ち上げると、リボンの中が下から満ちていきます。液面の上端は満ちる途中だけ小さく波打たせ、満ちきると平らに戻します。満ちたところで、先端の切れ込みが左右に小さく揺れて止まります。もう一度押すと、中身が下へ抜けて線画に戻ります。
押した瞬間に 0.1 秒で 0.88 倍まで縮み、0.45 秒の back.out(3) で戻ります。そのあいだに 0.55 秒の power2.inOut で満たし、押してから 0.5 秒後(満ちきる直前)から 1.1 秒かけて揺らします。揺れは、2.5 往復しながら小さくなる正弦を自前のイージングにして、先端のずれ 0 → 1.3(24 のグリッドで)のトゥイーンにかけます。0 で始まって 0 で終わるので、止まるときに形が飛びません。
揺れは要素を回さず、形そのもの(d)を書き換えて作ります。上の角は動かさず、下の 3 点(左右の先端と切れ込み)だけを横へずらし、左右の辺を下のほうで曲がる曲線にします。辺をまっすぐ傾けると、布ではなく板が傾いたように見えます。切れ込みは先端の 1.35 倍振って、しなりを出します。塗り・線画・液面は、3 つの値(満ち具合・先端のずれ・縮み)から 1 つの関数で描きます。
切り抜きは id で指すので、同じページにボタンを複数置くと、後のボタンが先のボタンの液面を使ってしまいます。id をボタンごとに付け直します。途中で押し直したときは、動いているタイムラインを止めてから今の値の続きで次の動きを作るので、形が飛びません。解除では揺れも 0.2 秒で止めます。
押した状態は aria-pressed で伝え、名前は「記事を保存」のまま変えません。動きを減らす設定では、揺らさずにほぼ一瞬で切り替えます。このデモは 24px のアイコンを 9 倍に拡大して見せています。弾けて粒を飛ばすいいねの動きに比べて、満ちて揺れるだけの静かな手応えなので、記事やレシピの保存、あとで読むリスト、求人や物件のお気に入りのように、一覧に何個も並ぶ所に向いています。
コード
<!-- その場で状態が変わるので button。状態は aria-pressed、名前は aria-label が持つ -->
<button class="bookmark" type="button" aria-pressed="false" aria-label="記事を保存">
<svg class="bookmark__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<defs>
<!-- 液面。script.js が形を書き換えて下から満たす(id は部品ごとに付け直す) -->
<clipPath id="bookmark-level"><path class="bookmark__level" d="M2 21.6H22V24H2Z" /></clipPath>
</defs>
<g class="bookmark__body">
<!-- 同じ形の塗りと線画を重ね、塗りだけを液面で切る -->
<path class="bookmark__fill" clip-path="url(#bookmark-level)" d="M5 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2Q19 14 19 21L12 16L5 21Q5 14 5 5Z" />
<path class="bookmark__outline" d="M5 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2Q19 14 19 21L12 16L5 21Q5 14 5 5Z" />
</g>
</svg>
</button>
.bookmark {
--size: 24px; /* アイコンの大きさ */
--ink: #eeece6; /* 線と塗りの色 */
display: inline-grid;
place-items: center;
width: 48px; /* 押せる範囲。アイコンより広くとる */
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink);
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.bookmark__icon {
width: var(--size);
height: var(--size);
overflow: visible;
}
.bookmark__fill {
fill: currentColor;
}
/* 24 グリッドで線幅 2(24px で描けば 2px)。線端と角は丸 */
.bookmark__outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.bookmark:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
import { gsap } from "gsap";
const SWAY = 1.3; // 先端を横へずらす最大の量(24 のグリッドで)
const WAVE = 0.7; // 液面の波の高さ。満ちきると 0 になる
// 減衰する正弦。0 で始まり、2.5 往復しながら 0 に戻る(sway 0 → SWAY のトゥイーンにかける)
const swayEase = (p) => Math.sin(p * Math.PI * 5) * (1 - p) ** 2;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => Math.round(v * 1000) / 1000;
// リボンの形。上の角は動かさず、下の 3 点(先端と切れ込み)だけを横へずらす。切れ込みは少し大きく振れる
const ribbon = (s) =>
`M5 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2Q19 14 ${f(19 + s)} 21L${f(12 + s * 1.35)} 16L${f(5 + s)} 21Q5 14 5 5Z`;
// 液面から下を覆う形。level 0 で先端より下、1 で上端より上。途中だけ上端が波打つ
function surface(level) {
const y = 21.6 - level * 20;
const a = WAVE * Math.sin(Math.PI * level);
const points = [];
for (let x = 2; x <= 22; x++) points.push(`${x} ${f(y + a * Math.sin(((x - 2) / 10) * Math.PI + level * Math.PI * 3))}`);
return `M${points.join("L")}L22 24L2 24Z`;
}
document.querySelectorAll(".bookmark").forEach((button, i) => {
const clip = button.querySelector("clipPath");
const level = button.querySelector(".bookmark__level");
const body = button.querySelector(".bookmark__body");
const shapes = button.querySelectorAll(".bookmark__fill, .bookmark__outline");
// 同じページに何個あっても液面を取り違えないように、clipPath の id を付け直す
clip.id = `bookmark-level-${i + 1}`;
button.querySelector(".bookmark__fill").setAttribute("clip-path", `url(#${clip.id})`);
// 見た目はこの 3 つの値から描く(満ち具合・先端のずれ・押したときの縮み)
const state = { level: 0, sway: 0, press: 1 };
const draw = () => {
const d = ribbon(state.sway);
shapes.forEach((shape) => shape.setAttribute("d", d));
level.setAttribute("d", surface(state.level));
body.setAttribute("transform", `translate(12 12) scale(${f(state.press)}) translate(-12 -12)`);
};
// 最初から保存済み(保存した状態を読み込んだときなど)は、動かさずに満ちた形にする
if (button.getAttribute("aria-pressed") === "true") state.level = 1;
draw();
let anim = null;
button.addEventListener("click", () => {
const saved = button.getAttribute("aria-pressed") !== "true";
button.setAttribute("aria-pressed", String(saved));
anim?.kill(); // 途中で押し直しても、今の値から次の動きへつなぐ
anim = gsap.timeline({ onUpdate: draw });
if (saved) {
// 押した瞬間に縮んで弾み、下から満ち、満ちたところで先端が揺れて止まる
anim
.to(state, { press: 0.88, duration: 0.1, ease: "power2.in" }, 0)
.to(state, { press: 1, duration: 0.45, ease: "back.out(3)" }, 0.1)
.to(state, { level: 1, duration: 0.55, ease: "power2.inOut" }, 0.06)
.fromTo(state, { sway: 0 }, { sway: SWAY, duration: 1.1, ease: swayEase, immediateRender: false }, 0.5);
} else {
// 解除: 中身が下へ抜ける。揺れや縮みが残っていたら止める
anim
.to(state, { press: 1, duration: 0.2, ease: "power2.out" }, 0)
.to(state, { level: 0, duration: 0.35, ease: "power2.in" }, 0)
.to(state, { sway: 0, duration: 0.2, ease: "power2.out" }, 0);
}
// 動きを減らす設定では、揺らさずにほぼ一瞬で切り替える
if (reduceMotion) anim.progress(1);
});
});