M-016 Background advanced

How to build a water ripple hero background with Canvas 2D Canvas 2D で作る水面の波紋ヒーロー背景

Simulate a water surface on a 2D canvas and refract a hero background through it, with ripples following the pointer. Canvas 2D 上で水面をシミュレートし、ヒーロー背景を屈折させます。ポインタの動きに合わせて波紋が広がります。

ライブデモ Live Demo

Water Ripple

Touch the surface

Height-field ripple simulation refracting a procedurally drawn hero background — pure Canvas 2D, zero dependencies.

マウス移動 / クリックで波紋 Move or click to ripple
0.960
220
1.2

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

A hero background whose pixels are refracted by a height-field water simulation running on a 2D canvas. Pointer movement leaves fine ripples and a click drops a large one.

Canvas 2D 上で動く高さ場の水面シミュレーションが背景ピクセルを屈折させるヒーロー背景です。マウスを動かすと細かい波紋が残り、クリックで大きな波紋が落ちます。

どこで使うかWhere to use

Use it on a hero or full-bleed section where a still background would feel flat, especially for brands connected to water, glass, bathing or wellness.

静止した背景では物足りないヒーローや全面セクションに向いています。水・ガラス・温浴・ウェルネス系のブランドと特に相性が良い表現です。

特徴Key features

Height-field wave simulation, gradient-based refraction and specular glint, pointer and click ripples, automatic raindrops, a crisp DOM overlay above the canvas, and a loop that pauses when offscreen or hidden.

高さ場による波動シミュレーション、勾配を使った屈折と鏡面ハイライト、ポインタ / クリックの波紋、自動の雨だれ、canvas の上に重ねる鮮明な DOM オーバーレイ、画面外・非表示時のループ停止に対応します。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
damping0.96Wave decay per frame. Closer to 1.0 means longer-lived ripples; above 0.99 it can become unstable.
dropStrength220Amplitude injected per drop, falling off along a cosine curve from the centre.
refraction1.2Multiplier converting the height gradient into pixel offsets — how strong the distortion looks.
specular1.2Strength of the gradient-based highlight; too high and it blows out to white.
SIM_WIDTH360Horizontal resolution of the simulation buffer. Cost scales with its square.
moveRadius / clickRadius2 / 6Ripple radius for pointer movement and for a click.
rainInterval900Milliseconds between automatic raindrops. Disabled under prefers-reduced-motion.

実装コード Implementation Code

<!-- (1) Hero: canvas underneath, DOM overlay on top -->
<div class="water-hero" id="water_hero">
  <canvas id="water_canvas" class="water-canvas" aria-hidden="true"></canvas>
  <div class="water-overlay">
    <span class="water-badge">Water Ripple</span>
    <h3>Touch the surface</h3>
    <p>Height-field ripple simulation — pure Canvas 2D.</p>
  </div>
</div>

<script>
(function () {
  var SIM_WIDTH    = 360;   // simulation buffer width (height follows aspect)
  var DAMPING      = 0.96;  // wave decay per frame
  var DROP_STRENGTH= 220;   // amplitude per drop
  var REFRACTION   = 1.2;   // height gradient -> pixel offset
  var SPECULAR     = 1.2;   // height gradient -> highlight
  var MOVE_RADIUS  = 2;
  var CLICK_RADIUS = 6;
  var RAIN_INTERVAL= 900;   // ms between automatic raindrops

  var hero   = document.getElementById('water_hero');
  var canvas = document.getElementById('water_canvas');
  var ctx    = canvas.getContext('2d', { alpha: false });

  var w = 0, h = 0;             // simulation dimensions
  var prev, curr;               // height field buffers
  var srcData, outData;         // source pixels and render target
  var rafId = null, lastRain = 0, running = false;

  // ── (2) Source texture: drawn procedurally, read back as pixels ──
  function buildSource() {
    var off = document.createElement('canvas');
    off.width = w; off.height = h;
    var c = off.getContext('2d');

    var g = c.createLinearGradient(0, 0, 0, h);
    g.addColorStop(0, '#0a3a5c');
    g.addColorStop(0.55, '#06263f');
    g.addColorStop(1, '#03131f');
    c.fillStyle = g;
    c.fillRect(0, 0, w, h);

    // Glowing orbs give the ripples something to bend
    var orbs = [
      [0.22, 0.28, 0.42, 'rgba(56, 189, 248, 0.55)'],
      [0.74, 0.22, 0.34, 'rgba(45, 212, 191, 0.45)'],
      [0.52, 0.86, 0.46, 'rgba(14, 116, 144, 0.55)'],
      [0.92, 0.72, 0.30, 'rgba(129, 230, 255, 0.35)']
    ];
    orbs.forEach(function (o) {
      var r = o[2] * w;
      var rg = c.createRadialGradient(o[0] * w, o[1] * h, 0, o[0] * w, o[1] * h, r);
      rg.addColorStop(0, o[3]);
      rg.addColorStop(1, 'rgba(0,0,0,0)');
      c.fillStyle = rg;
      c.fillRect(0, 0, w, h);
    });

    // Faint horizontal bands read as caustics once distorted
    c.globalAlpha = 0.05;
    c.fillStyle = '#d9f6ff';
    for (var y = 0; y < h; y += 4) c.fillRect(0, y, w, 1);
    c.globalAlpha = 1;

    srcData = c.getImageData(0, 0, w, h);
    outData = ctx.createImageData(w, h);
    outData.data.set(srcData.data);   // borders are never rewritten
  }

  function resize() {
    var rect = hero.getBoundingClientRect();
    if (!rect.width || !rect.height) return;
    var nextH = Math.max(2, Math.round(SIM_WIDTH * rect.height / rect.width));
    if (srcData && w === SIM_WIDTH && h === nextH) return;
    w = SIM_WIDTH;
    h = nextH;
    canvas.width = w;
    canvas.height = h;
    prev = new Float32Array(w * h);
    curr = new Float32Array(w * h);
    buildSource();
  }

  // ── (3) Inject a drop into the height field ──
  function drop(cx, cy, radius, strength) {
    var r2 = radius * radius;
    for (var y = -radius; y <= radius; y++) {
      for (var x = -radius; x <= radius; x++) {
        var d2 = x * x + y * y;
        if (d2 > r2) continue;
        var px = cx + x, py = cy + y;
        if (px < 1 || py < 1 || px > w - 2 || py > h - 2) continue;
        // Cosine falloff: a hard-edged disc reads as a white speck
        var falloff = 0.5 + 0.5 * Math.cos(Math.PI * Math.sqrt(d2) / radius);
        prev[py * w + px] -= strength * falloff;
      }
    }
  }

  // ── (4) Wave propagation: finite difference of the 2D wave equation ──
  function step() {
    for (var y = 1; y < h - 1; y++) {
      var i = y * w + 1;
      for (var x = 1; x < w - 1; x++, i++) {
        curr[i] = ((prev[i - 1] + prev[i + 1] + prev[i - w] + prev[i + w]) * 0.5 - curr[i]) * DAMPING;
      }
    }
    var t = prev; prev = curr; curr = t;   // swap
  }

  // ── (5) Refraction pass: sample the source with a gradient offset ──
  function draw() {
    var src = srcData.data, out = outData.data;
    for (var y = 1; y < h - 1; y++) {
      var i = y * w + 1;
      for (var x = 1; x < w - 1; x++, i++) {
        var xo = prev[i - 1] - prev[i + 1];
        var yo = prev[i - w] - prev[i + w];

        var sx = x + xo * REFRACTION;
        var sy = y + yo * REFRACTION;
        sx = sx < 0 ? 0 : sx > w - 1 ? w - 1 : sx;
        sy = sy < 0 ? 0 : sy > h - 1 ? h - 1 : sy;

        var si = ((sy | 0) * w + (sx | 0)) * 4;
        var p  = i * 4;
        var lit = (xo + yo) * SPECULAR;

        var r = src[si]     + lit;
        var g = src[si + 1] + lit;
        var b = src[si + 2] + lit;
        out[p]     = r < 0 ? 0 : r > 255 ? 255 : r;
        out[p + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
        out[p + 2] = b < 0 ? 0 : b > 255 ? 255 : b;
        out[p + 3] = 255;
      }
    }
    ctx.putImageData(outData, 0, 0);
  }

  function frame(now) {
    rafId = requestAnimationFrame(frame);
    if (!srcData) return;   // hero can measure as zero-sized before layout settles
    if (RAIN_INTERVAL > 0 && now - lastRain > RAIN_INTERVAL) {
      lastRain = now;
      drop(2 + ((Math.random() * (w - 4)) | 0), 2 + ((Math.random() * (h - 4)) | 0), 3, DROP_STRENGTH * 0.7);
    }
    step();
    draw();
  }

  function start() { if (!running) { running = true; rafId = requestAnimationFrame(frame); } }
  function stop()  { if (running) { running = false; cancelAnimationFrame(rafId); } }

  // ── (6) Pointer input ──
  function toSim(e) {
    var rect = hero.getBoundingClientRect();
    return [
      ((e.clientX - rect.left) / rect.width  * w) | 0,
      ((e.clientY - rect.top)  / rect.height * h) | 0
    ];
  }
  hero.addEventListener('pointermove', function (e) {
    var p = toSim(e);
    drop(p[0], p[1], MOVE_RADIUS, DROP_STRENGTH * 0.35);
  });
  hero.addEventListener('pointerdown', function (e) {
    var p = toSim(e);
    drop(p[0], p[1], CLICK_RADIUS, DROP_STRENGTH);
  });

  // ── (7) Keep the aspect ratio correct, and only run while visible ──
  // ResizeObserver rather than window resize: the hero is usually measured
  // before the surrounding layout has settled.
  if ('ResizeObserver' in window) {
    new ResizeObserver(function () { resize(); }).observe(hero);
  } else {
    window.addEventListener('resize', resize);
  }
  document.addEventListener('visibilitychange', function () {
    document.hidden ? stop() : start();
  });
  if ('IntersectionObserver' in window) {
    new IntersectionObserver(function (entries) {
      entries[0].isIntersecting ? start() : stop();
    }, { threshold: 0 }).observe(hero);
  }

  resize();
  start();
})();
</script>
.water-hero {
  position: relative;
  overflow: hidden;
  border-radius: 20px;
  height: clamp(280px, 45vw, 380px);
  background: #03131f;
  cursor: crosshair;
  touch-action: none;           /* don't scroll while dragging on the surface */
}
.water-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;                 /* small buffer stretched by CSS */
  display: block;
}
.water-overlay {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 12px;
  padding: clamp(20px, 5vw, 44px);
  pointer-events: none;         /* let pointer events reach the canvas */
  color: #f2fbff;
}
import { useEffect, useRef } from "react";
import "./M-016.css";

export default function WaterRippleHero({
  simWidth = 360,
  damping = 0.96,
  dropStrength = 220,
  refraction = 1.2,
  specular = 1.2,
  moveRadius = 2,
  clickRadius = 6,
  rainInterval = 900,
  children,
}) {
  const heroRef = useRef(null);
  const canvasRef = useRef(null);
  // Live parameter values, so prop changes don't restart the simulation
  const opts = useRef({});
  opts.current = { damping, dropStrength, refraction, specular, moveRadius, clickRadius, rainInterval };

  useEffect(() => {
    const hero = heroRef.current;
    const canvas = canvasRef.current;
    if (!hero || !canvas) return;
    const ctx = canvas.getContext("2d", { alpha: false });

    let w = 0, h = 0;
    let prev = null, curr = null;
    let srcData = null, outData = null;
    let rafId = null, lastRain = 0, running = false;

    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    function buildSource() {
      const off = document.createElement("canvas");
      off.width = w;
      off.height = h;
      const c = off.getContext("2d");

      const g = c.createLinearGradient(0, 0, 0, h);
      g.addColorStop(0, "#0a3a5c");
      g.addColorStop(0.55, "#06263f");
      g.addColorStop(1, "#03131f");
      c.fillStyle = g;
      c.fillRect(0, 0, w, h);

      const orbs = [
        [0.22, 0.28, 0.42, "rgba(56, 189, 248, 0.55)"],
        [0.74, 0.22, 0.34, "rgba(45, 212, 191, 0.45)"],
        [0.52, 0.86, 0.46, "rgba(14, 116, 144, 0.55)"],
        [0.92, 0.72, 0.3, "rgba(129, 230, 255, 0.35)"],
      ];
      for (const [ox, oy, orad, color] of orbs) {
        const r = orad * w;
        const rg = c.createRadialGradient(ox * w, oy * h, 0, ox * w, oy * h, r);
        rg.addColorStop(0, color);
        rg.addColorStop(1, "rgba(0,0,0,0)");
        c.fillStyle = rg;
        c.fillRect(0, 0, w, h);
      }

      c.globalAlpha = 0.05;
      c.fillStyle = "#d9f6ff";
      for (let y = 0; y < h; y += 4) c.fillRect(0, y, w, 1);
      c.globalAlpha = 1;

      srcData = c.getImageData(0, 0, w, h);
      outData = ctx.createImageData(w, h);
      // Border pixels are never rewritten by draw(), so seed them from the source
      outData.data.set(srcData.data);
    }

    function resize() {
      const rect = hero.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      const nextH = Math.max(2, Math.round((simWidth * rect.height) / rect.width));
      if (srcData && w === simWidth && h === nextH) return;
      w = simWidth;
      h = nextH;
      canvas.width = w;
      canvas.height = h;
      prev = new Float32Array(w * h);
      curr = new Float32Array(w * h);
      buildSource();
    }

    function drop(cx, cy, radius, strength) {
      const r2 = radius * radius;
      for (let y = -radius; y <= radius; y++) {
        for (let x = -radius; x <= radius; x++) {
          const d2 = x * x + y * y;
          if (d2 > r2) continue;
          const px = cx + x, py = cy + y;
          if (px < 1 || py < 1 || px > w - 2 || py > h - 2) continue;
          // Cosine falloff: a hard-edged disc reads as a white speck, not a drop
          const falloff = 0.5 + 0.5 * Math.cos((Math.PI * Math.sqrt(d2)) / radius);
          prev[py * w + px] -= strength * falloff;
        }
      }
    }

    function step() {
      const d = opts.current.damping;
      for (let y = 1; y < h - 1; y++) {
        let i = y * w + 1;
        for (let x = 1; x < w - 1; x++, i++) {
          curr[i] = ((prev[i - 1] + prev[i + 1] + prev[i - w] + prev[i + w]) * 0.5 - curr[i]) * d;
        }
      }
      const t = prev;
      prev = curr;
      curr = t;
    }

    function draw() {
      const { refraction: rf, specular: sp } = opts.current;
      const src = srcData.data, out = outData.data;
      for (let y = 1; y < h - 1; y++) {
        let i = y * w + 1;
        for (let x = 1; x < w - 1; x++, i++) {
          const xo = prev[i - 1] - prev[i + 1];
          const yo = prev[i - w] - prev[i + w];

          let sx = x + xo * rf;
          let sy = y + yo * rf;
          sx = sx < 0 ? 0 : sx > w - 1 ? w - 1 : sx;
          sy = sy < 0 ? 0 : sy > h - 1 ? h - 1 : sy;

          const si = ((sy | 0) * w + (sx | 0)) * 4;
          const p = i * 4;
          const lit = (xo + yo) * sp;

          const r = src[si] + lit;
          const g = src[si + 1] + lit;
          const b = src[si + 2] + lit;
          out[p]     = r < 0 ? 0 : r > 255 ? 255 : r;
          out[p + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
          out[p + 2] = b < 0 ? 0 : b > 255 ? 255 : b;
          out[p + 3] = 255;
        }
      }
      ctx.putImageData(outData, 0, 0);
    }

    function frame(now) {
      rafId = requestAnimationFrame(frame);
      // The hero can be measured as zero-sized before layout settles
      if (!srcData) return;
      const { rainInterval: ri, dropStrength: ds } = opts.current;
      if (!reduceMotion && ri > 0 && now - lastRain > ri) {
        lastRain = now;
        drop(2 + ((Math.random() * (w - 4)) | 0), 2 + ((Math.random() * (h - 4)) | 0), 3, ds * 0.7);
      }
      step();
      draw();
    }

    const start = () => {
      if (!running) {
        running = true;
        rafId = requestAnimationFrame(frame);
      }
    };
    const stop = () => {
      if (running) {
        running = false;
        cancelAnimationFrame(rafId);
      }
    };

    function toSim(e) {
      const rect = hero.getBoundingClientRect();
      return [
        (((e.clientX - rect.left) / rect.width) * w) | 0,
        (((e.clientY - rect.top) / rect.height) * h) | 0,
      ];
    }
    const onMove = (e) => {
      const [x, y] = toSim(e);
      drop(x, y, opts.current.moveRadius, opts.current.dropStrength * 0.35);
    };
    const onDown = (e) => {
      const [x, y] = toSim(e);
      drop(x, y, opts.current.clickRadius, opts.current.dropStrength);
    };
    const onVisibility = () => (document.hidden ? stop() : start());

    resize();
    hero.addEventListener("pointermove", onMove);
    hero.addEventListener("pointerdown", onDown);
    document.addEventListener("visibilitychange", onVisibility);

    // ResizeObserver, not window resize: the hero is often measured before the
    // surrounding layout has settled, which would leave the aspect ratio wrong.
    const ro = new ResizeObserver(() => resize());
    ro.observe(hero);

    const io = new IntersectionObserver(
      ([entry]) => (entry.isIntersecting ? start() : stop()),
      { threshold: 0 }
    );
    io.observe(hero);

    return () => {
      stop();
      io.disconnect();
      ro.disconnect();
      hero.removeEventListener("pointermove", onMove);
      hero.removeEventListener("pointerdown", onDown);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [simWidth]);

  return (
    <div className="water-hero" ref={heroRef}>
      <canvas className="water-canvas" ref={canvasRef} aria-hidden="true" />
      <div className="water-overlay">{children}</div>
    </div>
  );
}
.water-hero {
  position: relative;
  overflow: hidden;
  border-radius: 20px;
  height: clamp(280px, 45vw, 380px);
  background: #03131f;
  cursor: crosshair;
  /* Prevent scroll hijack while dragging on touch devices */
  touch-action: none;
}

.water-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.water-overlay {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 12px;
  padding: clamp(20px, 5vw, 44px);
  /* Let pointer events reach the canvas underneath */
  pointer-events: none;
  color: #f2fbff;
}

.water-overlay h3 {
  margin: 0;
  font-size: clamp(24px, 4.2vw, 38px);
  font-weight: 700;
  line-height: 1.2;
  text-shadow: 0 2px 18px rgba(0, 20, 35, 0.6);
}

.water-overlay p {
  margin: 0;
  max-width: 34em;
  font-size: clamp(13px, 1.6vw, 15px);
  line-height: 1.75;
  color: rgba(226, 247, 255, 0.78);
  text-shadow: 0 1px 12px rgba(0, 20, 35, 0.6);
}

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

A source texture is drawn on an offscreen canvas and read back with getImageData. Two Float32Array buffers hold the water height; each frame computes curr[i] = ((prev[i-1] + prev[i+1] + prev[i-w] + prev[i+w]) / 2 - curr[i]) * damping and swaps them, a finite-difference form of the 2D wave equation. The render pass takes the height gradient, offsets the source sampling position by it, and adds the same gradient to luminance for a glint. The buffer stays at roughly 360px wide and is stretched by CSS, so cost is constant and the upscaling blur reads as soft water.

オフスクリーン canvas にソーステクスチャを描き、getImageData で読み出します。水面の高さは Float32Array 2枚に保持し、毎フレーム curr[i] = ((prev[i-1] + prev[i+1] + prev[i-w] + prev[i+w]) / 2 - curr[i]) * damping を計算してバッファを入れ替えます。これは2次元波動方程式の差分近似です。描画時は高さの勾配でソースのサンプリング位置をずらし、同じ勾配を輝度に加えてハイライトを作ります。バッファは横360px 程度に固定して CSS で引き伸ばすため、計算量は一定に保たれ、拡大によるにじみが水らしい柔らかさになります。

カスタマイズ方法Customization

Swap the procedural source for ctx.drawImage(img, 0, 0, w, h) to ripple a photo. Tune damping for how long ripples live, dropStrength for wave height, refraction for distortion, and specular for the glint. Lower SIM_WIDTH on low-end devices.

buildSource のオフスクリーン描画を ctx.drawImage(img, 0, 0, w, h) に差し替えれば写真を波紋で歪ませられます。damping で波の持続、dropStrength で波の高さ、refraction で歪み、specular でハイライトを調整できます。低スペック端末では SIM_WIDTH を下げてください。

注意点Caveats

The source image must be same-origin or CORS-enabled, otherwise getImageData throws a SecurityError on a tainted canvas. Damping above about 0.995 lets energy accumulate until the surface degenerates into noise. Cost scales with the square of SIM_WIDTH, so keep the buffer small and let CSS stretch it. Mark the canvas aria-hidden and keep meaningful text in the DOM overlay.

ソース画像は同一オリジンか CORS 対応が必要です。そうでないと canvas が汚染され getImageData が SecurityError を投げます。damping が 0.995 を超えるとエネルギーが蓄積し、水面がノイズ化することがあります。計算量は SIM_WIDTH の2乗に比例するため、バッファは小さく保って CSS で引き伸ばしてください。canvas は aria-hidden にし、意味のあるテキストは DOM オーバーレイ側に置きます。

よくある質問 Frequently Asked Questions

Does this need WebGL or a library? WebGL やライブラリは必要ですか?

No. It runs on a plain 2D canvas context and manipulates ImageData directly, so it works anywhere canvas does.

不要です。素の Canvas 2D コンテキストで ImageData を直接操作しているため、canvas が動く環境ならそのまま動作します。

Can I ripple a real photo instead of the generated background? 生成した背景ではなく実際の写真を波紋で歪ませられますか?

Yes. Replace the offscreen drawing in buildSource with ctx.drawImage(img, 0, 0, w, h). The image must be same-origin or served with CORS, or getImageData will throw a SecurityError.

できます。buildSource のオフスクリーン描画を ctx.drawImage(img, 0, 0, w, h) に差し替えてください。画像は同一オリジンか CORS 対応である必要があり、そうでないと getImageData が SecurityError を投げます。

Is it heavy enough to hurt performance? パフォーマンスへの影響は大きいですか?

The simulation buffer is fixed at about 360px wide, roughly 70k pixels across two loops per frame, which holds 60fps on ordinary laptops. The loop also stops while the hero is offscreen or the tab is hidden.

シミュレーションバッファは横360px 程度に固定しており、1フレームあたり約7万ピクセル×2ループです。一般的なノートPCで 60fps を維持できます。ヒーローが画面外にあるときや非表示タブではループ自体を停止します。

How do I keep the headline sharp over the canvas? canvas の上に載せる見出しをぼかさないには?

Keep the text in a regular DOM overlay above the canvas and give the overlay pointer-events: none so ripples still follow the pointer.

テキストは canvas の上に重ねた通常の DOM に置いてください。オーバーレイに pointer-events: none を指定すれば、ポインタ操作は canvas に届くので波紋も動きます。

AIへの指示テンプレート AI Prompt Template

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.