M-019 Background advanced

How to build a water caustics hero background with Canvas Canvas で作る水底の光の揺らぎ(コースティクス)FV背景

A caustics light-net hero background that looks like sunlight rippling on a pool floor, computed on a tiny canvas and stretched with CSS. プールの底で揺れる日差しのような光の網目を、小さなCanvasで計算してCSSで引き伸ばすヒーロー背景です。

ライブデモ Live Demo

Aqua Studio

水底からゆらぐ光 Light from the pool floor

1.0
1.0
1.0

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

何ができるかWhat it does

A hero background that draws caustics — the moving net of bright lines sunlight makes on a pool floor — on a canvas a quarter of the hero's size and stretches it with CSS.

日差しがプールの底に作る明るい網目(コースティクス)を、ヒーローの約1/4サイズのCanvasに描き、CSSで引き伸ばす背景です。

どこで使うかWhere to use

Use it behind the headline of spa, hotel, swimming, aquarium, beverage or marine brand sites where a calm, premium water feeling matters more than a literal picture.

スパ・ホテル・スイミング・水族館・飲料・マリン系のサイトで、写真よりも上質で静かな水の質感を出したい見出し背景に向きます。

特徴Key features

Per-pixel caustics from layered sine interference, pointer drift, a click splash that speeds the water and decays, a coloured water body that deepens toward the bottom, resize tracking, a loop that pauses offscreen or in a hidden tab, and a still frame under prefers-reduced-motion.

重ねた正弦波の干渉によるピクセル単位のコースティクス、カーソル追従、減衰するクリック時の加速、下ほど深くなる水の色、リサイズ追従、画面外・非表示タブでの停止、prefers-reduced-motion での静止描画に対応します。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
scale1Pattern size. Larger gives a finer net.
speed1How fast time advances. 0 freezes the pattern.
glow1Light intensity. Too high washes out text.
PIXEL4CSS pixels per canvas pixel. Larger is lighter but coarser.
r0 / g0 / b014 / 92 / 132Base water colour at the top of the hero.

実装コード Implementation Code

<!-- (1) Hero: a low-resolution canvas behind the copy -->
<section class="caustic-hero" id="caustic_hero">
  <canvas class="caustic-canvas" id="caustic_canvas" aria-hidden="true"></canvas>
  <div class="caustic-copy">
    <p class="caustic-eyebrow">Aqua Studio</p>
    <h2 class="caustic-title">Light from the pool floor</h2>
  </div>
</section>

<script>
(function () {
  const TAU = 6.28318;
  const PIXEL = 4;                                  // CSS pixels per canvas pixel
  const opts = { scale: 1, speed: 1, glow: 1 };

  const hero = document.getElementById('caustic_hero');
  const canvas = document.getElementById('caustic_canvas');
  const ctx = canvas.getContext('2d');

  let w = 0, h = 0, img = null;
  let t = 0, last = 0, rafId = null, running = false;
  let boost = 0;                                    // click impulse that speeds the water up
  let ox = 0, oy = 0, tx = 0, ty = 0;               // pointer parallax (current / target)

  // (2) Per-pixel caustics: layered, domain-warped sine interference
  function draw() {
    const d = img.data;
    const unit = opts.scale * 1.6 / h;               // tiles per canvas pixel
    const glow = opts.glow * 255;
    for (let y = 0; y < h; y++) {
      const depth = y / h;
      // Water body: lighter near the top, deeper at the bottom
      const r0 = 14 - depth * 10, g0 = 92 - depth * 52, b0 = 132 - depth * 56;
      for (let x = 0; x < w; x++) {
        // The slow bend breaks the pattern's built-in tiling on wide heroes
        const px = (x * unit + ox + 0.18 * Math.sin(y * unit * 1.7 + t * 0.15)) * TAU - 250;
        const py = (y * unit + oy + 0.18 * Math.sin(x * unit * 1.3 - t * 0.12)) * TAU - 250;
        let ix = px, iy = py, c = 1;
        for (let n = 0; n < 4; n++) {
          const tn = t * (1 - 3.5 / (n + 1));
          const nx = px + Math.cos(tn - ix) + Math.sin(tn + iy);
          const ny = py + Math.sin(tn - iy) + Math.cos(tn + ix);
          ix = nx; iy = ny;
          const a = px / (Math.sin(ix + tn) / 0.005);
          const b = py / (Math.cos(iy + tn) / 0.005);
          c += 1 / Math.sqrt(a * a + b * b);
        }
        c = 1.17 - Math.pow(c / 4, 1.4);
        const k = Math.min(1, Math.pow(Math.abs(c), 8)) * (1 - depth * 0.35);
        const i = (y * w + x) * 4;
        d[i]     = Math.min(255, r0 + k * glow * 0.55);
        d[i + 1] = Math.min(255, g0 + k * glow * 0.92);
        d[i + 2] = Math.min(255, b0 + k * glow);
        d[i + 3] = 255;
      }
    }
    ctx.putImageData(img, 0, 0);
  }

  function resize() {
    const r = hero.getBoundingClientRect();
    if (!r.width || !r.height) return;
    w = Math.max(40, Math.round(r.width / PIXEL));
    h = Math.max(40, Math.round(r.height / PIXEL));
    canvas.width = w;
    canvas.height = h;
    img = ctx.createImageData(w, h);
    draw();
  }

  function frame(now) {
    rafId = requestAnimationFrame(frame);
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    t += dt * opts.speed * (1 + boost * 4);
    boost *= Math.exp(-dt * 2.2);
    ox += (tx - ox) * 0.06;
    oy += (ty - oy) * 0.06;
    draw();
  }

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

  // (3) Pointer: the pattern drifts with the cursor; a click is a splash that speeds the water up
  hero.addEventListener('pointermove', (e) => {
    const r = hero.getBoundingClientRect();
    tx = ((e.clientX - r.left) / r.width - 0.5) * 0.5;
    ty = ((e.clientY - r.top) / r.height - 0.5) * 0.5;
  });
  hero.addEventListener('pointerdown', () => { boost = 1; });

  // (4) Keep the canvas matched to the hero size
  new ResizeObserver(resize).observe(hero);

  // (5) Reduced motion: one still frame, no loop
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { t = 3; return; }
  document.addEventListener('visibilitychange', () => { document.hidden ? stop() : start(); });
  if ('IntersectionObserver' in window) {
    new IntersectionObserver((e) => { e[0].isIntersecting ? start() : stop(); }).observe(hero);
  } else { start(); }
})();
</script>
.caustic-hero {
  position: relative;
  width: 100%;
  overflow: hidden;
  min-height: 240px;
  aspect-ratio: 16 / 9;
  border-radius: 24px;
  background: #06304a;
  touch-action: pan-y;
}
.caustic-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.caustic-copy {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  padding: clamp(20px, 6vw, 56px);
  color: #fff;
  text-shadow: 0 2px 18px rgba(2, 24, 43, 0.65);  /* keeps the copy readable over bright caustics */
  pointer-events: none;
}
.caustic-eyebrow {
  margin: 0;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: #a5f3fc;
}
.caustic-title {
  margin: 0;
  font-size: clamp(24px, 5vw, 44px);
  line-height: 1.2;
  font-weight: 700;
}
import { useEffect, useRef } from "react";
import "./M-019.css";

const TAU = 6.28318;
const PIXEL = 4;

export default function WaterCaustics({ scale = 1, speed = 1, glow = 1, eyebrow = "Aqua Studio", title = "Light from the pool floor" }) {
  const heroRef = useRef(null);
  const canvasRef = useRef(null);
  // Live values, so prop changes don't restart the loop
  const opts = useRef({ scale, speed, glow });
  opts.current = { scale, speed, glow };

  useEffect(() => {
    const hero = heroRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas && canvas.getContext("2d");
    if (!hero || !ctx) return undefined;

    let w = 0, h = 0, img = null;
    let t = 0, last = 0, rafId = null, running = false;
    let boost = 0, ox = 0, oy = 0, tx = 0, ty = 0;

    function draw() {
      const d = img.data;
      const unit = opts.current.scale * 1.6 / h;               // tiles per canvas pixel
      const glow = opts.current.glow * 255;
      for (let y = 0; y < h; y++) {
        const depth = y / h;
        // Water body: lighter near the top, deeper at the bottom
        const r0 = 14 - depth * 10, g0 = 92 - depth * 52, b0 = 132 - depth * 56;
        for (let x = 0; x < w; x++) {
          // The slow bend breaks the pattern's built-in tiling on wide heroes
          const px = (x * unit + ox + 0.18 * Math.sin(y * unit * 1.7 + t * 0.15)) * TAU - 250;
          const py = (y * unit + oy + 0.18 * Math.sin(x * unit * 1.3 - t * 0.12)) * TAU - 250;
          let ix = px, iy = py, c = 1;
          for (let n = 0; n < 4; n++) {
            const tn = t * (1 - 3.5 / (n + 1));
            const nx = px + Math.cos(tn - ix) + Math.sin(tn + iy);
            const ny = py + Math.sin(tn - iy) + Math.cos(tn + ix);
            ix = nx; iy = ny;
            const a = px / (Math.sin(ix + tn) / 0.005);
            const b = py / (Math.cos(iy + tn) / 0.005);
            c += 1 / Math.sqrt(a * a + b * b);
          }
          c = 1.17 - Math.pow(c / 4, 1.4);
          const k = Math.min(1, Math.pow(Math.abs(c), 8)) * (1 - depth * 0.35);
          const i = (y * w + x) * 4;
          d[i]     = Math.min(255, r0 + k * glow * 0.55);
          d[i + 1] = Math.min(255, g0 + k * glow * 0.92);
          d[i + 2] = Math.min(255, b0 + k * glow);
          d[i + 3] = 255;
        }
      }
      ctx.putImageData(img, 0, 0);
    }

    function resize() {
      const r = hero.getBoundingClientRect();
      if (!r.width || !r.height) return;
      w = Math.max(40, Math.round(r.width / PIXEL));
      h = Math.max(40, Math.round(r.height / PIXEL));
      canvas.width = w;
      canvas.height = h;
      img = ctx.createImageData(w, h);
      draw();
    }

    function frame(now) {
      rafId = requestAnimationFrame(frame);
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      t += dt * opts.current.speed * (1 + boost * 4);
      boost *= Math.exp(-dt * 2.2);
      ox += (tx - ox) * 0.06;
      oy += (ty - oy) * 0.06;
      draw();
    }

    const start = () => { if (!running) { running = true; last = 0; rafId = requestAnimationFrame(frame); } };
    const stop = () => { if (running) { running = false; cancelAnimationFrame(rafId); } };
    const onMove = (e) => {
      const r = hero.getBoundingClientRect();
      tx = ((e.clientX - r.left) / r.width - 0.5) * 0.5;
      ty = ((e.clientY - r.top) / r.height - 0.5) * 0.5;
    };
    const onDown = () => { boost = 1; };
    const onVisibility = () => (document.hidden ? stop() : start());

    hero.addEventListener("pointermove", onMove);
    hero.addEventListener("pointerdown", onDown);
    const ro = new ResizeObserver(resize);
    ro.observe(hero);

    let io = null;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      t = 3;
    } else {
      document.addEventListener("visibilitychange", onVisibility);
      io = new IntersectionObserver((e) => (e[0].isIntersecting ? start() : stop()));
      io.observe(hero);
    }

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

  return (
    <section className="caustic-hero" ref={heroRef}>
      <canvas className="caustic-canvas" ref={canvasRef} aria-hidden="true" />
      <div className="caustic-copy">
        <p className="caustic-eyebrow">{eyebrow}</p>
        <h2 className="caustic-title">{title}</h2>
      </div>
    </section>
  );
}
.caustic-hero {
  position: relative;
  width: 100%;
  overflow: hidden;
  min-height: 240px;
  aspect-ratio: 16 / 9;
  border-radius: 24px;
  background: #06304a;
  touch-action: pan-y;
}
.caustic-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.caustic-copy {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  padding: clamp(20px, 6vw, 56px);
  color: #fff;
  text-shadow: 0 2px 18px rgba(2, 24, 43, 0.65);  /* keeps the copy readable over bright caustics */
  pointer-events: none;
}
.caustic-eyebrow {
  margin: 0;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: #a5f3fc;
}
.caustic-title {
  margin: 0;
  font-size: clamp(24px, 5vw, 44px);
  line-height: 1.2;
  font-weight: 700;
}

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

仕組みHow it works

For each pixel p, four iterations warp i with i = p + (cos(tn−i.x)+sin(tn+i.y), sin(tn−i.y)+cos(tn+i.x)) where tn = t·(1−3.5/(n+1)), and accumulate c += 1/|p/(sin(i+tn)/0.005)|. Then c = 1.17 − (c/4)^1.4 and the brightness is |c|^8, which keeps only thin bright lines. The result is added to a vertical blue gradient. A click sets boost = 1, which speeds time up to 5× and decays exponentially; the pointer eases into an offset added to p.

各ピクセル p について、tn = t·(1−3.5/(n+1)) として4回 i = p + (cos(tn−i.x)+sin(tn+i.y), sin(tn−i.y)+cos(tn+i.x)) で揺らし、c += 1/|p/(sin(i+tn)/0.005)| を積算します。その後 c = 1.17 − (c/4)^1.4 とし、明るさを |c|^8 にすると細い明線だけが残ります。これを縦方向の青のグラデーションに加算します。クリックで boost = 1 とし時間を最大5倍に加速して指数減衰、カーソルはなめらかに追従するオフセットとして p に加わります。

カスタマイズ方法Customization

Change opts.scale for pattern size, opts.speed for flow speed and opts.glow for intensity. PIXEL sets the canvas resolution, and r0/g0/b0 in draw() set the water colour. Put a translucent plate behind the copy if you raise glow.

opts.scale で模様の大きさ、opts.speed で流れる速さ、opts.glow で光の強さを変えます。PIXEL でCanvasの解像度、draw() 内の r0/g0/b0 で水の色を調整できます。glow を上げる場合は文字の背後に半透明の下地を敷いてください。

注意点Caveats

Every pixel is computed in JavaScript, so keep PIXEL at 4 or higher on mobile. Strong highlights can wash out text: keep a text-shadow or plate for contrast. The canvas is decorative, so mark it aria-hidden and draw a single still frame under prefers-reduced-motion.

全ピクセルをJavaScriptで計算するため、モバイルでは PIXEL を4以上にしてください。強い光は文字を飲み込むので、text-shadow か下地でコントラストを確保します。Canvas は装飾なので aria-hidden を付け、prefers-reduced-motion では静止した1フレームだけを描いてください。

よくある質問 Frequently Asked Questions

Why is the canvas so small? なぜCanvasをこんなに小さくするのですか?

Computing every pixel in JavaScript is the cost. At a quarter of the size the canvas has 1/16 of the pixels, and CSS stretching softens the result into a convincing water surface.

全ピクセルをJavaScriptで計算するのがコストだからです。1/4サイズなら画素数は1/16になり、CSSで引き伸ばすとむしろ水面らしく柔らかく見えます。

How do I change the water colour? 水の色を変えるには?

Edit r0, g0 and b0 in draw() for the base colour and the three channel weights on k for the light colour. Greener values give a pond or hot-spring look.

draw() 内の r0・g0・b0 で水の基調色、k にかかる3チャンネルの係数で光の色を変えます。緑寄りにすると池や温泉風になります。

Can I put text on top? 上に文字を載せられますか?

Yes. Keep the copy in a separate layer with pointer-events: none and a text-shadow. If you raise glow, add a translucent plate behind the text.

可能です。文字は pointer-events: none の別レイヤーに置き、text-shadow を付けます。glow を上げるなら文字の背後に半透明の下地を敷いてください。

Is it heavy on mobile? モバイルで重くなりませんか?

A 390×220 hero is about 5,000 canvas pixels with four iterations each, which runs comfortably. Keep PIXEL at 4 or higher, and the loop already pauses offscreen.

390×220 のヒーローなら約5,000画素×4反復で、快適に動きます。PIXEL は4以上にし、画面外ではループが自動で止まります。

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

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