本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ボタン

ホバーでボタンに液体がうねりながら満ちる

デモ

仕様

用途
ボタン
動作
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 で常に流しておき、水位と波の高さだけを mouseentermouseleave でトゥイーンします。

2 つの波それぞれの長さ・速さ・強さ、静かなときと動かしたときの波の高さ、満ちたときの水面の位置、満ちる時間と引く時間で調整します。ドリンク・食品・水まわりのブランドの申し込みボタンや、遊び心のあるランディングページに向いています。

コード

HTML
<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>
CSS
.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;
}
JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。