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

スクロールで画像が左からクリップで開いて現れる

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

画像を包む枠を clip-path の inset で右の縁から切り落としておき、画面に入ったら、切り落とす量を 0 へ戻して左から右へ開きます。同時に、枠の中の画像を 1.35 倍の寄りから元の大きさへ引くので、開いていく窓の奥で景色が落ち着いていくように見えます。キャプションは開き始めて 0.5 秒後に、12px 下から現れます。

開くのは 1.2 秒の power4.inOut です。出だしと止まり際がとても緩く、中ほどで一気に開くので、幕を引くような切れのある動きになります。画像の引きは 1.6 秒の expo.out で、窓が開ききったあとも少しだけ動きを残します。同じときに画面へ入った画像は、0.15 秒ずつずらして開きます。

開く向きは、inset の 4 辺(上・右・下・左)のどれを 100% から始めるかで決まります。上から開くなら下の辺を、中央から開くなら 4 辺とも 50% から始めます。下から開く形は、画面の下にはみ出した部分から開き始めて見えにくいので、スクロールで出すなら横か上からにします。枠には overflow の hidden を付け、寄せた画像がはみ出さないようにします。

出すのは画面に入った 1 回だけなので、スクロールの位置を追い続ける ScrollTrigger は要らず、ブラウザの IntersectionObserver で足ります。画面の下を 15% 縮めた範囲に枠の上端が入ると知らせが届き、同じときに入った画像は 1 回の知らせにまとまって届くので、その順番でずらします。隠すのは切り抜きと不透明度だけなので、画像の説明とキャプションは読み上げに届き、動きを減らす設定では最初から開いたまま見せます。

開く時間を短くすると切れが増し、長くすると写真をめくるような落ち着いた出方になります。寄りの倍率を上げると、開いたあとの引きが大きくなります。写真を大きく見せる記事やポートフォリオ、旅や作品の記録のように、1 枚ずつ見せたい節に向いています。

コード

HTML
<section class="journey" aria-labelledby="journey-title">
  <div class="journey__head">
    <h2 class="journey__title" id="journey-title">旅先で出会った、<br>静かな景色。</h2>
    <p class="journey__lead">ここに説明文が入ります。</p>
  </div>

  <div class="journey__grid">
    <!-- 切り抜くのは .clip-reveal の枠。中の画像は、枠の中で少し寄った所から戻る -->
    <figure class="journey__figure">
      <div class="clip-reveal">
        <img src="/img/dune-study.jpg" alt="夕暮れの砂丘と、空に浮かぶ白い太陽" width="1200" height="900">
      </div>
      <figcaption><span>01 — 砂丘</span><span>2026年</span></figcaption>
    </figure>
    <figure class="journey__figure journey__figure--tall">
      <div class="clip-reveal">
        <img src="/img/tide-pools.jpg" alt="青い潮だまりに映る丸い光" width="800" height="1000">
      </div>
      <figcaption><span>02 — 潮だまり</span><span>2026年</span></figcaption>
    </figure>
    <figure class="journey__figure journey__figure--wide">
      <div class="clip-reveal">
        <img src="/img/low-meadow.jpg" alt="なだらかに重なる緑の丘と、淡い空" width="2100" height="900">
      </div>
      <figcaption><span>03 — なだらかな草原</span><span>2026年</span></figcaption>
    </figure>
  </div>
</section>

CSS
.journey {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute-small: #6b6b6b;
  --placeholder: rgba(38, 38, 38, 0.12); /* 画像が届くまでの地 */
  padding: 96px 6vw;
  background: var(--bg);
  color: var(--ink);
}

/* 見出しとリード文は、最後の行の文字の並びでそろえる */
.journey__head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: last baseline;
  gap: 24px 48px;
  margin-bottom: 64px;
}

.journey__title {
  margin: 0;
  font-size: clamp(36px, 5vw, 64px);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
  word-break: auto-phrase;
}

.journey__lead {
  margin: 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
}

.journey__grid {
  display: grid;
  grid-template-columns: 7fr 5fr;
  gap: 64px 48px;
  align-items: start;
}

.journey__figure { margin: 0; }
.journey__figure--tall { margin-top: 176px; } /* 右の列を下げて段差をつける */
.journey__figure--wide { grid-column: 1 / -1; }

/* 切り抜く枠。寄せた画像がはみ出さないよう、枠で切る。角は立てる */
.clip-reveal {
  overflow: hidden;
  aspect-ratio: 4 / 3;
  background: var(--placeholder);
}

.journey__figure--tall .clip-reveal { aspect-ratio: 4 / 5; }
.journey__figure--wide .clip-reveal { aspect-ratio: 21 / 9; }

.clip-reveal img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.journey__figure figcaption {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin-top: 16px;
  font-size: 14px;
  line-height: 1.4;
  letter-spacing: 0.04em;
  color: var(--mute-small);
  font-variant-numeric: tabular-nums;
}

/* 狭い画面では 1 列に並べ、横長の画像は少し背を高くする */
@media (max-width: 720px) {
  .journey__grid { grid-template-columns: 1fr; gap: 48px; }
  .journey__figure--tall { margin-top: 0; }
  .journey__figure--wide .clip-reveal { aspect-ratio: 16 / 9; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const STAGGER = 0.15; // 同じときに画面へ入った画像どうしの間隔(秒)

// 1 枚分の開き方。枠を左から右へ開き、中の画像は寄った所から引く。キャプションは少し遅れて下から出す
function open(figure) {
  const frame = figure.querySelector(".clip-reveal");
  return gsap
    .timeline({ paused: true })
    // inset の 4 辺(上 右 下 左)のどれを 100% から始めるかで、開く向きが決まる(右を 100% にすると左から開く)
    .fromTo(frame, { clipPath: "inset(0% 100% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 1.2, ease: "power4.inOut" })
    .fromTo(frame.querySelector("img"), { scale: 1.35 }, { scale: 1, duration: 1.6, ease: "expo.out" }, 0)
    .fromTo(figure.querySelector("figcaption"), { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, 0.5);
}

document.querySelectorAll(".journey__grid").forEach((grid) => {
  // 動きを減らす設定では何もしない(画像は最初から開いている)
  if (reduceMotion) return;
  // 隠すのは切り抜きと opacity だけ。出る前の画像の説明とキャプションも、読み上げには届く
  const figures = [...grid.querySelectorAll(".journey__figure")];
  const reveals = new Map(figures.map((figure) => [figure.querySelector(".clip-reveal"), open(figure)]));

  // 出すのは 1 回だけなので、スクロールを追い続ける ScrollTrigger は要らない。
  // 下を 15% 縮めた画面に枠が入ったら(上端が下から 15% の線を越えたら)知らせが来る。
  // 同じときに越えた枠は 1 回の知らせにまとまって届くので、その順番でずらして開く
  const io = new IntersectionObserver(
    (entries) => {
      entries
        .filter((entry) => entry.isIntersecting)
        .forEach((entry, i) => {
          io.unobserve(entry.target);
          gsap.delayedCall(i * STAGGER, () => reveals.get(entry.target).play());
        });
    },
    { rootMargin: "0px 0px -15% 0px" }
  );
  reveals.forEach((_, frame) => io.observe(frame));
});

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

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