デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
1px の線で縁取っただけの丸いボタンの中に SVG を置き、2 つの正弦波を足した水面の path を毎フレーム組み立てます。水面は手前と奥の 2 枚です。奥は位相をずらして少し高くし、灰色にして奥行きを出します。
カーソルが重なると水位を 0 から 1 へ 0.9 秒(power2.inOut)で上げ、離れると 0.8 秒で下げます。水位が動くたびに、波の高さを 0.25 秒で上げてから 1.2〜1.6 秒かけて静めるので、注いだときのうねりが出ます。満ちきると墨色の地に白い文字の見た目になり、水面は上端から高さの 20% の所に残って、高さの 4.5% ほどの静かな波が揺れ続けます。
文字は、地の上の墨色の文字と、手前の波の形の clipPath で切り抜いた白い文字を重ねています。そのため、水面が通るところで、字の途中から色が変わります。楕円を持ち上げて文字色を一度に切り替える塗りと違い、波の形そのものが変わるので、液体の表面と、字の塗り分けが見どころになります。
実際のサイトでは、波を gsap.ticker で常に流しておき、水位と波の高さだけを mouseenter と mouseleave でトゥイーンします。
2 つの波それぞれの長さ・速さ・強さ、静かなときと動かしたときの波の高さ、満ちたときの水面の位置、満ちる時間と引く時間で調整します。ドリンク・食品・水まわりのブランドの申し込みボタンや、遊び心のあるランディングページに向いています。
コード
<a class="liquid-button" href="/order/">
<svg class="liquid-button__liquid" aria-hidden="true">
<defs>
<!-- 手前の波と同じ形。白い文字はこの形で切り抜く(d は script.js が書く) -->
<clipPath id="liquid-button-surface">
<path class="liquid-button__clip" />
</clipPath>
</defs>
<path class="liquid-button__back" />
<path class="liquid-button__front" />
</svg>
<span class="liquid-button__label">いますぐ注文</span>
<span class="liquid-button__on" aria-hidden="true">いますぐ注文</span>
</a>
.liquid-button {
--ink: #262626; /* 文字と枠の色。満ちる液体の色にもなる */
--foam: #898989; /* 奥の波の色 */
--on-liquid: #ffffff; /* 液体の上の文字の色 */
position: relative;
display: inline-grid;
place-items: center;
height: 48px;
padding: 0 32px;
border-radius: 999px;
overflow: hidden;
color: var(--ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
}
/* 枠は液体の上に重ねる(border にすると、枠と液体の境目に地の色が細く透ける) */
.liquid-button::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--ink);
border-radius: inherit;
pointer-events: none;
}
/* ボタンと同じ大きさに広げる。viewBox を持たせないので、座標はそのまま px になる */
.liquid-button__liquid {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.liquid-button__front {
fill: var(--ink);
}
.liquid-button__back {
fill: var(--foam);
}
.liquid-button__label {
position: relative;
}
/* 白い文字。手前の波の形で切り抜く。
箱をボタン全体に広げないと、切り抜く形の座標が文字の箱からの px になってずれる */
.liquid-button__on {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--on-liquid);
clip-path: url(#liquid-button-surface);
user-select: none; /* 文字が 2 枚あるので、選ぶと同じ語が 2 度取られる */
}
.liquid-button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 8 : 1; // 動きを減らす設定では、ほぼ一瞬で満たす
// 水面の成分: 波長(ボタンの幅に対する比)・一巡で進む周期数(符号は向き)・強さ
const WAVES = [
{ length: 0.52, laps: 4, weight: 0.62 },
{ length: 0.95, laps: -2, weight: 0.38 },
];
const CYCLE = 4.6; // 波が一巡する秒数
const SEGMENTS = 56; // 水面の分割数。増やすとなめらかになるが、毎コマの計算が増える
const FULL = 0.2; // 満ちたときの水面の位置(高さに対する比。0 で縁まで)
const CALM = 0.045; // 静かなときの波の高さ(比)
const STIR = 0.12; // 注いだ直後の波の高さ(比)
const TAU = Math.PI * 2;
document.querySelectorAll(".liquid-button").forEach((button, index) => {
const back = button.querySelector(".liquid-button__back");
const front = button.querySelector(".liquid-button__front");
const clip = button.querySelector(".liquid-button__clip");
const on = button.querySelector(".liquid-button__on");
// 同じボタンがページに何個あってもよいよう、切り抜く形の id を 1 つずつ振り直す
clip.parentNode.id = `liquid-button-surface-${index}`;
on.style.clipPath = `url(#${clip.parentNode.id})`;
// 大きさは CSS が正。書体が読み込まれて幅が変わっても合うよう、変わるたびに測り直す
let w = button.offsetWidth;
let h = button.offsetHeight;
new ResizeObserver(() => { w = button.offsetWidth; h = button.offsetHeight; }).observe(button);
// 水面の形(下は閉じる)。base は水面の高さ、amp は波の高さ、shift は奥の波をずらす位相
const wave = (base, amp, time, shift) => {
let d = "";
for (let i = 0; i <= SEGMENTS; i++) {
const x = -4 + ((w + 8) * i) / SEGMENTS;
let y = base;
WAVES.forEach((v, k) => {
y += amp * v.weight * Math.sin((x / (w * v.length)) * TAU + (time / CYCLE) * v.laps * TAU + shift * (k + 1));
});
d += `${i ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`;
}
return `${d}L${w + 4},${h + 20}L-4,${h + 20}Z`;
};
const liquid = { level: 0, energy: 0 };
const draw = (time) => {
const amp = h * (CALM + (STIR - CALM) * liquid.energy);
const empty = h * (1 + STIR) + 4; // 波ごとボタンの下に隠れる高さ
const base = empty + (h * FULL - empty) * liquid.level;
const d = wave(base, amp, time, 0);
front.setAttribute("d", d);
clip.setAttribute("d", d); // 白い文字を切り抜く形は、手前の波と同じ
back.setAttribute("d", wave(base - h * 0.04, amp * 0.9, time, 2.1));
};
// 波は実時間で流す。動きを減らす設定では流さず、形だけ見せる
const flow = (time) => draw(reduceMotion ? 0 : time);
const rest = () => {
// 空で静まったら描くのをやめる。波を流さない設定では、形が変わらないので動きが終わるたびに
if (reduceMotion || (liquid.level < 0.001 && liquid.energy < 0.001)) gsap.ticker.remove(flow);
};
// 水位が動くたびに波を立て、少しずつ静める
const stir = (duration) => {
gsap.ticker.add(flow); // 動いているあいだだけ描く(同じ関数は二重に登録されない)
gsap
.timeline({ onComplete: rest })
.to(liquid, { energy: 1, duration: 0.25 / SPEED, ease: "power1.out", overwrite: "auto" })
.to(liquid, { energy: 0, duration: duration / SPEED, ease: "power2.out", overwrite: "auto" });
};
const pour = () => {
gsap.to(liquid, { level: 1, duration: 0.9 / SPEED, ease: "power2.inOut", overwrite: "auto" });
stir(1.6);
};
const drain = () => {
gsap.to(liquid, { level: 0, duration: 0.8 / SPEED, ease: "power2.inOut", overwrite: "auto" });
stir(1.2);
};
button.addEventListener("pointerenter", pour);
button.addEventListener("pointerleave", drain);
button.addEventListener("focus", pour);
button.addEventListener("blur", drain);
});