M-017 Animation medium

How to build an animated wave section divider with SVG SVG で作る波形アニメーションのセクション区切り

Turn a section boundary into a moving water line with layered SVG waves that react to the pointer. 重ねた SVG の波でセクションの境目を動く水面に変え、カーソル操作に反応させます。

ライブデモ Live Demo

Wave Divider

Ride the next wave

Layered sine waves drawn into SVG paths — a section divider that never sits still.

ホバーで盛り上がり / クリックで波紋 Hover to swell, click to ripple
Next section The front wave shares this background colour, so the divider needs no image or extra border.
18
1.0

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

何ができるかWhat it does

A section divider made of three stacked SVG wave layers that are redrawn every frame from summed sine waves. The water rises under the pointer, and a click sends a ripple outward.

正弦波の合成から毎フレーム描き直す3層の SVG 波を重ねたセクション区切りです。カーソルの下で水面が盛り上がり、クリックすると波紋が外側へ広がります。

どこで使うかWhere to use

Place it along the bottom of a hero or between two sections where a straight edge feels stiff. It suits pools, marine, bathing and other water-related brands, and any page that wants gentle motion without a heavy background effect.

ヒーローの下端や、直線の境目では硬く見える2つのセクションの間に置きます。プール・マリン・温浴など水に関わる業種や、重い背景エフェクトを使わずに穏やかな動きを出したいページに向いています。

特徴Key features

Three independent wave layers, irregular swells from opposing sine waves, a pointer-following Gaussian swell, click ripples that travel outward, a front layer that melts into the next section, and a loop that pauses offscreen or in a hidden tab.

独立した3層の波、逆向きの正弦波による不規則なうねり、カーソルに追従するガウス型の盛り上がり、外側へ伝わるクリック波紋、次のセクションに溶け込む最前面レイヤー、画面外・非表示タブでのループ停止に対応します。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
amplitude18Wave height in viewBox units, multiplied by each layer's amplitude factor.
speed1How fast time advances. 0 freezes the waves; changes never make them jump.
LAYERS3 layersPer-layer baseline, amplitude multiplier, speed, wave numbers k1 / k2 and phase.
POINTS40Samples per layer. More is finer but each frame rewrites longer path strings.
SWELL_WIDTH150Width of the Gaussian swell under the pointer, in viewBox units.
fill#f4f9fc (front)Fill colour per layer. The front layer must match the next section's background.

実装コード Implementation Code

<!-- (1) Section with the waves along its bottom edge -->
<section class="wave-band" id="wave_band">
  <div class="wave-copy">
    <h2>Ride the next wave</h2>
    <p>A section divider that never sits still.</p>
  </div>
  <svg class="wave-svg" id="wave_svg" viewBox="0 0 1200 200"
       preserveAspectRatio="none" aria-hidden="true" focusable="false">
    <path class="wave-layer-1"></path>
    <path class="wave-layer-2"></path>
    <path class="wave-layer-3"></path>
  </svg>
</section>
<section class="wave-next">…</section>

<script>
(function () {
  const VB_W = 1200, VB_H = 200;
  const POINTS = 40;          // samples per layer
  const SWELL_WIDTH = 150;    // pointer swell width (viewBox units)
  let amplitude = 18;         // wave height (viewBox units)
  let speed = 1;              // time multiplier

  const section = document.getElementById('wave_band');
  const paths = document.querySelectorAll('#wave_svg path');

  // (2) Back to front: baseline, amplitude multiplier, speed, wave numbers, phase
  const LAYERS = [
    { base: 70,  amp: 1.0, speed: 0.9,  k1: 2.0, k2: 3.7, phase: 0 },
    { base: 100, amp: 0.8, speed: -0.7, k1: 2.6, k2: 4.3, phase: 1.7 },
    { base: 132, amp: 0.6, speed: 1.2,  k1: 1.6, k2: 5.1, phase: 3.1 }
  ];

  let t = 0, last = 0, rafId = null, running = false;
  let hovering = false, pointerX = VB_W / 2;
  let swell = 0, swellX = VB_W / 2;
  let splashX = 0, splashAge = 99;   // 99 = no active ripple

  // (3) Height of one layer at horizontal position u (0..1)
  function waveY(layer, u) {
    const a = amplitude * layer.amp;
    const x = u * VB_W;
    let y = layer.base
      + (Math.sin(u * Math.PI * layer.k1 + t * layer.speed + layer.phase) * 0.65
      +  Math.sin(u * Math.PI * layer.k2 - t * layer.speed * 1.3 + layer.phase * 2) * 0.35) * a;

    const d = (x - swellX) / SWELL_WIDTH;
    y -= swell * Math.exp(-d * d) * a * 1.6;          // raise water under the pointer

    if (splashAge < 3) {                               // outward ripple after a click
      const dist = Math.abs(x - splashX);
      const env = Math.exp(-dist / 320) * Math.max(0, 1 - splashAge / 3);
      y += Math.sin(dist / 60 - splashAge * 8) * env * a * 1.4;
    }
    return y;
  }

  // (4) Samples joined by quadratic Béziers ending at neighbour midpoints
  function buildPath(layer) {
    const pts = [];
    for (let i = 0; i <= POINTS; i++) {
      const u = i / POINTS;
      pts.push([u * VB_W, waveY(layer, u)]);
    }
    let d = 'M0,' + VB_H + ' L0,' + pts[0][1].toFixed(1);
    for (let i = 1; i < pts.length - 1; i++) {
      const mx = (pts[i][0] + pts[i + 1][0]) / 2;
      const my = (pts[i][1] + pts[i + 1][1]) / 2;
      d += ' Q' + pts[i][0].toFixed(1) + ',' + pts[i][1].toFixed(1) + ' ' + mx.toFixed(1) + ',' + my.toFixed(1);
    }
    d += ' L' + VB_W + ',' + pts[pts.length - 1][1].toFixed(1) + ' L' + VB_W + ',' + VB_H + ' Z';
    return d;
  }

  function render() {
    LAYERS.forEach((layer, i) => paths[i].setAttribute('d', buildPath(layer)));
  }

  function frame(now) {
    rafId = requestAnimationFrame(frame);
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    t += dt * speed;                                   // accumulate: speed changes never jump
    splashAge += dt;
    swell += ((hovering ? 1 : 0) - swell) * 0.06;
    swellX += (pointerX - swellX) * 0.12;
    render();
  }

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

  // (5) Pointer input, converted to viewBox units
  function toViewBox(e) {
    const rect = section.getBoundingClientRect();
    return (e.clientX - rect.left) / rect.width * VB_W;
  }
  section.addEventListener('pointermove', (e) => { hovering = true; pointerX = toViewBox(e); });
  section.addEventListener('pointerleave', () => { hovering = false; });
  section.addEventListener('pointerdown', (e) => { splashX = toViewBox(e); splashAge = 0; });

  // (6) Reduced motion: one still frame, no loop
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    render();
    return;
  }

  document.addEventListener('visibilitychange', () => { document.hidden ? stop() : start(); });
  if ('IntersectionObserver' in window) {
    new IntersectionObserver((entries) => {
      entries[0].isIntersecting ? start() : stop();
    }, { threshold: 0 }).observe(section);
  } else {
    start();
  }
  render();
})();
</script>
.wave-band {
  position: relative;
  overflow: hidden;
  min-height: 340px;
  padding-bottom: clamp(70px, 12vw, 130px);  /* keep copy clear of the waves */
  background: linear-gradient(160deg, #0b4a6f 0%, #0a6c8f 45%, #0891b2 100%);
}
.wave-copy {
  position: relative;
  z-index: 1;
  padding: clamp(22px, 5vw, 44px);
  color: #fff;
  pointer-events: none;         /* let the section track the pointer */
}
.wave-svg {
  position: absolute;
  left: 0;
  bottom: -1px;                 /* hide the sub-pixel seam */
  width: 100%;
  height: clamp(80px, 14vw, 150px);
  display: block;
}
.wave-layer-1 { fill: rgba(165, 243, 252, 0.28); }
.wave-layer-2 { fill: rgba(103, 232, 249, 0.38); }
.wave-layer-3 { fill: #f4f9fc; }  /* same colour as .wave-next */
.wave-next    { background: #f4f9fc; }
import { useEffect, useRef } from "react";
import "./M-017.css";

const VB_W = 1200;
const VB_H = 200;

const LAYERS = [
  { base: 70, amp: 1.0, speed: 0.9, k1: 2.0, k2: 3.7, phase: 0 },
  { base: 100, amp: 0.8, speed: -0.7, k1: 2.6, k2: 4.3, phase: 1.7 },
  { base: 132, amp: 0.6, speed: 1.2, k1: 1.6, k2: 5.1, phase: 3.1 },
];

export default function WaveDivider({
  amplitude = 18,
  speed = 1,
  points = 40,
  swellWidth = 150,
  pointerSwell = true,
  children,
}) {
  const sectionRef = useRef(null);
  const pathRefs = useRef([]);
  // Live parameter values, so prop changes don't restart the loop
  const opts = useRef({});
  opts.current = { amplitude, speed, points, swellWidth, pointerSwell };

  useEffect(() => {
    const section = sectionRef.current;
    const paths = pathRefs.current;
    if (!section || paths.length !== LAYERS.length) return;

    let t = 0, last = 0, rafId = null, running = false;
    let hovering = false, pointerX = VB_W / 2;
    let swell = 0, swellX = VB_W / 2;
    let splashX = 0, splashAge = 99;

    function waveY(layer, u) {
      const { amplitude: amp, swellWidth: sw } = opts.current;
      const a = amp * layer.amp;
      const x = u * VB_W;
      let y =
        layer.base +
        (Math.sin(u * Math.PI * layer.k1 + t * layer.speed + layer.phase) * 0.65 +
          Math.sin(u * Math.PI * layer.k2 - t * layer.speed * 1.3 + layer.phase * 2) * 0.35) * a;

      const d = (x - swellX) / sw;
      y -= swell * Math.exp(-d * d) * a * 1.6;

      if (splashAge < 3) {
        const dist = Math.abs(x - splashX);
        const env = Math.exp(-dist / 320) * Math.max(0, 1 - splashAge / 3);
        y += Math.sin(dist / 60 - splashAge * 8) * env * a * 1.4;
      }
      return y;
    }

    function buildPath(layer) {
      const n = opts.current.points;
      const pts = [];
      for (let i = 0; i <= n; i++) {
        const u = i / n;
        pts.push([u * VB_W, waveY(layer, u)]);
      }
      let d = `M0,${VB_H} L0,${pts[0][1].toFixed(1)}`;
      for (let i = 1; i < pts.length - 1; i++) {
        const mx = (pts[i][0] + pts[i + 1][0]) / 2;
        const my = (pts[i][1] + pts[i + 1][1]) / 2;
        d += ` Q${pts[i][0].toFixed(1)},${pts[i][1].toFixed(1)} ${mx.toFixed(1)},${my.toFixed(1)}`;
      }
      d += ` L${VB_W},${pts[pts.length - 1][1].toFixed(1)} L${VB_W},${VB_H} Z`;
      return d;
    }

    const render = () => LAYERS.forEach((layer, i) => paths[i].setAttribute("d", buildPath(layer)));

    function frame(now) {
      rafId = requestAnimationFrame(frame);
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      t += dt * opts.current.speed;
      splashAge += dt;
      const target = hovering && opts.current.pointerSwell ? 1 : 0;
      swell += (target - swell) * 0.06;
      swellX += (pointerX - swellX) * 0.12;
      render();
    }

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

    const toViewBox = (e) => {
      const rect = section.getBoundingClientRect();
      return ((e.clientX - rect.left) / rect.width) * VB_W;
    };
    const onMove = (e) => {
      hovering = true;
      pointerX = toViewBox(e);
    };
    const onLeave = () => {
      hovering = false;
    };
    const onDown = (e) => {
      splashX = toViewBox(e);
      splashAge = 0;
    };
    const onVisibility = () => (document.hidden ? stop() : start());

    render();
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return undefined;

    section.addEventListener("pointermove", onMove);
    section.addEventListener("pointerleave", onLeave);
    section.addEventListener("pointerdown", onDown);
    document.addEventListener("visibilitychange", onVisibility);

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

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

  return (
    <section className="wave-band" ref={sectionRef}>
      <div className="wave-copy">{children}</div>
      <svg
        className="wave-svg"
        viewBox={`0 0 ${VB_W} ${VB_H}`}
        preserveAspectRatio="none"
        aria-hidden="true"
        focusable="false"
      >
        {LAYERS.map((layer, i) => (
          <path
            key={i}
            className={`wave-layer-${i + 1}`}
            ref={(el) => (pathRefs.current[i] = el)}
          />
        ))}
      </svg>
    </section>
  );
}
.wave-band {
  position: relative;
  overflow: hidden;
  min-height: 340px;
  padding-bottom: clamp(70px, 12vw, 130px);  /* keep copy clear of the waves */
  background: linear-gradient(160deg, #0b4a6f 0%, #0a6c8f 45%, #0891b2 100%);
}

.wave-copy {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: clamp(22px, 5vw, 44px);
  color: #fff;
  /* Pointer events pass through so the section can track the pointer */
  pointer-events: none;
}

.wave-svg {
  position: absolute;
  left: 0;
  /* Hide the sub-pixel seam against the next section */
  bottom: -1px;
  width: 100%;
  height: clamp(80px, 14vw, 150px);
  display: block;
}

.wave-layer-1 {
  fill: rgba(165, 243, 252, 0.28);
}

.wave-layer-2 {
  fill: rgba(103, 232, 249, 0.38);
}

/* Must match the background of the section that follows */
.wave-layer-3 {
  fill: #f4f9fc;
}

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

仕組みHow it works

The SVG uses viewBox="0 0 1200 200" with preserveAspectRatio="none" so it stretches to any width. For each layer, about 40 samples are computed as base + (sin(u·π·k1 + t·speed + phase)·0.65 + sin(u·π·k2 − t·speed·1.3 + 2·phase)·0.35)·amplitude, then joined with quadratic Béziers ending at neighbour midpoints for a smooth curve. A Gaussian term raises the water at the eased pointer position, and a click adds sin(dist/60 − age·8)·exp(−dist/320), a ripple that fades over three seconds. Time accumulates as t += dt·speed, so changing speed never makes the waves jump.

SVG は viewBox="0 0 1200 200" と preserveAspectRatio="none" で任意の横幅に引き伸ばします。各レイヤーで約40点を base + (sin(u·π·k1 + t·speed + phase)·0.65 + sin(u·π·k2 − t·speed·1.3 + 2·phase)·0.35)·amplitude として計算し、隣り合う点の中点を終点とする二次ベジェでつないで滑らかな曲線にします。イージングしたカーソル位置にはガウス関数で盛り上がりを加え、クリック時は sin(dist/60 − age·8)·exp(−dist/320) の波紋を3秒かけて減衰させます。時間は t += dt·speed で積算するため、速度を変えても波が飛びません。

カスタマイズ方法Customization

Change the layer fills to match your palette, keeping the front layer identical to the next section's background. Tune amplitude for wave height and speed for pace, edit the LAYERS table to change baselines, wave numbers and phases, and adjust SWELL_WIDTH for a wider or tighter swell. Flip the SVG with scaleY(-1) to place waves on a section's top edge.

各レイヤーの塗りをパレットに合わせて変更します。最前面だけは次セクションの背景色と必ず揃えてください。amplitude で波の高さ、speed で速さを調整し、LAYERS の表で基準線・波数・位相を、SWELL_WIDTH で盛り上がりの幅を変えられます。SVG に scaleY(-1) を掛ければセクションの上端にも置けます。

注意点Caveats

If the front layer colour differs from the next section, a visible band appears; keep them in sync, ideally through a shared CSS custom property. A sub-pixel seam can show at some zoom levels, which bottom: -1px hides. Keep POINTS between about 30 and 60, since every frame rewrites three path strings. The SVG is decorative, so mark it aria-hidden and focusable="false", and draw a single still frame under prefers-reduced-motion.

最前面レイヤーの色が次セクションと違うと境目に帯が見えるため、CSS カスタムプロパティを共有するなどして揃えてください。拡大率によってはサブピクセルの継ぎ目が出ることがあり、bottom: -1px で隠しています。毎フレーム3本のパス文字列を書き換えるので、POINTS は 30〜60 程度に抑えます。SVG は装飾なので aria-hidden と focusable="false" を付け、prefers-reduced-motion では静止した波を1回だけ描画してください。

よくある質問 Frequently Asked Questions

Can I make the waves without JavaScript? JavaScript なしで波を動かせますか?

Yes, for a simpler effect. Tile a fixed wave path twice and loop translateX(-50%) with CSS keyframes. You lose the irregular swell, pointer response and click ripples, so it suits footers and other non-interactive areas.

簡易版なら可能です。固定形状の波パスを2枚並べ、CSS の keyframes で translateX(-50%) をループさせます。不規則なうねりやカーソルへの反応、クリック波紋は失われるため、フッターなどインタラクション不要な場所に向きます。

How do I put the waves on the top edge of a section? セクションの上端に波を置くには?

Flip the SVG with transform: scaleY(-1), position it at top: -1px, and fill the front layer with the previous section's background colour.

SVG に transform: scaleY(-1) を掛けて top: -1px に配置し、最前面レイヤーを前のセクションの背景色で塗ってください。

Will it slow the page down? ページが重くなりませんか?

Each frame computes 3 layers × 41 points and makes three setAttribute calls, which is lighter than canvas pixel effects. The loop also stops while the section is offscreen or the tab is hidden.

1フレームあたり 3レイヤー × 41点の計算と setAttribute 3回だけで、canvas のピクセル処理より軽量です。セクションが画面外にあるときや非表示タブではループ自体を止めます。

Why does a thin line appear between the wave and the next section? 波と次のセクションの間に細い線が見えるのはなぜですか?

It is a sub-pixel rounding seam. Offset the SVG with bottom: -1px and make sure the front layer uses exactly the same colour as the next section.

サブピクセルの丸めによる継ぎ目です。SVG を bottom: -1px でずらし、最前面レイヤーが次セクションとまったく同じ色になっているか確認してください。

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

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