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

ヒーローの画像を押した所から波紋が広がり、次の画像に切り替わる

デモ

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

仕様

用途
ヒーロー
動作
DOM+WebGL
種類
シェーダー
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ヒーローの画像を前と次の 2 枚のテクスチャにし、画像の枠に重ねた WebGL の板のシェーダーで切り替えます。押した点からの距離と、広がっていく波の先頭との差を取り、波の高さを「減衰する包絡線 × cos」で決めます。その傾きのぶんだけ画像を引く位置を中心から外向きにずらすと、水を通して見たような屈折になります。波の先頭より内側(波が通り過ぎた所)が次の画像です。見出しや日時は HTML のままなので、ゆがむのは画像の枠の中だけです。

波の列は画像の高さの 36% の長さで、その中に 3 つの波が減衰しながら続きます。ずらす量は最大で画像の高さの 5% です。傾きと光の向きで斜面に明暗を付け、光の来る側にきらめきを足し、赤と青をずれの向きに 25% ずつずらすと、縁に水らしい色のにじみが出ます。広がりきるまでは 1.8 秒の sine.inOut で、次の画像は押した点を中心に 1.05 倍から等倍へ引きます。

波の届く距離は、押した点からいちばん遠い枠の角までの距離に、波の列の長さを足して決めます。短いと、最後の波が角に残ったまま止まります。波の強さには sin(π × 進み具合) を掛けて始まりと終わりを波の無い画像にしておくと、切り替えの間だけ canvas を出し、終わったら img 要素に戻しても画像が跳びません。画像の比が枠と違うときは、object-fit: cover と同じ切り抜きをシェーダーでもします。

画像の枠そのものを button にし、Enter や Space で押したときは枠の中央から広げます。波が抜けきるまでは次の押下を受けず、番号・名前・日時は読み上げにも届く領域に書き換えます。画像は WebGL が読むので同じオリジンに置き、動きを減らす設定では板を作らず画像をすぐ差し替えます。PJAX でページを差し替えるときは、destroy を呼んで WebGL の文脈を捨ててください。

波の列の長さと数、ずらす量、斜面の明暗ときらめき、色のにじみ、次の画像の寄りで調整します。水・夏・音楽のイベントのように、押した手応えを大きく見せたいサイトのヒーローや、作品の画像を送るスライダーに向いています。

コード

HTML
<section class="ripple-hero" aria-roledescription="カルーセル" aria-label="プログラム">
  <div class="ripple-hero__copy">
    <p class="ripple-hero__eyebrow"><span>Festival</span><span>7月13日〜19日</span></p>
    <h1 class="ripple-hero__title">水辺で、<br>夏を遊ぶ。</h1>
    <p class="ripple-hero__lead">ここに説明文が入ります。</p>
    <p class="ripple-hero__caption" aria-live="polite">
      <span class="ripple-hero__count">01 / 03</span>
      <span class="ripple-hero__name">夜明けの水泳</span>
      <span class="ripple-hero__date">7月13日(月)6:00</span>
    </p>
    <p class="ripple-hero__hint">画像を押すと、次のプログラムへ進みます</p>
  </div>

  <!-- 画像は WebGL が読むので、同じオリジンに置く(別のドメインなら crossorigin="anonymous" を付け、配信側で CORS を許す)。
       名前と日時は data-* に書き、切り替えたときに左の列へ移す -->
  <button class="ripple-hero__media" type="button" aria-label="次のプログラムを見る">
    <img class="ripple-hero__image" src="/img/dawn-swim.jpg" alt="" data-name="夜明けの水泳" data-date="7月13日(月)6:00">
    <img class="ripple-hero__image" src="/img/pier-sessions.jpg" alt="" data-name="桟橋のライブ" data-date="7月15日(水)19:30" hidden>
    <img class="ripple-hero__image" src="/img/film-on-the-water.jpg" alt="" data-name="水上の映画会" data-date="7月18日(土)21:00" hidden>
    <!-- 波紋の切り替えを描く板。切り替えのあいだだけ出す -->
    <canvas class="ripple-hero__canvas" aria-hidden="true" hidden></canvas>
  </button>
</section>

CSS
.ripple-hero {
  --ground: #111111;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --line: rgba(238, 236, 230, 0.14);
  --gutter: clamp(16px, 7.5vw, 96px);
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 512px);
  align-items: center;
  gap: 48px 64px;
  padding: 96px var(--gutter);
  background: var(--ground);
  color: var(--ink);
}

.ripple-hero__eyebrow { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: var(--mute); font-size: 13px; line-height: 1; }
.ripple-hero__eyebrow span:first-child { font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.ripple-hero__eyebrow span:last-child { font-weight: 600; letter-spacing: 0.06em; }
.ripple-hero__title { margin: 24px 0 0; font-size: clamp(40px, 8vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.ripple-hero__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.ripple-hero__lead span { display: inline-block; } /* 狭い画面でも「映画を観る。」を 1 語のまま次の行へ送る */

/* いまの画像の番号・名前・日時。切り替えると script.js が書き換え、読み上げにも届く */
.ripple-hero__caption {
  display: grid;
  justify-items: start;
  gap: 8px;
  margin: 32px 0 0;
  padding-top: 24px;
  box-shadow: inset 0 1px 0 var(--line);
  line-height: 1;
}

.ripple-hero__count { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.ripple-hero__name { margin-top: 8px; font-size: 22px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.ripple-hero__date { color: var(--mute); font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.ripple-hero__hint { margin: 24px 0 0; color: var(--mute); font-size: 13px; line-height: 1.6; letter-spacing: 0.06em; }

/* 画像の枠そのものがボタン。画像と、切り替えのあいだだけ重ねる canvas を枠いっぱいに敷く */
.ripple-hero__media {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 16 / 15;
  padding: 0;
  border: 0;
  border-radius: 4px;
  overflow: hidden;
  background: var(--line);
  cursor: pointer;
}

.ripple-hero__media > * { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ripple-hero__media > [hidden] { display: none; }
.ripple-hero__image { object-fit: cover; }
.ripple-hero__media:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

@media (max-width: 860px) {
  .ripple-hero { grid-template-columns: minmax(0, 1fr); }
}

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";

const DURATION = 1.8; // 波の列が画像の端を抜けきるまで(秒)
const WIDTH = 0.36, CRESTS = 3; // 波の列の長さ(画像の高さに対する比)と、その中の波の数
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
// 長さはどれも画像の高さを 1 とした比。AMP は画像をずらす最大量、SPLIT は R と B のずれ、ZOOM は次の画像の寄り
const fragmentShader = /* glsl */ `
  precision highp float;
  uniform sampler2D uFrom, uTo; uniform vec2 uCoverFrom, uCoverTo, uCenter; uniform float uProgress, uReach, uAspect, uPx;
  varying vec2 vUv;
  const float PI = 3.14159265, WIDTH = ${WIDTH.toFixed(2)}, K = ${((CRESTS * 2 * Math.PI) / WIDTH).toFixed(4)};
  const float AHEAD = 0.03, AMP = 0.05, SHINE = 0.3, GLINT = 0.5, SPLIT = 0.25, ZOOM = 1.05;
  const vec2 LIGHT = vec2(-0.6, 0.8); // 光の来る向き(左上)
  // o だけずらして引く。R と B はずらす量を変えて、屈折の縁に色のにじみを出す。cover は object-fit: cover と同じ切り抜き
  vec3 refracted(sampler2D tex, vec2 uv, vec2 cover, vec2 o) {
    uv = (uv - 0.5) * cover + 0.5; o *= cover;
    return vec3(texture2D(tex, uv + o * (1.0 + SPLIT)).r, texture2D(tex, uv + o).g, texture2D(tex, uv + o * (1.0 - SPLIT)).b);
  }
  void main() {
    vec2 aspect = vec2(uAspect, 1.0), d = vUv * aspect - uCenter, dir = d / max(length(d), 1e-4);
    float x = length(d) - uProgress * uReach; // 0 = 波の先頭。負 = 波が通り過ぎた後(次の画像)
    float env = x > 0.0 ? exp(-x * x / (AHEAD * AHEAD)) : exp(x / (WIDTH * 0.42)); // 先頭の前はすぐ消え、後ろは減衰しながら続く
    float slope = -env * sin(K * x) * sin(PI * uProgress); // 波の高さ env · cos(Kx) の傾き。始まりと終わりには波が無い
    vec2 o = dir * slope * AMP / aspect;
    vec3 from = refracted(uFrom, vUv, uCoverFrom, o);
    vec3 to = refracted(uTo, (uCenter + d / mix(ZOOM, 1.0, uProgress)) / aspect, uCoverTo, o); // 押した点を中心に、寄った所から引く
    float lit = slope * dot(dir, normalize(LIGHT)); // 光の来る側の斜面は明るく、反対側は暗く
    gl_FragColor = vec4(mix(from, to, smoothstep(uPx, -uPx, x)) + SHINE * lit + GLINT * pow(max(lit, 0.0), 4.0), 1.0);
  }
`;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".ripple-hero").forEach((hero) => {
  const media = hero.querySelector(".ripple-hero__media"), canvas = hero.querySelector(".ripple-hero__canvas");
  const images = [...hero.querySelectorAll(".ripple-hero__image")];
  const [count, name, date] = ["count", "name", "date"].map((key) => hero.querySelector(`.ripple-hero__${key}`));
  const pad = (i) => String(i + 1).padStart(2, "0");
  let index = 0, tween = null, gl = null;

  const caption = (i) => ([count.textContent, name.textContent, date.textContent] = [`${pad(i)} / ${pad(images.length - 1)}`, images[i].dataset.name, images[i].dataset.date]);
  const show = (i) => { images.forEach((img, k) => (img.hidden = k !== i)); index = i; };
  // <img> の object-fit: cover と同じ所を切り抜く(画像の比が枠と違っても、切り替えの前後で絵が跳ばない)
  const cover = (img, out) => { const k = media.clientWidth / media.clientHeight / (img.naturalWidth / img.naturalHeight); out.set(Math.min(1, k), Math.min(1, 1 / k)); };

  // 動きを減らす設定では板を作らず、画像をすぐ差し替える
  if (!reduceMotion) {
    const renderer = new THREE.WebGLRenderer({ canvas });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    const scene = new THREE.Scene(), camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
    const textures = images.map((img) => {
      const texture = new THREE.Texture(img);
      img.decode().then(() => { texture.needsUpdate = true; renderer.initTexture(texture); }, () => {}); // 先に GPU へ送り、押した瞬間に詰まらせない
      return texture;
    });
    const uniforms = { uFrom: { value: null }, uTo: { value: null }, uProgress: { value: 0 }, uReach: { value: 1 }, uAspect: { value: 1 }, uPx: { value: 0 } };
    for (const key of ["uCoverFrom", "uCoverTo", "uCenter"]) uniforms[key] = { value: new THREE.Vector2() };
    const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
    scene.add(mesh);
    const sizes = new ResizeObserver(() => renderer.setSize(media.clientWidth, media.clientHeight, false)); // 大きさは CSS が決めるので、style は書き換えない
    sizes.observe(media);
    gl = { renderer, scene, camera, textures, uniforms, mesh, sizes };
  }

  // x, y は押した点(枠の左下を原点、枠の高さを 1 とした座標)
  const go = (x, y) => {
    if (tween?.isActive()) return; // 波が抜けきるまでは次の押下を受けない
    const next = (index + 1) % images.length, from = images[index], to = images[next];
    if (!gl || !from.naturalWidth || !to.naturalWidth) { show(next); caption(next); return; } // 画像が読めていなければ、そのまま差し替える
    const { renderer, scene, camera, textures, uniforms: u } = gl;
    const aspect = media.clientWidth / media.clientHeight;
    u.uFrom.value = textures[index]; u.uTo.value = textures[next];
    cover(from, u.uCoverFrom.value);
    cover(to, u.uCoverTo.value);
    u.uCenter.value.set(x, y); u.uAspect.value = aspect; u.uPx.value = 1 / media.clientHeight;
    // いちばん遠い角まで、波の列ごと抜ける距離。短いと最後の波が角に残ったまま止まる
    u.uReach.value = Math.max(Math.hypot(x, y), Math.hypot(aspect - x, y), Math.hypot(x, 1 - y), Math.hypot(aspect - x, 1 - y)) + WIDTH + 0.05;
    const render = () => renderer.render(scene, camera);
    tween = gsap.timeline({ onComplete: () => { show(next); canvas.hidden = true; } }) // 出し終えたら <img> に戻す
      .fromTo(u.uProgress, { value: 0 }, { value: 1, duration: DURATION, ease: "sine.inOut", onUpdate: render, onStart: () => { render(); canvas.hidden = false; } })
      // 番号・名前・日時は始めに消し、波が広がる途中で次のものを出す
      .to([count, name, date], { opacity: 0, duration: DURATION * 0.15, ease: "none" }, 0)
      .call(caption, [next], DURATION * 0.2)
      .to([count, name, date], { opacity: 1, duration: DURATION * 0.2, ease: "none" }, DURATION * 0.25);
  };
  media.addEventListener("click", (e) => {
    const r = media.getBoundingClientRect();
    if (e.detail === 0) go(r.width / r.height / 2, 0.5); // Enter・Space で押したときは、枠の中央から広げる
    else go((e.clientX - r.left) / r.height, (r.bottom - e.clientY) / r.height);
  });

  hero.destroy = () => { // PJAX で差し替えるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    tween?.kill();
    if (!gl) return;
    gl.sizes.disconnect(); gl.mesh.geometry.dispose(); gl.mesh.material.dispose();
    gl.textures.forEach((texture) => texture.dispose()); gl.renderer.dispose(); gl.renderer.forceContextLoss();
  };
});

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

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