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

作品カードのホバーした画像が液体のようにゆがむ

デモ

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

仕様

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

しくみと調整

作品カードの画像を WebGL の板に貼り、フラグメントシェーダーで画像を引く位置をずらしてゆがめます。ずらす量は 3 つの和です。ノイズでずらした座標でもう一度ノイズを引く液体のうねり(ドメインワープ)、カーソルが直前に動いた向きへの引きずり、カーソルへ向かって引き伸ばす水滴のような膨らみです。どれもカーソルに近いほど強め、離れた所にもうねりを 4 分の 1 ほど残します。

うねりでずらす量は画像の高さの 16%、強くゆがむ範囲は半径 38% です。赤と青は変位を 1.14 倍と 0.86 倍にして引くので、ゆがんだ縁に色のにじみが出ます。0.3 倍までずらすとグリッチ風になります。ホバー中は 1.06 倍まで寄せ、ずらした先が画像の外に出たら、テクスチャを鏡写しに繰り返して端を折り返します。効きは 0.9 秒の power2.out で入れ、離れたら同じ道を戻します。

つまずきやすいのは、止まっている img 要素と、WebGL で描いた画像をそろえる所です。画像の縦横比が枠と違うと、CSS の object-fit: cover は切り抜くのに、テクスチャは引き伸ばされ、ホバーした瞬間に画像が跳びます。シェーダーでも同じ切り抜きをしてください。角の丸みも CSS から読み、角の外を地の色で塗ります。地の色は THREE.Color を通すと線形に直されて暗くなるので、hex を 255 で割った値で渡します。

canvas は一覧に 1 枚だけ重ね、全部のカードをそこに描きます。カードごとに WebGL の文脈を作ると、ブラウザの上限にすぐ届きます。描くのはゆがんでいる間だけにし、止まった画像は img 要素のまま見せます。キーボードで焦点が来たときは画像の中央を中心にゆがめ、指で触る端末と動きを減らす設定では板を作らず、画像をそのまま出します。

強さ・範囲・膨らみ・色のにじみ・引きずり・うねりの速さで調整します。画像を寄せるだけのホバーと違い、画像そのものが液体のようにゆがむので、写真や映像の作品が主役のポートフォリオ・ギャラリー・商品一覧のサムネイルに向いています。

コード

HTML
<section class="works" aria-labelledby="works-title">
  <!-- ホバーした画像のゆがみを描く板。飾りなので読み上げからは外す -->
  <canvas class="works__canvas" aria-hidden="true"></canvas>

  <div class="works__head">
    <h2 class="works__title" id="works-title">制作実績<span class="works__count">(03)</span></h2>
    <nav aria-label="作品の分野">
      <ul class="works__filters">
        <li><a href="/works/" aria-current="page">すべて</a></li>
        <li><a href="/works/brand/">ブランド</a></li>
        <li><a href="/works/film/">映像</a></li>
        <li><a href="/works/spatial/">空間</a></li>
      </ul>
    </nav>
  </div>

  <!-- 画像は WebGL が読むので、同じオリジンに置く(別のドメインなら crossorigin="anonymous" を付け、配信側で CORS を許す) -->
  <ul class="works__grid">
    <li>
      <a class="work" href="/works/hikishio/">
        <img class="work__image" src="/img/hikishio.jpg" alt="" width="672" height="800">
        <span class="work__caption">
          <span class="work__name"><span class="work__index" aria-hidden="true">01</span>引き潮</span>
          <span class="work__year">2026年</span>
          <span class="work__kind">写真</span>
        </span>
      </a>
    </li>
    <li>
      <a class="work" href="/works/takai-sora/">
        <img class="work__image" src="/img/takai-sora.jpg" alt="" width="672" height="800">
        <span class="work__caption">
          <span class="work__name"><span class="work__index" aria-hidden="true">02</span>高い空</span>
          <span class="work__year">2025年</span>
          <span class="work__kind">ブランドムービー</span>
        </span>
      </a>
    </li>
    <li>
      <a class="work" href="/works/shio-no-hana/">
        <img class="work__image" src="/img/shio-no-hana.jpg" alt="" width="672" height="800">
        <span class="work__caption">
          <span class="work__name"><span class="work__index" aria-hidden="true">03</span>流砂の花</span>
          <span class="work__year">2025年</span>
          <span class="work__kind">展覧会</span>
        </span>
      </a>
    </li>
  </ul>
</section>

CSS
.works {
  --ground: #f2f1ed; /* 地。script.js が読んで、ゆがめた画像の角の外を同じ色で塗る */
  --ink: #262626;
  --mute: #6b6b6b;
  --line: rgba(38, 38, 38, 0.12);
  --on-ink: #ffffff;
  position: relative;
  padding: 56px clamp(16px, 7.5vw, 96px) 64px;
  background: var(--ground);
  color: var(--ink);
}

/* ゆがみを描く板。画像の上に重ね、ポインタは下の画像へ通す */
.works__canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }

.works__head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: flex-end;
  gap: 16px 32px;
  margin-bottom: 32px;
  padding-bottom: 32px;
  box-shadow: inset 0 -1px 0 var(--line);
}

.works__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }

.works__count {
  margin-left: 8px;
  color: var(--mute);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  vertical-align: top;
  font-variant-numeric: tabular-nums;
}

.works__filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }

.works__filters a {
  display: block;
  height: 40px;
  padding: 0 16px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--line);
  color: inherit;
  font-size: 14px;
  font-weight: 600;
  line-height: 40px;
  letter-spacing: 0.04em;
  text-decoration: none;
  white-space: nowrap;
}

.works__filters a[aria-current="page"] { background: var(--ink); color: var(--on-ink); box-shadow: none; }

.works__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 48px 40px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.work { display: block; color: inherit; text-decoration: none; }
.works a:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 角の丸みは script.js が読み、ゆがめた画像にも同じ丸みを付ける */
.work__image {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 21 / 25;
  object-fit: cover;
  border-radius: 4px;
  background: var(--line);
}

.work__caption { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; column-gap: 16px; margin-top: 16px; }
.work__name { font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }

/* 番号の列の幅を決めておき、分野の行を作品名の頭にそろえる */
.work__index {
  display: inline-block;
  width: 32px;
  color: var(--mute);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
}

.work__year, .work__kind { color: var(--mute); font-size: 14px; letter-spacing: 0.04em; white-space: nowrap; font-variant-numeric: tabular-nums; }
.work__kind { grid-column: 1 / -1; margin: 4px 0 0 32px; line-height: 1.7; }

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

const HOVER = 0.9; // ゆがみが効ききるまで(秒)
const LOOP = 6; // うねりがひと回りして元の形に戻るまで(秒)
const DRAG = 0.45; // 動かした向きに画像が引きずられる強さ
const MAX_DRAG = 0.09, DECAY = 0.88; // 引きずりの上限(画像の高さに対する比)と、1 コマ(1/60 秒)ごとに残る割合
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }";
// 長さはどれも画像の高さを 1 とした比。SPLIT(R と B のずれ)を 0.3 まで上げるとグリッチ風になる
const fragmentShader = /* glsl */ `
  precision highp float;
  uniform sampler2D uTex; uniform vec2 uMouse, uDrag, uSize, uCover; uniform float uHover, uPhase, uRadius; uniform vec3 uBg;
  varying vec2 vUv;
  const float STRENGTH = 0.16, REACH = 0.38, BASE = 0.25, LENS = 0.18, SPLIT = 0.14, ZOOM = 1.06, FLOW = 0.45;
  vec2 hash2(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return -1.0 + 2.0 * fract(sin(p) * 43758.5453123); }
  float noise(vec2 p) { // グラディエントノイズ(-1..1)
    vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
    return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
  }
  void main() {
    vec2 aspect = vec2(uSize.x / uSize.y, 1.0), p = vUv * aspect, toMouse = p - uMouse;
    // 液体のうねり: ノイズでずらした座標でもう一度ノイズを引く。時間は円周上の座標として足すので、ひと回りで元に戻る
    vec2 loop = vec2(cos(uPhase), sin(uPhase)) * FLOW;
    vec2 q = vec2(noise(p * 1.7 + loop), noise(p * 1.7 + vec2(3.7, 8.1) - loop.yx));
    vec2 flow = vec2(noise(p * 2.3 + q * 1.8 + vec2(1.3, 4.7)), noise(p * 2.3 + q * 1.8 + vec2(7.9, 2.2)));
    float near = exp(-dot(toMouse, toMouse) / (REACH * REACH)), amount = uHover * mix(BASE, 1.0, near); // near はカーソルに近いほど 1
    vec2 disp = flow * STRENGTH * amount - uDrag * near * uHover - toMouse * near * LENS * uHover; // うねり・引きずり・膨らみ
    // 寄りは画像の中心を基準に。uCover は object-fit: cover と同じ切り抜き
    vec2 base = (vUv - 0.5) / mix(1.0, ZOOM, uHover) * uCover + 0.5, o = disp / aspect * uCover;
    vec3 color = vec3(texture2D(uTex, base + o * (1.0 + SPLIT)).r, texture2D(uTex, base + o).g, texture2D(uTex, base + o * (1.0 - SPLIT)).b);
    vec2 corner = abs(vUv - 0.5) * uSize - (0.5 * uSize - uRadius); // 角丸の外は地の色で塗る(境目は 1px でなめらかに)
    float sd = length(max(corner, 0.0)) + min(max(corner.x, corner.y), 0.0) - uRadius;
    gl_FragColor = vec4(mix(color, uBg, clamp(sd + 0.5, 0.0, 1.0)), 1.0);
  }
`;
// 指で触る端末と、動きを減らす設定では、板を作らず <img> のまま見せる
const enabled = matchMedia("(hover: hover) and (pointer: fine)").matches && !matchMedia("(prefers-reduced-motion: reduce)").matches;

if (enabled) document.querySelectorAll(".works").forEach((section) => {
  const hex = Number.parseInt(getComputedStyle(section).getPropertyValue("--ground").trim().slice(1), 16);
  const ground = new THREE.Vector3(((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255); // THREE.Color は線形に直すので使わない
  const renderer = new THREE.WebGLRenderer({ canvas: section.querySelector(".works__canvas"), alpha: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  const scene = new THREE.Scene(), geometry = new THREE.PlaneGeometry(1, 1);
  const camera = new THREE.OrthographicCamera(0, 1, 1, 0, -1, 1); // 左下を原点にした px。右端と上端は大きさの変化で入れる
  const pointer = new THREE.Vector2(), drag = new THREE.Vector2(); // ポインタの位置と、直前の動き(px。毎コマ少しずつ 0 へ戻す)
  const works = [...section.querySelectorAll(".work")].map((link) => {
    const img = link.querySelector(".work__image"), texture = new THREE.Texture(img);
    texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping; // ずらした先が画像の外に出たら、端で折り返して引く
    img.decode().then(() => (texture.needsUpdate = true), () => {}); // 読めなかった画像は、ゆがめずにそのまま見せる
    const uniforms = { uTex: { value: texture }, uBg: { value: ground }, uHover: { value: 0 }, uPhase: { value: 0 }, uRadius: { value: 0 } };
    for (const name of ["uMouse", "uDrag", "uSize", "uCover"]) uniforms[name] = { value: new THREE.Vector2() };
    const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
    const state = { hover: 0 }, hover = gsap.to(state, { hover: 1, duration: HOVER, ease: "power2.out", paused: true });
    const show = () => { if (texture.version > 0) { hover.play(); gsap.ticker.add(tick); } }; // 2 度足しても 1 つにまとまる
    const hide = () => { if (!img.matches(":hover") && !link.matches(":focus-visible")) hover.reverse(); };
    img.addEventListener("pointerenter", show);
    img.addEventListener("pointerleave", hide);
    link.addEventListener("blur", hide);
    link.addEventListener("focus", () => { // キーボードで来たときは、画像の中央を中心にゆがめる
      if (link.matches(":focus-visible")) { const r = img.getBoundingClientRect(); pointer.set(r.x + r.width / 2, r.y + r.height / 2); show(); }
    });
    scene.add(mesh);
    return { img, mesh, uniforms, state, hover, texture };
  });
  section.addEventListener("pointerenter", (e) => pointer.set(e.clientX, e.clientY));
  section.addEventListener("pointermove", (e) => { drag.add({ x: e.clientX - pointer.x, y: e.clientY - pointer.y }); pointer.set(e.clientX, e.clientY); });
  const tick = (time) => {
    drag.multiplyScalar(DECAY ** gsap.ticker.deltaRatio(60)); // 画面の更新の速さに依らず、同じ時間で引きずりが消える
    const box = section.getBoundingClientRect();
    for (const { img, mesh, uniforms: u, state } of works) {
      if (!(mesh.visible = state.hover > 0)) continue;
      const r = img.getBoundingClientRect(); // スクロールしても画像に重なるよう、毎コマ測る
      mesh.position.set(r.x - box.x + r.width / 2, box.bottom - r.bottom + r.height / 2, 0);
      mesh.scale.set(r.width, r.height, 1);
      const cover = r.width / r.height / (img.naturalWidth / img.naturalHeight);
      u.uCover.value.set(Math.min(1, cover), Math.min(1, 1 / cover));
      u.uSize.value.set(r.width, r.height);
      u.uRadius.value = parseFloat(getComputedStyle(img).borderTopLeftRadius); // 角丸は CSS から読む
      u.uMouse.value.set((pointer.x - r.x) / r.height, (r.bottom - pointer.y) / r.height);
      u.uDrag.value.set(drag.x, -drag.y).multiplyScalar(DRAG / r.height).clampLength(0, MAX_DRAG); // 上向きを正に直す
      u.uHover.value = state.hover;
      u.uPhase.value = (time / LOOP) * Math.PI * 2;
    }
    renderer.render(scene, camera);
    if (works.every(({ state }) => state.hover === 0)) gsap.ticker.remove(tick); // 止まったら描くのをやめ、<img> のまま見せる
  };
  const sizes = new ResizeObserver(() => {
    renderer.setSize(section.clientWidth, section.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
    Object.assign(camera, { right: section.clientWidth, top: section.clientHeight }).updateProjectionMatrix();
  });
  sizes.observe(section);
  section.destroy = () => { // PJAX で差し替えるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    gsap.ticker.remove(tick); sizes.disconnect(); geometry.dispose();
    for (const { mesh, hover, texture } of works) { hover.kill(); mesh.material.dispose(); texture.dispose(); }
    renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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