デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ボタンの中に液体が波打ちながら満ちていき、満ちたあとも上のほうで小さな波が揺れ続けます。液体の表面は、2つの正弦波を足し合わせた path を毎フレーム組み立て直したものです。水面は手前と奥に重ねてあり、奥は波の位相をずらして少し高く置き、手前より明るい果汁の色にして奥行きを付けています。
水位はカーソルが乗ると0.9秒の power2.inOut で満ち、離れると0.8秒で引きます。水位が動くたびに波の高さを一度大きくしてから時間をかけて静めるので、注いだ瞬間のうねりが表れます。満ちきっても水面はボタンの上端まで上げず、高さの20%の位置で止めることにしました。上に余白が残るので、静まったあとも小さな波が揺れ続けて見えます。
描く処理は、水位が動き始めたときに gsap.ticker へ登録し、液体が空になって波も静まったら外しています。トゥイーンで動かしているのは、水位と波の高さの数値だけです。カーソルのほか、キーボードのフォーカスでも同じように満ちます。
文字も重ねてあり、液体のある部分にだけ、手前の波と同じ形の clipPath で切り抜いた文字を表示しています。この文字の色を変えておくと、水面が通るところで文字の途中から色が切り替わります。楕円を持ち上げて文字色を一度に替える塗りとは違い、境目そのものが波の形で動き続けるのが、このボタンの特徴です。
液体の色はイエローに寄せるほど、しぼりたてのジュースらしく見えます。ただ、そのぶん白い文字とのコントラストは下がり、このデモのみかん色ではコントラスト比が2.3:1しかありません。そこで液体の上の文字もブラックにしました。白い文字を使いたいなら、液体のほうを濃い色にします。
コード
<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; /* 文字と枠線の色 */
--liquid: #ee9612; /* 手前の液体(イエロー寄りのオレンジ。ブラックの文字との差は 6.5:1) */
--foam: #ffc42e; /* 奥の波の色(明るい果汁の色) */
--on-liquid: #262626; /* 液体の上の文字の色。明るい液体なのでブラック。液体を濃くするなら白にできる */
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(--liquid);
}
.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);
});