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

ヒーローの巨大な文字の穴から景色が見え、スクロールで画面いっぱいに抜ける

デモ

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

仕様

用途
ヒーロー
動作
DOM
種類
レシピ
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

紙に見立てた面から大きな文字の形を mask でくり抜き、その下に景色を敷いています。スクロールに合わせて、くり抜いた文字だけを拡大していくと、穴が広がって景色が画面いっぱいに現れます。拡大しているのは文字の形そのものなので、広がっていく穴の縁は最後まで字の輪郭のままです。

倍率は、スクロールの進み具合を掛け算の指数にして増やします。倍率をまっすぐ増やすと、近づくほど遅くなったように見えるためです。景色のほうは 1.3 倍から等倍へ引いていき、迫ってくるのではなく奥へ入っていく感じを足しています。

くぐり抜ける縦棒の中心と、画面を覆いきるまでの倍率は、実際の字形を測って決めます。測るのは fonts.ready を待ってからにしてください。読み込みの前に測ると、代わりの書体の幅で決まってしまい、縦棒から外れた場所へ向かって迫ります。

固定は CSS の sticky でも ScrollTrigger の pin でも作れますが、どちらでも、固定する区間の高さは抜けきるまでのスクロール量と同じだけ取ります。ここでは画面の 1.6 倍で、短くすると抜けきる前に固定が外れ、文字が途中の大きさのまま流れていきます。覆いきったところで紙の面は消して、拡大しきった字の縁が残らないようにします。

くぐらせる字は、縦棒のある字を選ぶと穴がきれいに広がります。語を替えるときは、倍率を増やす時間と、次の見出しが出る時刻も一緒に見直してください。旅行・アウトドア・映画・ブランドのヒーローのように、景色そのものを見せたいページに向いています。

コード

HTML
<section class="mask-hero">
  <div class="mask-hero__stage">
    <!-- 穴からのぞく景色。写真でも動画でもよい -->
    <img class="mask-hero__scene" src="/img/hero-valley.jpg" alt="" width="2560" height="1440">

    <!-- 紙の面。mask の中の黒い文字が、そのまま穴になる -->
    <svg class="mask-hero__page" viewBox="0 0 1280 720" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
      <mask id="mask-hero-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="1280" height="720">
        <rect width="1280" height="720" fill="#fff" stroke="none"></rect>
        <text class="mask-hero__word" x="640" y="430" text-anchor="middle">WILD</text>
      </mask>
      <rect class="mask-hero__paper" width="1280" height="720" mask="url(#mask-hero-cut)"></rect>
    </svg>

    <div class="mask-hero__title">
      <h1>Into the wild.</h1>
      <p>ガイド付きの山歩き、山小屋、満天の星空。6つの山域をめぐる40のルートを紹介しています。</p>
    </div>
  </div>
</section>
CSS
.mask-hero {
  --paper: #f2f1ed; /* 穴のあいた面の色 */
  --ink: #ffffff; /* 景色の上に出る文字の色 */
  --zoom: 160vh; /* 抜けきるまでのスクロール量 */
  position: relative;
  height: calc(100vh + var(--zoom));
  background-color: var(--paper);
}

/* 固定する区間。JS が動かないときも、この固定だけは効く */
.mask-hero__stage {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.mask-hero__scene,
.mask-hero__page {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.mask-hero__scene {
  object-fit: cover;
  transform: scale(1.3); /* 奥へ入っていく感じを出すため、少し寄せた所から始める */
}

.mask-hero__paper {
  fill: var(--paper);
}

/* mask の中では、黒い所が穴になる。字の大きさは viewBox の単位(画面の高さの 46%) */
.mask-hero__word {
  font-family: inherit;
  font-size: 331px;
  font-weight: 800;
  fill: #000000;
}

.mask-hero__title {
  position: absolute;
  left: 0;
  right: 0;
  top: 18vh;
  padding: 0 6vw;
  text-align: center;
  color: var(--ink);
}

.mask-hero__title h1 {
  margin: 0;
  font-size: clamp(36px, 5.6vw, 72px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.mask-hero__title p {
  max-width: 34em;
  margin: 24px auto 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
}

/* 動きを減らす設定では、抜けきった絵で止める(JS も拡大を作らない) */
@media (prefers-reduced-motion: reduce) {
  .mask-hero {
    height: 100vh;
  }

  .mask-hero__page {
    display: none;
  }

  .mask-hero__scene {
    transform: none;
  }
}
JavaScript
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

const THROUGH = 1; // くぐり抜ける字の番号(0 始まり。WILD の I)
const COVER = 1.25; // 縦棒が画面を覆いきる倍率の、さらに何倍まで拡大するか
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".mask-hero").forEach(async (hero) => {
  if (reduceMotion) return; // 抜けきった絵は CSS 側で作る
  const word = hero.querySelector(".mask-hero__word");
  const scene = hero.querySelector(".mask-hero__scene");
  const page = hero.querySelector(".mask-hero__page");
  const title = hero.querySelector(".mask-hero__title");

  // 字形が確定してから測る。読み込みの前に測ると、代わりの書体の幅で決まってしまう
  await document.fonts.ready;
  const style = getComputedStyle(word);
  const probe = document.createElement("canvas").getContext("2d");
  probe.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
  const text = word.textContent.trim();
  const glyph = probe.measureText(text[THROUGH]);
  // text-anchor が middle なので、語の左端は中心から幅の半分だけ左になる
  const left = Number(word.getAttribute("x")) - probe.measureText(text).width / 2;
  const fx = left + probe.measureText(text.slice(0, THROUGH)).width + glyph.width / 2;
  const fy = Number(word.getAttribute("y")) - glyph.actualBoundingBoxAscent / 2;
  // 字形の左右の縁のあいだが、そのまま縦棒の太さになる
  const half = (glyph.actualBoundingBoxLeft + glyph.actualBoundingBoxRight) / 2;
  const view = word.ownerSVGElement.viewBox.baseVal;
  const max = (Math.max(fx, view.width - fx, fy * 0.6, (view.height - fy) * 0.6) / half) * COVER;

  const state = { zoom: 0 };
  // 倍率は「覆いきる倍率 ^ 進み」。まっすぐ増やすと、近づくほど遅くなったように見える
  const render = () => {
    const scale = max ** state.zoom;
    word.setAttribute("transform", `translate(${fx} ${fy}) scale(${scale}) translate(${-fx} ${-fy})`);
  };

  gsap.set(title, { autoAlpha: 0, y: 30 });
  gsap
    .timeline({
      scrollTrigger: { trigger: hero, start: "top top", end: "bottom bottom", scrub: true },
      onUpdate: render,
    })
    .to(state, { zoom: 1, duration: 0.86, ease: "power1.in" }, 0)
    .to(scene, { scale: 1, duration: 0.9, ease: "power2.out" }, 0)
    .to(title, { autoAlpha: 1, y: 0, duration: 0.12 }, 0.74)
    .to(page, { autoAlpha: 0, duration: 0.06 }, 0.84); // 覆いきったら、紙そのものを消す
});

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

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