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

スクロールで画像が縮んだ状態から広がって現れる

デモ

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

仕様

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

しくみと調整

画像を包む外枠は動かさず、中の画像の箱を半分の大きさ・大きな角丸・透明にしておき、画面に入ったら、中心を軸に元の大きさへ広げながら、角を写真の 4px へ戻します。箱の中の画像は 1.3 倍の寄りから逆に引くので、広がる窓の中で景色が落ち着いていきます。添えた番号・作品名・分野は、少し遅れて 24px 下から順に現れます。

広がりは 1.4 秒、画像の引きは 1.6 秒で、どちらも power3.out です。角の丸みは 120px から 4px へ、大きさと同じ速さで戻すので、丸い窓がそのまま写真の形へ変わっていくように見えます。文字は 0.25 秒遅らせ、0.08 秒ずつずらして上げます。

見張るのは、拡大する箱ではなく、動かない外枠です。拡大する箱を見張ると、縮んだ状態の大きさで画面に入ったかを測られ、出るのが遅れます。また、中心から広げるので、知らせを受ける線は rootMargin で画面の下を 35% 縮めて、深めに置きます。浅くすると、画像の中心がまだ画面の外にあるうちに広がりきってしまいます。

出すのは画面に入った 1 回だけなので、スクロールの位置を追い続ける ScrollTrigger は要らず、ブラウザの IntersectionObserver で足ります。同じときに線を越えた画像は 1 回の知らせにまとまって届くので、その順番に 0.15 秒ずつずらします。隠すのは大きさと不透明度だけなので、画像の説明や作品名は読み上げに届き、動きを減らす設定では最初から出たまま見せます。

縮める倍率を小さくするほど、広がる動きが大きく目を引きます。角の丸みを大きめから始めると、丸い窓から写真が現れる印象が強まります。狭い画面では画像も小さくなるので、角の丸みの始まりも小さくします。実績や制作事例のように、1 件ずつ大きな画像で見せる一覧に向いています。

コード

HTML
<section class="work" aria-labelledby="work-title">
  <p class="work__eyebrow">Recent work</p>
  <h2 class="work__title" id="work-title">新しく手がけた仕事</h2>

  <!-- 見張るのは動かない外枠(.scale-in)。広げるのは中の .scale-in__media -->
  <article class="project">
    <div class="scale-in">
      <div class="scale-in__media">
        <img src="/img/summer-market.jpg" alt="夕焼けの空に淡い太陽が浮かぶキービジュアル" width="1600" height="1000">
      </div>
    </div>
    <div class="project__text">
      <p class="project__number">01</p>
      <h3 class="project__name">夏至の市</h3>
      <p class="project__meta">ブランドアイデンティティ・2026年</p>
    </div>
  </article>

  <article class="project project--reverse">
    <div class="scale-in">
      <div class="scale-in__media">
        <img src="/img/kodama.jpg" alt="青灰色の渦の上に淡い黄色の円が浮かぶキービジュアル" width="1600" height="1000">
      </div>
    </div>
    <div class="project__text">
      <p class="project__number">02</p>
      <h3 class="project__name">こだま</h3>
      <p class="project__meta">プロダクトローンチ</p>
    </div>
  </article>

  <article class="project">
    <div class="scale-in">
      <div class="scale-in__media">
        <img src="/img/choryu.jpg" alt="緑の地に斜めの細い線が流れる誌面の表紙" width="1600" height="1000">
      </div>
    </div>
    <div class="project__text">
      <p class="project__number">03</p>
      <h3 class="project__name">潮流</h3>
      <p class="project__meta">エディトリアルデザイン</p>
    </div>
  </article>
</section>

CSS
.work {
  --bg: #111111;
  --ink: #eeece6;
  --mute-small: #a8a6a0;
  --placeholder: #262626; /* 画像が届くまでの地 */
  --radius: 4px; /* 広がりきった画像の角 */
  --radius-from: 120px; /* 縮んでいるときの角。JS が読むので px で書く */
  padding: 96px 6vw 24px;
  background: var(--bg);
  color: var(--ink);
}

.work__eyebrow {
  margin: 0 0 16px;
  font-size: 13px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mute-small);
}

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

/* 画像と文字を横に並べ、1 件ごとに左右を入れ替える */
.project {
  display: grid;
  grid-template-columns: 1.7fr 1fr;
  gap: 48px;
  align-items: end;
  margin-bottom: 96px;
}

.project--reverse { grid-template-columns: 1fr 1.7fr; }
.project--reverse .scale-in { order: 1; }
.project--reverse .project__text { text-align: right; }

/* 外枠は動かさず、中の media を中心から広げる */
.scale-in {
  position: relative;
  aspect-ratio: 16 / 10;
}

.scale-in__media {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: var(--radius);
  background: var(--placeholder);
}

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

.project__text { padding-bottom: 8px; }
.project__number { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute-small); font-variant-numeric: tabular-nums; }
.project__name { margin: 16px 0 0; font-size: clamp(32px, 3.4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.project__meta { margin: 8px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute-small); }

/* 狭い画面では画像の下に文字を置き、縮んでいるときの角も小さくする */
@media (max-width: 720px) {
  .work { --radius-from: 56px; }
  .project,
  .project--reverse { grid-template-columns: 1fr; gap: 24px; margin-bottom: 64px; }
  .project--reverse .scale-in { order: 0; }
  .project--reverse .project__text { text-align: left; }
}

JavaScript
import { gsap } from "gsap";

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

// 1 件分の出方。中の media を中心から広げながら角を写真の形へ戻し、画像は寄った所から引く。
// 終わりの値(大きさ 1・CSS の角・不透明)は CSS のままなので、from の値だけを渡す。文字は少し遅れて下から順に
function scaleIn(project, radiusFrom) {
  const frame = project.querySelector(".scale-in");
  return gsap
    .timeline({ paused: true })
    .from(frame.querySelector(".scale-in__media"), { scale: 0.5, borderRadius: radiusFrom, opacity: 0, duration: 1.4, ease: "power3.out" })
    .from(frame.querySelector("img"), { scale: 1.3, duration: 1.6, ease: "power3.out" }, 0)
    .from(project.querySelector(".project__text").children, { y: 24, opacity: 0, duration: 0.8, ease: "power3.out", stagger: 0.08 }, 0.25);
}

document.querySelectorAll(".work").forEach((section) => {
  // 動きを減らす設定では何もしない(画像は最初から広がっている)
  if (reduceMotion) return;
  // 縮んでいるときの角は CSS から読む(狭い画面では小さくしている)
  const radiusFrom = parseFloat(getComputedStyle(section).getPropertyValue("--radius-from"));
  // 隠すのは大きさと opacity だけ。出る前の画像の説明と作品名も、読み上げには届く
  const projects = [...section.querySelectorAll(".project")];
  const reveals = new Map(projects.map((project) => [project.querySelector(".scale-in"), scaleIn(project, radiusFrom)]));

  // 見張るのは、拡大する media ではなく動かない外枠。media を見張ると、縮んだ大きさで入ったかを測られて遅れる。
  // 中心から広げるので、線は深めに置く(下を 35% 縮めた画面に、枠の上端が入ったら知らせが来る)。
  // 同じときに越えた枠は 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 -35% 0px" }
  );
  reveals.forEach((_, frame) => io.observe(frame));
});

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

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