【CSS+SVG】波型の中を写真が流れるセクションの作り方

css

はじめに

みなさん、こんにちは!
たくまです!

みなさんはLPやブランドサイトで以下のようなセクションを見たことはありませんか?



上記のように波型に切り抜かれた中を画像が横に流れていくセクションですが、意外と作り方が分かりにくいです。

この記事では①波型の枠をつくる → ②そこに画像群を当てはめる → ③横流しアニメーションを付けるの3ステップで、初学者の方でも再現できるように丁寧に解説します。

完成イメージは先の動画のとおり、波型クリップの中に横並びの画像列が入り、無限ループで左へ流れる構造です。記事内のコードは、本文にある最小構成に置き換えても動作します。

全体の考え方(仕組み)

  1. 波型の枠=マスクをSVGのclipPathで作り、clip-path: url(#waveClip)で要素を切り抜く。
  2. 枠の内側に横並び(flex)**の画像群を配置。
  3. 画像列を2周分用意(=横幅200%)して、translateX(-100%)まで動かす無限ループで継ぎ目をなくす。

作成方法

① 波型の枠(クリップ)を作成する

なぜclipPathを使うのかですが、CSSclip-path: path()でも波型は描けますが、ブラウザ間の実装差編集時の見通しを考えると、SVGのclipPathを定義して参照するのが安定・安全です。
mask-imageでも近い表現は可能ですが、透明度ベースなのでアンチエイリアスの差や色のにじみ
が気になるケースがあります。幾何学的な輪郭で切り抜きたいならclipPathがベターです。

<div class="gallery">
  <!-- ここに波型クリップの定義 -->
  <svg width="0" height="0" aria-hidden="true" style="position:absolute">
    <defs>
      <clipPath id="waveClip" clipPathUnits="objectBoundingBox">
        <path d="
          M 0,0.12
          C 0.083,0.16 0.167,0.08 0.25,0.12
          S 0.417,0.16 0.50,0.12
          S 0.667,0.08 0.75,0.12
          S 0.917,0.16 1.00,0.12
          V 0.88
          C 0.917,0.84 0.833,0.92 0.75,0.88
          S 0.583,0.84 0.50,0.88
          S 0.333,0.92 0.25,0.88
          S 0.083,0.84 0.00,0.88
          Z" />
      </clipPath>
    </defs>
  </svg>

  <div class="gallery__inner">
    <!-- 後述:画像の横並び -->
  </div>
</div>

clipPathUnits="objectBoundingBox" とは?

  • パス中の座標を0〜1の割合で扱える設定です。
  • M 0,0.12は「左端の少し下(高さ12%)」、1.00,0.12は「右端の同じ高さ」を意味します。
  • どんな横幅・高さでも同じ形状に伸縮してくれるので、レスポンシブ向き。

波型パスの読み方

  • M:開始位置(MoveTo)
  • C x1,y1 x2,y2 x,yベジェ曲線の制御点2つ+終点。
  • S x2,y2 x,y:直前の曲線の方向を引き継ぐ滑らか曲線
  • V 0.88縦線で0.88(=88%の高さ)まで下ろす。
  • 最後のZでパスを閉じる(輪郭がつながる)。

上側にさざ波、下側に逆向きのさざ波を描いており、上下とも滑らかになるようSを多用しています。
波の高さを変えたいときは、0.12 / 0.16 / 0.08などの値を0.1刻み程度で触ると雰囲気が変わります。

🔰コツ:
一度clipPathUnits="userSpaceOnUse"viewBox="0 0 100 100"に変えて、
M 0,12 …のように0〜100の座標で試し書きすると形がつかみやすいです。
形が決まったら、100で割って0〜1に戻すと失敗しにくいです。

クリップを要素に適用

.gallery {
  background:#fbf8f9;     /* 波の外側の色 */
  width:100%;
}
.gallery__inner {
  width:100%;
  aspect-ratio:16 / 8;     /* 高さは必ず決める(比率 or 固定高) */
  clip-path:url(#waveClip); /* ここで波型に切り抜く */
  overflow:hidden;          /* はみ出しを消す */
}

❗注意:width="0" height="0"にしておくとレイアウトに影響しない

② 波型の中に画像群を流し込む(横並び)

横スクロールの継ぎ目を無くすため、同じ並びを2セット並べた200%幅の行列を作ります。

<div class="gallery__inner">
  <div class="strip">
    <div class="item"><img src="1.jpg" alt=""></div>
    <div class="item"><img src="2.jpg" alt=""></div>
    <div class="item wide"><img src="3.jpg" alt=""></div>
    <div class="item wide"><img src="4.jpg" alt=""></div>

    <!-- もう一周分(同じ順序で) -->
    <div class="item"><img src="1.jpg" alt=""></div>
    <div class="item"><img src="2.jpg" alt=""></div>
    <div class="item wide"><img src="3.jpg" alt=""></div>
    <div class="item wide"><img src="4.jpg" alt=""></div>
  </div>
</div>
.strip{
  width:200%;            /* 2周分 */
  height:100%;
  display:flex;
  gap:4px;
  position:relative;
}

.item{
  flex-shrink:0;         /* つぶれ防止 */
  min-width:463px;
  max-width:463px;
  height:100%;
}
.item.wide{
  max-width:700px;       /* 画像により幅に強弱を付ける */
}

/* 画像の収まり */
.item img{
  width:100%;
  height:100%;
  object-fit:cover;      /* はみ出しなくトリミング */
  object-position:center;
}

/* 高さはレスポンシブで変えやすい */
@media (max-width:768px){
  .gallery__inner{ aspect-ratio:16/10; }
  .item{ min-width:200px; max-width:200px; }
  .item.wide{ max-width:300px; }
}
  • object-fit: cover枠いっぱいに。
  • 画像ごとにobject-positionを調整すると見せたい領域を中心にできます。
  • aspect-ratioで高さを決めると波形が潰れずに済みます(高さの指定を忘れるとclipPathの比率が不安定になります)。

③ 横流しアニメーションを付与(無限ループ)

strip全体を左へ動かします。-100%(=1周分)動かすと最初に戻るので、見た目は継ぎ目なしに。

.strip{
  animation:slideLeft 50s linear infinite;
}
@keyframes slideLeft{
  0%   { transform:translateX(0); }
  100% { transform:translateX(-100%); }
}

/* モバイルは少し早めにするなど調整 */
@media (max-width:768px){
  .strip{ animation:slideLeft 30s linear infinite; }
}

画像を1セットだけにすると、端まで行った瞬間に戻る動きが見えてしまいます。
2セット並べて**「1周目が左へ消えるのと同時に、2周目が画面に入る」**状態にすると、常に連続表示になり違和感が消えます。

仕上げのコツ・よくある落とし穴

SVG参照の位置
clip-path: url(#waveClip)で参照する#waveClipは同じドキュメント内に置く。外部読み込みはSafariで失敗しがち。

高さは必ず固定 or 比率指定
clipPathは親要素の縦横比で見え方が変わります。aspect-ratioheightをきちんと指定すること。

画像の最適化
動画的に動くセクションは描画コストが高いので、画像はWebP/AVIF、適正サイズ、loading="eager"(表示直後の領域は遅延しない)などでチューニング。

はみ出し線が見える
gapに色が付くと波の縁に細いラインが見える場合があります。gapは背景色と同色(今回なら#fbf8f9に近い色)にすると目立ちません。

will-changeの使いすぎ注意
.strip{ will-change: transform; }でスクロールを滑らかにできますが、多用はメモリを圧迫します。必要な箇所だけに。

まとめ

  • 波型の枠は、SVGのclipPathを**0〜1座標(objectBoundingBox)**で描いておくとレスポンシブに強い。
  • クリップの内側に**横並び(flex)**の画像列を作り、2セット=200%幅で継ぎのない無限ループを実現。
  • aspect-ratioで高さを固定し、object-fit: coverで見切れを美しく。
  • prefers-reduced-motionへの配慮や画像最適化で実運用のクオリティを上げる。

これで、波型の中を写真がゆったり流れるヒーローセクションが再現できます。
サイトのトーンに合わせて、波の高さ速度(アニメ時間)アイテム幅を微調整してみてください。ブランドらしい空気感づくりにとても効きます。

参考:記事内で使った最小コード一式

<div class="gallery">
  <svg width="0" height="0" aria-hidden="true" style="position:absolute">
    <defs>
      <clipPath id="waveClip" clipPathUnits="objectBoundingBox">
        <path d="M0,0.12 C0.083,0.16 0.167,0.08 0.25,0.12 S0.417,0.16 0.5,0.12 S0.667,0.08 0.75,0.12 S0.917,0.16 1,0.12 V0.88 C0.917,0.84 0.833,0.92 0.75,0.88 S0.583,0.84 0.5,0.88 S0.333,0.92 0.25,0.88 S0.083,0.84 0,0.88 Z"/>
      </clipPath>
    </defs>
  </svg>

  <div class="gallery__inner">
    <div class="strip">
      <div class="item"><img src="1.jpg" alt=""></div>
      <div class="item"><img src="2.jpg" alt=""></div>
      <div class="item wide"><img src="3.jpg" alt=""></div>
      <div class="item wide"><img src="4.jpg" alt=""></div>

      <div class="item"><img src="1.jpg" alt=""></div>
      <div class="item"><img src="2.jpg" alt=""></div>
      <div class="item wide"><img src="3.jpg" alt=""></div>
      <div class="item wide"><img src="4.jpg" alt=""></div>
    </div>
  </div>
</div>
.gallery{ background:#fbf8f9; width:100%; }
.gallery__inner{
  width:100%;
  aspect-ratio:16/8;
  clip-path:url(#waveClip);
  overflow:hidden;
}
.strip{
  width:200%;
  height:100%;
  display:flex;
  gap:4px;
  animation:slideLeft 50s linear infinite;
}
.item{ flex-shrink:0; min-width:463px; max-width:463px; height:100%; }
.item.wide{ max-width:700px; }
.item img{ width:100%; height:100%; object-fit:cover; }

@keyframes slideLeft{
  0%{   transform:translateX(0); }
  100%{ transform:translateX(-100%); }
}
@media (max-width:768px){
  .gallery__inner{ aspect-ratio:16/10; }
  .strip{ animation:slideLeft 30s linear infinite; }
  .item{ min-width:200px; max-width:200px; }
  .item.wide{ max-width:300px; }
}
@media (prefers-reduced-motion: reduce){
  .strip{ animation:none; transform:none; }
}