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

カーソル

カーソルの通った跡に波紋が広がり、下の写真が水越しに揺らぐ

デモ

操作:カーソルを動かす。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カーソル
動作
WebGL
種類
シーン
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

川底の写真の上でカーソルを動かすと、通った跡から次々に波紋が生まれて広がり、輪の下の小石が水越しに見ているように揺らぎます。カーソルを止めれば新しい波紋はもう生まれず、残った輪が広がりながら静まっていきます。見出しやボタンは HTML のまま上に重ねてあるので、ゆがむのは写真だけです。

押した所から波紋を1つ広げるのとは違い、ここでは軌跡に沿って波紋が生まれ続けます。水面は時間を追って波の方程式を解くのではなく、カーソルの位置に0.12秒ごとに置いた「しずく」から、毎回その場で計算することにしました。しずくが持っているのは位置と経過時間と強さだけで、シェーダーはしずくごとの輪の波を足し合わせ、水面の傾きのぶんだけ写真をずらして描きます。前のコマの水面を使わないので、計算用の描画先を行き来させる仕組みが要らず、コードも短く済みます。

合わせておかなければならないのが、しずくを置く間隔と波の山の間隔です。輪は1秒に高さの36%ずつ広がるので、0.12秒で輪が進む距離を、そのまま山の間隔にしました。ここがずれると、続けて置いたしずくどうしで山と谷が打ち消し合い、カーソルの近くの波紋がぼやけて消えてしまいます。しずくの強さはそのあいだにカーソルが動いた距離に比例させたので、ゆっくり動かせば弱い輪が重なり、速く動かせば強い輪が間を空けて並びます。

波紋の見え方を決めるのは、輪が弱まる速さ(1.3秒)と、写真をずらす量です。弱まるまでを長くすると波紋が遠くまで届き、ずらす量を増やすと小石が大きく揺らぎます。写真は WebGL がテクスチャとして読み込むので、同じオリジンに置いてください。PJAX でページを差し替えるサイトでは、ヒーローを外す前に destroyRipple() を呼び、WebGL のコンテキストを破棄します。

コード

HTML
<section class="ripple-hero" aria-labelledby="ripple-hero-title">
  <!-- 波紋でゆがむのは画像だけ。WebGL がテクスチャとして読むので、画像は同じオリジンに置く -->
  <img class="ripple-hero__image" src="/img/riverbed.jpg" alt="" width="1600" height="900">
  <!-- 波紋を描く canvas。飾りなので、スクリーンリーダーからは外す -->
  <canvas class="ripple-hero__canvas" aria-hidden="true" hidden></canvas>
  <div class="ripple-hero__inner">
    <p class="ripple-hero__eyebrow">Riverside inn</p>
    <h1 class="ripple-hero__title" id="ripple-hero-title">川のほとりで、<br>ゆっくり眠る。</h1>
    <p class="ripple-hero__lead">ここに説明文が入ります。</p>
    <div class="ripple-hero__actions">
      <a class="ripple-hero__button" href="/rooms/">空室を見る</a>
      <a class="ripple-hero__button ripple-hero__button--line" href="/about/">宿のご案内</a>
    </div>
  </div>
</section>

CSS
.ripple-hero {
  --ink: #ffffff; /* 画像の上の文字 */
  --mute: #dedcd6; /* 英字のラベル */
  --line: rgba(255, 255, 255, 0.2);
  --on-ink: #111111; /* 白いボタンの文字 */
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  align-items: center;
  min-height: min(90vh, 720px);
  padding: 96px var(--gutter);
  box-sizing: border-box;
  overflow: hidden;
  isolation: isolate;
  color: var(--ink);
  background: #6f6656; /* 画像を読み込むまでの背景 */
}

/* 画像と canvas は同じ大きさで重ねる。canvas は画像を object-fit: cover と同じ切り抜きで描く */
.ripple-hero__image,
.ripple-hero__canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.ripple-hero__canvas[hidden] { display: none; }

/* 文字の側を濃くするオーバーレイ。波紋の上に重ね、文字は動かさない */
.ripple-hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: linear-gradient(90deg, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.12) 70%);
  pointer-events: none;
}

.ripple-hero__inner { position: relative; z-index: 1; }

.ripple-hero__eyebrow {
  margin: 0;
  color: var(--mute);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.ripple-hero__title {
  margin: 24px 0 0;
  font-size: clamp(40px, 8vw, 72px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}

.ripple-hero__lead { max-width: 432px; margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

.ripple-hero__actions { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 48px; }

.ripple-hero__button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--ink);
  color: var(--on-ink);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
  white-space: nowrap;
}

.ripple-hero__button--line {
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--ink);
  color: var(--ink);
}

.ripple-hero__button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

JavaScript
import * as THREE from "three";

// 波の決まり(長さはヒーローの高さを 1 とした単位)
const DT = 0.12, LIFE = 2.4, SLOTS = Math.round(LIFE / DT) + 1; // しずくを置く間隔と、消えるまでの秒数
const SPEED = 0.36, WAVE = SPEED * DT; // 輪が広がる速さ(高さ / 秒)。山の間隔は DT 秒に広がる距離にそろえ、続けて置いたしずくの山を重ねる
const WIDTH = 0.05, TAU = 1.3, SPREAD = 2.5; // 輪の厚み・弱まる速さ(秒)・広がるほど薄まる割合
const FULL = 0.5; // この速さ(高さ / 秒)で動かすと、しずくの強さが 1 になる
const REFRACT = 0.018, SHINE = 0.22, GLINT = 0.5, SPLIT = 0.18; // 画像をずらす量・斜面の明暗・きらめき・色ごとのずれ
const f = (v) => v.toFixed(5); // GLSL の float 定数
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
const fragmentShader = /* glsl */ `
  uniform sampler2D uImage;
  uniform vec4 uDrops[${SLOTS}]; // x, y(高さを 1 とした座標、左下原点), 生まれてからの秒, 強さ
  uniform vec4 uCover; // object-fit: cover と同じ切り抜き(倍率 xy・ずらし zw)
  uniform float uAspect;
  varying vec2 vUv;
  void main() {
    vec2 p = vec2(vUv.x * uAspect, vUv.y), grad = vec2(0.0);
    for (int i = 0; i < ${SLOTS}; i++) {
      vec4 d = uDrops[i];
      vec2 v = p - d.xy;
      float r = length(v), x = r - ${f(SPEED)} * d.z; // 0 = 輪の真ん中
      if (d.w <= 0.0 || abs(x) > ${f(WIDTH * 3)}) continue;
      // 輪の高さ h = 包み × sin(Kx) の、半径の向きの傾き(K で割って -1〜1 にそろえる)
      float k = ${f((2 * Math.PI) / WAVE)}, env = exp(-x * x / ${f(WIDTH * WIDTH)}) * exp(-d.z / ${f(TAU)}) * smoothstep(0.0, 0.12, d.z) / (1.0 + r * ${f(SPREAD)});
      grad += v / max(r, 1e-4) * env * (cos(k * x) - ${f(2 / (WIDTH * WIDTH))} * x * sin(k * x) / k) * d.w;
    }
    grad /= 1.0 + 0.35 * length(grad); // 重なりすぎた所で、ずれが大きくなりすぎないようにする
    vec2 o = grad * ${f(REFRACT)} / vec2(uAspect, 1.0);
    vec3 color = vec3( // 赤と青はずらす量を少し変えて、縁に色のにじみを付ける
      texture2D(uImage, uCover.zw + (vUv - o * ${f(1 + SPLIT)}) * uCover.xy).r,
      texture2D(uImage, uCover.zw + (vUv - o) * uCover.xy).g,
      texture2D(uImage, uCover.zw + (vUv - o * ${f(1 - SPLIT)}) * uCover.xy).b);
    float lit = dot(grad, normalize(vec2(-0.6, 0.8))); // 光は左上から。光の来る側の斜面を明るく
    gl_FragColor = vec4(color + ${f(SHINE)} * lit + ${f(GLINT)} * pow(max(lit, 0.0), 3.0), 1.0);
  }
`;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".ripple-hero").forEach(async (hero) => {
  const img = hero.querySelector(".ripple-hero__image");
  const canvas = hero.querySelector(".ripple-hero__canvas");
  if (reduceMotion) return; // 動きを減らす設定では、波紋を描かずに画像だけを見せる
  await img.decode().catch(() => {});
  if (!img.naturalWidth) return; // 画像が読めなければ、背景色のまま
  const renderer = new THREE.WebGLRenderer({ canvas });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const texture = new THREE.Texture(img);
  Object.assign(texture, { minFilter: THREE.LinearFilter, generateMipmaps: false, needsUpdate: true });
  texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping; // 縁の外を引いたら、鏡に映したように折り返す
  const uniforms = { uImage: { value: texture }, uDrops: { value: Array.from({ length: SLOTS }, () => new THREE.Vector4()) }, uCover: { value: new THREE.Vector4(1, 1, 0, 0) }, uAspect: { value: 1 } };
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
  const scene = new THREE.Scene().add(mesh);
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const drops = []; // { x, y, born, amp }
  let pointer = null, prev = null, next = 0, lastMove = 0;
  const draw = (now) => {
    uniforms.uDrops.value.forEach((slot, i) => (drops[i] ? slot.set(drops[i].x, drops[i].y, now - drops[i].born, drops[i].amp) : slot.set(0, 0, 0, 0)));
    renderer.render(scene, camera);
  };
  const frame = (ms) => {
    const now = ms / 1000;
    // DT 秒ごとにしずくを置く。強さは、その間にポインタが動いた距離に比例する(止まっていれば置かない)
    for (next ||= now; next <= now; next += DT) {
      if (pointer && prev) drops.push({ ...pointer, born: next, amp: Math.min(1, Math.hypot(pointer.x - prev.x, pointer.y - prev.y) / DT / FULL) });
      prev = pointer;
    }
    while (drops.length && (now - drops[0].born >= LIFE || drops.length > SLOTS)) drops.shift();
    draw(now);
    // 波紋が消えて、ポインタも止まっていれば、描くのをやめる(次に動いたときに再開する)
    if (!drops.length && now - lastMove > DT * 2) {
      renderer.setAnimationLoop(null);
      next = 0;
    }
  };
  new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    renderer.setSize(w, h, false);
    const s = Math.max(w / img.naturalWidth, h / img.naturalHeight); // object-fit: cover と同じ倍率
    const sx = w / (img.naturalWidth * s), sy = h / (img.naturalHeight * s);
    uniforms.uCover.value.set(sx, sy, (1 - sx) / 2, (1 - sy) / 2);
    uniforms.uAspect.value = w / h;
    draw(performance.now() / 1000);
  }).observe(hero);
  canvas.hidden = false;
  hero.addEventListener("pointermove", (e) => {
    const r = hero.getBoundingClientRect();
    pointer = { x: (e.clientX - r.left) / r.height, y: (r.bottom - e.clientY) / r.height };
    lastMove = performance.now() / 1000;
    renderer.setAnimationLoop(frame); // 止めていたら再開する(同じ関数なら何度呼んでもよい)
  });
  hero.addEventListener("pointerleave", () => (pointer = prev = null));
  // ページを差し替えるサイト(PJAX)では、ヒーローを外す前に呼んで WebGL のコンテキストを捨てる
  hero.destroyRipple = () => {
    renderer.setAnimationLoop(null);
    [mesh.geometry, mesh.material, texture, renderer].forEach((item) => item.dispose());
    renderer.forceContextLoss();
  };
});

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

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