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

ヒーロー

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

デモ

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

仕様

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

しくみと調整

ヒーローの画像を押すと、その点を中心に水面のような波紋が広がり、波が通り過ぎた内側から次の画像に変わっていきます。次の画像は1.05倍に寄った状態から、押した点を中心に等倍へ引いていきます。見出しや日時は WebGL に描かず HTML のまま残し、ゆがむのは画像の範囲だけにしました。

前と次の画像をテクスチャとして読み込み、画像の上に重ねた平面をシェーダーで描いています。波の高さは、押した点からの距離と、広がっていく波の先頭との差から「減衰する包絡線 × cos」で求め、その傾きの分だけ、画像の色を読み取る位置を外向きにずらします。傾きの大きい所ほど大きくずれるので、水を通して見たような屈折になります。波の列は画像の高さの36%ぶんの長さで、そこに3つの波が弱まりながら続きます。

斜面には光の向きから明暗を付け、光の来る側にはきらめきを足しています。赤と青だけはずれる量を少し増減させてあり、色ごとのずれ方の違いが、波の縁に水らしいにじみを作ります。広がりきるまでは1.8秒の sine.inOut です。

波が届く距離は、いちばん遠い角までの距離に波の列の長さを足して決めています。角までで止めると、最後の波が角に残ったまま終わってしまいます。波の強さには sin(π × 進み具合) を乗算して、始まりと終わりでは波が無い状態にしました。canvas を表示するのは切り替えのあいだだけで、前後は img 要素に戻すので、波が残っていると入れ替わる瞬間に画像が跳んで見えます。

画像を表示している要素そのものが 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。切り替えのあいだだけ表示する -->
    <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); // 押した点を中心に、1.05 倍に拡大した状態から等倍へ戻す
    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)); };

  // 動きを減らす設定ではWebGL の描画を用意せず、画像をすぐ差し替える
  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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。